project-nomad/admin/app/validators/common.ts
Chris Sherwood 744de888cd 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>
2026-03-08 21:44:11 -07:00

112 lines
3.0 KiB
TypeScript

import vine from '@vinejs/vine'
/**
* Checks whether a URL points to a loopback or link-local address.
* Used to prevent SSRF — the server should not fetch from localhost
* or link-local/metadata endpoints (e.g. cloud instance metadata at 169.254.x.x).
*
* RFC1918 private ranges (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.
*
* Throws an error if the URL is a loopback or link-local address.
*/
export function assertNotPrivateUrl(urlString: string): void {
const parsed = new URL(urlString)
const hostname = parsed.hostname.toLowerCase()
const blockedPatterns = [
/^localhost$/,
/^127\.\d+\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
/^\[::1\]$/,
/^\[?fe80:/i, // IPv6 link-local
]
if (blockedPatterns.some((re) => re.test(hostname))) {
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
}
}
export const remoteDownloadValidator = vine.compile(
vine.object({
url: vine
.string()
.url({ require_tld: false }) // Allow LAN URLs (e.g. http://my-nas:8080/file.zim)
.trim(),
})
)
export const remoteDownloadWithMetadataValidator = vine.compile(
vine.object({
url: vine
.string()
.url({ require_tld: false }) // Allow LAN URLs
.trim(),
metadata: vine
.object({
title: vine.string().trim().minLength(1),
summary: vine.string().trim().optional(),
author: vine.string().trim().optional(),
size_bytes: vine.number().optional(),
})
.optional(),
})
)
export const remoteDownloadValidatorOptional = vine.compile(
vine.object({
url: vine
.string()
.url({ require_tld: false }) // Allow LAN URLs
.trim()
.optional(),
})
)
export const filenameParamValidator = vine.compile(
vine.object({
params: vine.object({
filename: vine.string().trim().minLength(1).maxLength(4096),
}),
})
)
export const downloadCollectionValidator = vine.compile(
vine.object({
slug: vine.string(),
})
)
export const downloadCategoryTierValidator = vine.compile(
vine.object({
categorySlug: vine.string().trim().minLength(1),
tierSlug: vine.string().trim().minLength(1),
})
)
export const selectWikipediaValidator = vine.compile(
vine.object({
optionId: vine.string().trim().minLength(1),
})
)
const resourceUpdateInfoBase = vine.object({
resource_id: vine.string().trim().minLength(1),
resource_type: vine.enum(['zim', 'map'] as const),
installed_version: vine.string().trim(),
latest_version: vine.string().trim().minLength(1),
download_url: vine.string().url({ require_tld: false }).trim(),
})
export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)
export const applyAllContentUpdatesValidator = vine.compile(
vine.object({
updates: vine
.array(resourceUpdateInfoBase)
.minLength(1),
})
)