Skip to content

Commit 4296275

Browse files
authored
fix: use allow list also for image proxy and update readme recommendations (#141)
1 parent b04417c commit 4296275

File tree

11 files changed

+311
-30
lines changed

11 files changed

+311
-30
lines changed

.env.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ NODE_ENV=development
55
# Supports wildcards for subdomains, e.g. *.example.com
66
CORS_ALLOWED_ORIGINS=http://localhost:5173
77

8+
# Number of trusted reverse proxy hops (default: 0 = disabled, max: 10)
9+
# Set to 1 when behind a single reverse proxy (e.g. nginx) so rate limiting uses the real client IP
10+
# TRUST_PROXY=0
11+
812
# Rate Limiting / Throttle Configuration
913
# THROTTLE_TTL=60000 # Time-to-live in milliseconds (default: 60000 = 1 minute)
1014
# THROTTLE_LIMIT=10 # Maximum requests per TTL window (default: 10)
@@ -62,6 +66,15 @@ NOSTR_AMB_RELAY_URL=ws://amb-relay:3334
6266
# Example: PUBLIC_BASE_URL=https://oer.example.com
6367
# PUBLIC_BASE_URL=
6468

69+
# Asset Proxy Security Configuration
70+
# Timeout for proxied asset fetches in milliseconds (default: 15000, range: 1000-30000)
71+
# ASSET_PROXY_TIMEOUT_MS=15000
72+
73+
# Comma-separated domain allowlist for asset proxy (empty = allow all)
74+
# Subdomains are matched automatically. Strongly recommended in production to prevent SSRF.
75+
# Example: ASSET_PROXY_ALLOWED_DOMAINS=arasaac.org,upload.wikimedia.org,live.staticflickr.com
76+
# ASSET_PROXY_ALLOWED_DOMAINS=
77+
6578
# RPI-Virtuell Materialpool Configuration (optional, has default)
6679
# GraphQL API endpoint for RPI-Virtuell Materialpool
6780
# Default: https://material.rpi-virtuell.de/graphql

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@ All adapters implement `SourceAdapter` from `oer-adapter-core` and normalize res
7575
| `ASSET_PROXY_ALLOWED_DOMAINS` | `''` | Comma-separated domain allowlist for asset proxy (empty = allow all). Subdomains are matched automatically |
7676
| `PUBLIC_BASE_URL` | `''` | Base URL for signed asset URLs (falls back to localhost) |
7777
| `CORS_ALLOWED_ORIGINS` | `''` | Comma-separated allowed origins (empty = allow all). Supports wildcards e.g. `*.example.com` |
78+
| `TRUST_PROXY` | `0` | Number of trusted reverse proxy hops (0 = disabled, max 10) |
7879
| `THROTTLE_TTL` | `60000` | Rate limit window (ms) |
7980
| `THROTTLE_LIMIT` | `30` | Requests per window |
81+
| `THROTTLE_BLOCK_DURATION` | `60000` | Block duration (ms) after exceeding limit |
8082

8183
## Build & Test
8284

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ pnpm add @edufeed-org/oer-finder-plugin
172172
- 🔒 **Rate Limiting** - Per-IP rate limiting for API protection
173173
- 🔌 **Extensible** - Add custom adapters to integrate any external OER API
174174

175+
> **Security Note — Nostr AMB Relay adapter:** If you plan to use the `nostr-amb-relay` adapter, it is recommended to use it in **direct-client mode** (browser-side) only. If you need to use it through the proxy server, configure **imgproxy** and set `ASSET_PROXY_ALLOWED_DOMAINS` to restrict which domains the proxy may contact. AMB relay events can contain arbitrary URLs; when the proxy fetches these server-side, a malicious event could point to internal network resources (SSRF). See the [Server Setup Guide](./docs/server-setup.md#nostr-amb-relay-security) for details.
176+
175177
## API Example
176178

177179
```bash

docs/server-setup.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ The application uses environment variables for configuration. See `.env.example`
6262
| Variable | Default | Description |
6363
|----------|---------|-------------|
6464
| `PORT` | `3000` | HTTP server port |
65+
| `NODE_ENV` | `development` | `development`, `production`, or `test` |
66+
| `CORS_ALLOWED_ORIGINS` | - | Comma-separated allowed origins (empty = allow all). Supports wildcards e.g. `*.example.com` |
67+
| `TRUST_PROXY` | `0` | Number of trusted reverse proxy hops (0 = disabled, max 10). Set to `1` when behind a single reverse proxy (e.g. nginx) so rate limiting uses the real client IP |
68+
69+
### Rate Limiting
70+
71+
| Variable | Default | Description |
72+
|----------|---------|-------------|
73+
| `THROTTLE_TTL` | `60000` | Rate limit window in milliseconds |
74+
| `THROTTLE_LIMIT` | `30` | Maximum requests per window |
75+
| `THROTTLE_BLOCK_DURATION` | `60000` | Block duration in milliseconds after exceeding the limit |
6576

6677
### Source Adapter Configuration
6778

@@ -76,7 +87,7 @@ Source adapters forward search requests to external OER sources. Adapters are en
7687

7788
| Adapter ID | Description | Additional Config |
7889
|------------|-------------|-------------------|
79-
| `nostr-amb-relay` | AMB Nostr relay for educational metadata | `NOSTR_AMB_RELAY_URL` required |
90+
| `nostr-amb-relay` | AMB Nostr relay for educational metadata | `NOSTR_AMB_RELAY_URL` required. See [security note](#nostr-amb-relay-security) |
8091
| `arasaac` | ARASAAC AAC pictograms (CC BY-NC-SA 4.0) | None required |
8192
| `openverse` | Openverse openly licensed media | None required |
8293
| `rpi-virtuell` | RPI-Virtuell religious education materials | Optional `RPI_VIRTUELL_API_URL` |
@@ -93,6 +104,12 @@ NOSTR_AMB_RELAY_URL=ws://amb-relay:3334
93104
ADAPTER_TIMEOUT_MS=5000
94105
```
95106

107+
#### Nostr AMB Relay Security
108+
109+
> **Recommendation:** If you plan to use the `nostr-amb-relay` adapter, it is recommended to use it in **direct-client mode only** (running in the browser). If you need to use it through the proxy server, **configure imgproxy** and set `ASSET_PROXY_ALLOWED_DOMAINS` to restrict which domains the proxy may contact.
110+
>
111+
> **Why:** AMB relay events can contain arbitrary URLs (e.g. thumbnails, resource links). When the proxy fetches these URLs server-side, a malicious event could include URLs pointing to internal network resources (e.g. `http://169.254.169.254/...`, `http://localhost:...`), causing the proxy to make requests to services that should not be publicly reachable (SSRF). In direct-client mode, these requests come from the user's browser, which does not have access to the server's internal network. When using imgproxy, asset fetching is handled by imgproxy's own safeguards rather than the proxy application directly.
112+
96113
When adapters are enabled:
97114
- The `source` query parameter selects which adapter to query (required)
98115
- With `source=nostr-amb-relay`: queries the AMB Nostr relay
@@ -127,6 +144,12 @@ The proxy supports optional [imgproxy](https://imgproxy.net/) integration. When
127144
- **Insecure**: Set only `IMGPROXY_BASE_URL`. URLs are generated without signatures.
128145
- **Secure**: Set all three variables. URLs are signed with HMAC-SHA256.
129146

147+
**SSRF hardening**: imgproxy fetches arbitrary source URLs server-side. While imgproxy has built-in loopback protections, [past CVEs have shown bypasses](https://github.com/imgproxy/imgproxy/security/advisories/GHSA-j2hp-6m75-v4j4). To mitigate SSRF risks:
148+
1. **Set `ASSET_PROXY_ALLOWED_DOMAINS`** — the proxy applies this allowlist *before* any URL reaches imgproxy, blocking URLs to non-allowlisted domains at the application layer.
149+
2. **Network isolation** — run imgproxy in a network segment that cannot reach internal services (e.g. cloud metadata endpoints, databases, admin interfaces). In Docker Compose, use a dedicated network with no access to internal services.
150+
3. **Keep imgproxy updated** — patches close known bypasses.
151+
4. **Use URL signing** — prevents attackers from crafting arbitrary imgproxy URLs.
152+
130153
```bash
131154
# Generate secure keys (Linux/macOS)
132155
echo $(xxd -g 2 -l 64 -p /dev/random | tr -d '\n')
@@ -167,6 +190,15 @@ ASSET_SIGNING_TTL_SECONDS=3600
167190
PUBLIC_BASE_URL=https://oer.example.com
168191
```
169192

193+
#### Asset Proxy Security
194+
195+
| Variable | Default | Description |
196+
|----------|---------|-------------|
197+
| `ASSET_PROXY_TIMEOUT_MS` | `15000` | Per-asset proxy fetch timeout in ms (range 1000–30000) |
198+
| `ASSET_PROXY_ALLOWED_DOMAINS` | - | Comma-separated domain allowlist for asset proxy (empty = allow all). Subdomains are matched automatically |
199+
200+
When the proxy fetches assets on behalf of clients, these settings control timeouts and restrict which external domains are allowed. Setting `ASSET_PROXY_ALLOWED_DOMAINS` is **strongly recommended** in production to prevent the proxy from being used to probe internal network resources (SSRF).
201+
170202
**Priority**: When both imgproxy and asset signing are configured, imgproxy takes priority for generating image URLs from source URLs. Asset signing is still used to sign adapter-provided image URLs.
171203

172204
## API Documentation

src/oer/controllers/asset-proxy.controller.spec.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { PassThrough } from 'node:stream';
77
import * as dnsPromises from 'node:dns/promises';
88
import { AssetProxyController } from './asset-proxy.controller';
99
import { AssetSigningService } from '../services/asset-signing.service';
10+
import { DomainAllowlistService } from '../services/domain-allowlist.service';
1011
import { AssetProxyEnabledGuard } from '../guards/asset-proxy-enabled.guard';
1112

1213
jest.mock('node:dns/promises', () => ({
@@ -95,14 +96,19 @@ describe('AssetProxyController', () => {
9596
controllers: [AssetProxyController],
9697
providers: [
9798
{ provide: AssetSigningService, useValue: assetSigningService },
99+
{
100+
provide: DomainAllowlistService,
101+
useValue: {
102+
isDomainAllowed: jest.fn().mockReturnValue(true),
103+
},
104+
},
98105
{
99106
provide: ConfigService,
100107
useValue: {
101108
get: jest
102109
.fn()
103110
.mockImplementation((key: string, defaultValue: unknown) => {
104111
if (key === 'app.assetProxy.timeoutMs') return 15000;
105-
if (key === 'app.assetProxy.allowedDomains') return [];
106112
return defaultValue;
107113
}),
108114
},
@@ -814,6 +820,7 @@ describe('AssetProxyController', () => {
814820
describe('AssetProxyController (domain allowlist)', () => {
815821
let controller: AssetProxyController;
816822
let assetSigningService: jest.Mocked<AssetSigningService>;
823+
let domainAllowlistService: jest.Mocked<DomainAllowlistService>;
817824
let mockFetch: jest.SpyInstance;
818825

819826
beforeEach(async () => {
@@ -824,19 +831,25 @@ describe('AssetProxyController (domain allowlist)', () => {
824831
generateSignedUrl: jest.fn(),
825832
} as unknown as jest.Mocked<AssetSigningService>;
826833

834+
domainAllowlistService = {
835+
isDomainAllowed: jest.fn().mockReturnValue(true),
836+
} as unknown as jest.Mocked<DomainAllowlistService>;
837+
827838
const module: TestingModule = await Test.createTestingModule({
828839
controllers: [AssetProxyController],
829840
providers: [
830841
{ provide: AssetSigningService, useValue: assetSigningService },
842+
{
843+
provide: DomainAllowlistService,
844+
useValue: domainAllowlistService,
845+
},
831846
{
832847
provide: ConfigService,
833848
useValue: {
834849
get: jest
835850
.fn()
836851
.mockImplementation((key: string, defaultValue: unknown) => {
837852
if (key === 'app.assetProxy.timeoutMs') return 15000;
838-
if (key === 'app.assetProxy.allowedDomains')
839-
return ['example.com', 'cdn.images.org'];
840853
return defaultValue;
841854
}),
842855
},
@@ -915,6 +928,7 @@ describe('AssetProxyController (domain allowlist)', () => {
915928
assetSigningService.verify.mockReturnValue(
916929
'https://evil.attacker.com/image.jpg',
917930
);
931+
domainAllowlistService.isDomainAllowed.mockReturnValue(false);
918932

919933
const res = createMockResponse();
920934
const req = createMockRequest();
@@ -932,10 +946,10 @@ describe('AssetProxyController (domain allowlist)', () => {
932946
);
933947
});
934948

935-
it('should not match partial domain names', async () => {
936-
assetSigningService.verify.mockReturnValue(
937-
'https://notexample.com/image.jpg',
938-
);
949+
it('should call isDomainAllowed with the verified URL', async () => {
950+
const verifiedUrl = 'https://example.com/image.jpg';
951+
assetSigningService.verify.mockReturnValue(verifiedUrl);
952+
domainAllowlistService.isDomainAllowed.mockReturnValue(false);
939953

940954
const res = createMockResponse();
941955
const req = createMockRequest();
@@ -951,5 +965,9 @@ describe('AssetProxyController (domain allowlist)', () => {
951965
),
952966
HttpStatus.FORBIDDEN,
953967
);
968+
969+
expect(domainAllowlistService.isDomainAllowed).toHaveBeenCalledWith(
970+
verifiedUrl,
971+
);
954972
});
955973
});

src/oer/controllers/asset-proxy.controller.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { Request, Response } from 'express';
2424
import { Readable, Transform } from 'node:stream';
2525
import { lookup } from 'node:dns/promises';
2626
import { AssetSigningService } from '../services/asset-signing.service';
27+
import { DomainAllowlistService } from '../services/domain-allowlist.service';
2728
import { AssetCorpExceptionFilter } from '../filters/asset-corp-exception.filter';
2829
import { AssetProxyEnabledGuard } from '../guards/asset-proxy-enabled.guard';
2930
import { isPrivateIp } from '../utils/is-private-ip';
@@ -47,20 +48,16 @@ const ALLOWED_IMAGE_TYPES = new Set([
4748
export class AssetProxyController {
4849
private readonly logger = new Logger(AssetProxyController.name);
4950
private readonly timeoutMs: number;
50-
private readonly allowedDomains: ReadonlyArray<string>;
5151

5252
constructor(
5353
private readonly assetSigningService: AssetSigningService,
54+
private readonly domainAllowlistService: DomainAllowlistService,
5455
private readonly configService: ConfigService,
5556
) {
5657
this.timeoutMs = this.configService.get<number>(
5758
'app.assetProxy.timeoutMs',
5859
15000,
5960
);
60-
this.allowedDomains = this.configService.get<string[]>(
61-
'app.assetProxy.allowedDomains',
62-
[],
63-
);
6461
}
6562

6663
@Get(':signature')
@@ -177,23 +174,17 @@ export class AssetProxyController {
177174
const parsed = new URL(url);
178175
const hostnameValue = parsed.hostname.toLowerCase();
179176

180-
if (this.allowedDomains.length > 0) {
181-
const isAllowed = this.allowedDomains.some(
182-
(domain) =>
183-
hostnameValue === domain || hostnameValue.endsWith(`.${domain}`),
177+
if (!this.domainAllowlistService.isDomainAllowed(url)) {
178+
this.logger.warn(
179+
`Blocked proxy to non-allowlisted domain: ${hostnameValue}`,
180+
);
181+
throw new HttpException(
182+
{
183+
statusCode: HttpStatus.FORBIDDEN,
184+
message: 'Domain not allowed',
185+
},
186+
HttpStatus.FORBIDDEN,
184187
);
185-
if (!isAllowed) {
186-
this.logger.warn(
187-
`Blocked proxy to non-allowlisted domain: ${hostnameValue}`,
188-
);
189-
throw new HttpException(
190-
{
191-
statusCode: HttpStatus.FORBIDDEN,
192-
message: 'Domain not allowed',
193-
},
194-
HttpStatus.FORBIDDEN,
195-
);
196-
}
197188
}
198189

199190
// Check if hostname is an IP literal

src/oer/oer.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { OerQueryService } from './services/oer-query.service';
33
import { ImgproxyService } from './services/imgproxy.service';
44
import { AssetSigningService } from './services/asset-signing.service';
55
import { AssetUrlService } from './services/asset-url.service';
6+
import { DomainAllowlistService } from './services/domain-allowlist.service';
67
import { OerController } from './controllers/oer.controller';
78
import { AssetProxyController } from './controllers/asset-proxy.controller';
89
import { AdapterModule } from '../adapter';
@@ -15,6 +16,7 @@ import { AdapterModule } from '../adapter';
1516
ImgproxyService,
1617
AssetSigningService,
1718
AssetUrlService,
19+
DomainAllowlistService,
1820
],
1921
exports: [
2022
OerQueryService,

0 commit comments

Comments
 (0)