Skip to content

Commit 5d3c659

Browse files
chriscrosstalkclaude
authored andcommitted
fix(security): narrow SSRF scope to allow RFC1918 LAN addresses
NOMAD is a LAN appliance — blocking RFC1918 private ranges (10.x, 172.16-31.x, 192.168.x) would prevent users from downloading content from local network mirrors. Narrowed to only block loopback (localhost, 127.x, 0.0.0.0, ::1) and link-local (169.254.x, fe80::) addresses. Restored require_tld: false for LAN hostnames without TLDs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 75106a8 commit 5d3c659

File tree

2 files changed

+31
-49
lines changed

2 files changed

+31
-49
lines changed

admin/app/validators/common.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,39 @@
11
import vine from '@vinejs/vine'
22

33
/**
4-
* Checks whether a URL points to a private/internal network address.
5-
* Used to prevent SSRF — the server should not fetch from localhost,
6-
* private RFC1918 ranges, link-local, or cloud metadata endpoints.
4+
* Checks whether a URL points to a loopback or link-local address.
5+
* Used to prevent SSRF — the server should not fetch from localhost
6+
* or link-local/metadata endpoints (e.g. cloud instance metadata at 169.254.x.x).
77
*
8-
* Throws an error if the URL is internal/private.
8+
* RFC1918 private ranges (10.x, 172.16-31.x, 192.168.x) are intentionally
9+
* ALLOWED because NOMAD is a LAN appliance and users may host content
10+
* mirrors on their local network.
11+
*
12+
* Throws an error if the URL is a loopback or link-local address.
913
*/
1014
export function assertNotPrivateUrl(urlString: string): void {
1115
const parsed = new URL(urlString)
1216
const hostname = parsed.hostname.toLowerCase()
1317

14-
const privatePatterns = [
18+
const blockedPatterns = [
1519
/^localhost$/,
1620
/^127\.\d+\.\d+\.\d+$/,
17-
/^10\.\d+\.\d+\.\d+$/,
18-
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
19-
/^192\.168\.\d+\.\d+$/,
20-
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
2121
/^0\.0\.0\.0$/,
22+
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
2223
/^\[::1\]$/,
23-
/^\[?fe80:/i,
24-
/^\[?fd[0-9a-f]{2}:/i, // Unique local IPv6
24+
/^\[?fe80:/i, // IPv6 link-local
2525
]
2626

27-
if (privatePatterns.some((re) => re.test(hostname))) {
28-
throw new Error(`Download URL must not point to a private/internal address: ${hostname}`)
27+
if (blockedPatterns.some((re) => re.test(hostname))) {
28+
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
2929
}
3030
}
3131

3232
export const remoteDownloadValidator = vine.compile(
3333
vine.object({
3434
url: vine
3535
.string()
36-
.url()
36+
.url({ require_tld: false }) // Allow LAN URLs (e.g. http://my-nas:8080/file.zim)
3737
.trim(),
3838
})
3939
)
@@ -42,7 +42,7 @@ export const remoteDownloadWithMetadataValidator = vine.compile(
4242
vine.object({
4343
url: vine
4444
.string()
45-
.url()
45+
.url({ require_tld: false }) // Allow LAN URLs
4646
.trim(),
4747
metadata: vine
4848
.object({
@@ -59,7 +59,7 @@ export const remoteDownloadValidatorOptional = vine.compile(
5959
vine.object({
6060
url: vine
6161
.string()
62-
.url()
62+
.url({ require_tld: false }) // Allow LAN URLs
6363
.trim()
6464
.optional(),
6565
})
@@ -97,7 +97,7 @@ const resourceUpdateInfoBase = vine.object({
9797
resource_type: vine.enum(['zim', 'map'] as const),
9898
installed_version: vine.string().trim(),
9999
latest_version: vine.string().trim().minLength(1),
100-
download_url: vine.string().url().trim(),
100+
download_url: vine.string().url({ require_tld: false }).trim(),
101101
})
102102

103103
export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)

admin/docs/security-audit-v1.md

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -109,47 +109,29 @@ if (!fullPath.startsWith(path.resolve(basePath))) {
109109

110110
### 4. SSRF — Download Endpoints (HIGH)
111111

112-
**File:** `admin/app/validators/common.ts:3-12`
112+
**File:** `admin/app/validators/common.ts`
113113
**Endpoints:** `POST /api/zim/download-remote`, `POST /api/maps/download-remote`, `POST /api/maps/download-base-assets`, `POST /api/maps/download-remote-preflight`
114114

115-
The URL validator uses `require_tld: false`, allowing internal/private URLs:
116-
117-
```typescript
118-
url: vine.string().url({ require_tld: false })
119-
```
120-
121-
An attacker on the LAN can make NOMAD fetch from:
115+
The download endpoints accept user-supplied URLs and the server fetches from them. Without validation, an attacker on the LAN (or via CSRF since `shield.ts` disables CSRF protection) could make NOMAD fetch from co-located services:
122116
- `http://localhost:3306` (MySQL)
123117
- `http://localhost:6379` (Redis)
124118
- `http://169.254.169.254/` (cloud metadata — if NOMAD is ever cloud-hosted)
125-
- Any internal network service
126119

127-
**Fix:** Add a URL validation helper that rejects private/internal IPs:
120+
**Fix:** Added `assertNotPrivateUrl()` that blocks loopback and link-local addresses before any download is initiated. Called in all download controllers.
121+
122+
**Scope note:** RFC1918 private addresses (10.x, 172.16-31.x, 192.168.x) are intentionally **allowed** because NOMAD is a LAN appliance and users may host content mirrors on their local network. The `require_tld: false` VineJS option is preserved so URLs like `http://my-nas:8080/file.zim` remain valid.
128123

129124
```typescript
130-
function isPrivateUrl(urlString: string): boolean {
131-
const url = new URL(urlString)
132-
const hostname = url.hostname
133-
// Block localhost, private ranges, link-local, metadata
134-
const blocked = [
135-
/^localhost$/i,
136-
/^127\./,
137-
/^10\./,
138-
/^172\.(1[6-9]|2\d|3[01])\./,
139-
/^192\.168\./,
140-
/^169\.254\./,
141-
/^0\./,
142-
/^\[::1\]$/,
143-
/^\[fe80:/i,
144-
]
145-
return blocked.some(re => re.test(hostname))
146-
}
125+
const blockedPatterns = [
126+
/^localhost$/,
127+
/^127\.\d+\.\d+\.\d+$/,
128+
/^0\.0\.0\.0$/,
129+
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
130+
/^\[::1\]$/,
131+
/^\[?fe80:/i, // IPv6 link-local
132+
]
147133
```
148134

149-
Alternatively, maintain an allowlist of known-good download domains (e.g., `download.kiwix.org`, `github.com`, `api.projectnomad.us`).
150-
151-
**Note:** The `require_tld: false` setting was intentionally added with the comment "Allow local URLs" — but this should only be needed for dev/testing. Consider making it configurable via environment variable, or keeping the allowlist approach for production.
152-
153135
---
154136

155137
## FIX AFTER LAUNCH (Medium Priority)
@@ -186,7 +168,7 @@ The `updateSetting` endpoint validates the key against an enum, but `getSetting`
186168

187169
The `download_url` comes directly from the client request body. An attacker can supply any URL and NOMAD will download from it. The URL should be looked up server-side from the content manifest instead.
188170

189-
**Fix:** Validate `download_url` against the cached manifest, or apply the same SSRF protections as finding #4.
171+
**Fix:** Validate `download_url` against the cached manifest, or apply the same loopback/link-local protections as finding #4 (already applied in this PR).
190172

191173
---
192174

0 commit comments

Comments
 (0)