mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-23 12:55:05 +02:00
Replace literal string matching with ipaddr.js parsing so equivalent encodings of 169.254.169.254 (::ffff:169.254.169.254, ::ffff:a9fe:a9fe,fully-expanded forms) and fd00:ec2::254 are all rejected.
204 lines
6.6 KiB
TypeScript
204 lines
6.6 KiB
TypeScript
import vine from '@vinejs/vine'
|
|
import ipaddr from 'ipaddr.js'
|
|
|
|
/**
|
|
* 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()
|
|
|
|
// `URL.hostname` strips the surrounding brackets from IPv6 literals
|
|
// (e.g. `http://[::1]/` → hostname `::1`), so IPv6 patterns must match
|
|
// the unbracketed form.
|
|
const blockedPatterns = [
|
|
/^localhost$/,
|
|
/^127\.\d+\.\d+\.\d+$/,
|
|
/^0\.0\.0\.0$/,
|
|
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
|
|
/^::1$/, // IPv6 loopback
|
|
/^fe80:/i, // IPv6 link-local
|
|
/^::ffff:/i, // IPv4-mapped IPv6 (e.g. ::ffff:7f00:1 = 127.0.0.1)
|
|
/^::$/, // IPv6 all-zeros (equivalent to 0.0.0.0)
|
|
]
|
|
|
|
if (blockedPatterns.some((re) => re.test(hostname))) {
|
|
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Narrower SSRF guard for "remote service" URLs the user points NOMAD at
|
|
* (e.g. an OpenAI-compatible endpoint like LM Studio, llama.cpp, vLLM, or a
|
|
* sibling Ollama container). Unlike `assertNotPrivateUrl`, this intentionally
|
|
* ALLOWS loopback, link-local-ish, and RFC1918 hosts because the legitimate
|
|
* target is frequently on the same host or LAN (host.docker.internal,
|
|
* the docker bridge gateway, or a LAN IP).
|
|
*
|
|
* It blocks only:
|
|
* - the cloud instance-metadata IP (169.254.169.254), to avoid leaking
|
|
* IAM creds on a misconfigured cloud VM
|
|
* - non-HTTP schemes (file:, gopher:, etc.)
|
|
*/
|
|
// Canonical cloud instance-metadata addresses. AWS, GCP, Azure, DigitalOcean,
|
|
// Oracle Cloud, and Alibaba all expose IMDS at 169.254.169.254 over IPv4;
|
|
// AWS additionally exposes it at fd00:ec2::254 over IPv6.
|
|
// Compared after `ipaddr.toNormalizedString()`, which expands IPv6 to its
|
|
// fully-zero-padded form (e.g. `fd00:ec2::254` → `fd00:ec2:0:0:0:0:0:254`).
|
|
const BLOCKED_METADATA_IPV4 = new Set(['169.254.169.254'])
|
|
const BLOCKED_METADATA_IPV6 = new Set([
|
|
ipaddr.parse('fd00:ec2::254').toNormalizedString(),
|
|
])
|
|
|
|
export function assertNotCloudMetadataUrl(urlString: string): void {
|
|
const parsed = new URL(urlString)
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
throw new Error(`URL must use http or https scheme: ${parsed.protocol}`)
|
|
}
|
|
|
|
// Node's WHATWG URL parser keeps the brackets on IPv6 literals
|
|
// (`http://[::1]/` → hostname `[::1]`), so strip them before parsing.
|
|
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
|
|
// If the hostname isn't an IP literal it's a DNS name; allow it. (DNS
|
|
// rebinding is out of scope here — would require resolving and re-checking
|
|
// at fetch time.)
|
|
if (!ipaddr.isValid(hostname)) return
|
|
|
|
let addr = ipaddr.parse(hostname)
|
|
|
|
// Unwrap IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254, ::ffff:a9fe:a9fe,
|
|
// and the fully-expanded 0:0:0:0:0:ffff:a9fe:a9fe) so the IPv4 check below
|
|
// sees the embedded address.
|
|
if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) {
|
|
addr = (addr as ipaddr.IPv6).toIPv4Address()
|
|
}
|
|
|
|
const canonical = addr.toNormalizedString()
|
|
|
|
const blocked =
|
|
addr.kind() === 'ipv4' ? BLOCKED_METADATA_IPV4 : BLOCKED_METADATA_IPV6
|
|
if (blocked.has(canonical)) {
|
|
throw new Error(`URL must not point to the cloud instance metadata endpoint: ${canonical}`)
|
|
}
|
|
}
|
|
|
|
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(),
|
|
size_bytes: vine.number().positive().optional(),
|
|
})
|
|
|
|
export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)
|
|
|
|
export const applyAllContentUpdatesValidator = vine.compile(
|
|
vine.object({
|
|
updates: vine
|
|
.array(resourceUpdateInfoBase)
|
|
.minLength(1),
|
|
})
|
|
)
|
|
|
|
// --- Map extract (regional pmtiles download) ---
|
|
|
|
// ISO 3166-1 alpha-2, 2 letters. Loose regex; CountriesService.resolveCodes
|
|
// does the authoritative check against the polygon dataset.
|
|
const countryCodeSchema = vine
|
|
.string()
|
|
.trim()
|
|
.toUpperCase()
|
|
.regex(/^[A-Z]{2}$/)
|
|
|
|
const countriesArraySchema = vine.array(countryCodeSchema).minLength(1).maxLength(300)
|
|
|
|
export const mapExtractPreflightValidator = vine.compile(
|
|
vine.object({
|
|
countries: countriesArraySchema.clone(),
|
|
maxzoom: vine.number().min(0).max(15).optional(),
|
|
})
|
|
)
|
|
|
|
export const mapExtractValidator = vine.compile(
|
|
vine.object({
|
|
countries: countriesArraySchema.clone(),
|
|
maxzoom: vine.number().min(0).max(15).optional(),
|
|
label: vine.string().trim().minLength(1).maxLength(64).optional(),
|
|
estimatedBytes: vine.number().min(0).optional(),
|
|
})
|
|
)
|