diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index f9f4e63..3c6b758 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -14,25 +14,26 @@ import ipaddr from 'ipaddr.js' */ export function assertNotPrivateUrl(urlString: string): void { const parsed = new URL(urlString) - const hostname = parsed.hostname.toLowerCase() + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '') - // `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))) { + if (hostname === 'localhost') { throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`) } + + // If this is a DNS name, allow it. DNS rebinding has to be handled at fetch + // time; this guard only classifies literal addresses supplied by users. + if (!ipaddr.isValid(hostname)) return + + let addr = ipaddr.parse(hostname) + if (addr.kind() === 'ipv6' && (addr as ipaddr.IPv6).isIPv4MappedAddress()) { + addr = (addr as ipaddr.IPv6).toIPv4Address() + } + + if (addr.range() === 'loopback' || addr.range() === 'linkLocal' || addr.range() === 'unspecified') { + throw new Error( + `Download URL must not point to a loopback or link-local address: ${addr.toNormalizedString()}` + ) + } } /** diff --git a/admin/tests/unit/private_url.spec.ts b/admin/tests/unit/private_url.spec.ts new file mode 100644 index 0000000..d111a1e --- /dev/null +++ b/admin/tests/unit/private_url.spec.ts @@ -0,0 +1,39 @@ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' + +import { assertNotPrivateUrl } from '../../app/validators/common.js' + +const expectBlocked = (url: string) => { + assert.throws(() => assertNotPrivateUrl(url), /loopback or link-local/) +} + +const expectAllowed = (url: string) => { + assert.doesNotThrow(() => assertNotPrivateUrl(url)) +} + +test('blocks loopback and unspecified IPv4 literals', () => { + expectBlocked('http://127.0.0.1/file.zim') + expectBlocked('http://0177.0.0.1/file.zim') + expectBlocked('http://2130706433/file.zim') + expectBlocked('http://0.0.0.0/file.zim') + expectBlocked('http://0/file.zim') +}) + +test('blocks link-local IPv4 literals including cloud metadata', () => { + expectBlocked('http://169.254.169.254/latest/meta-data/') + expectBlocked('http://169.254.1.1/file.zim') +}) + +test('blocks IPv6 loopback, link-local, unspecified, and mapped loopback literals', () => { + expectBlocked('http://[::1]/file.zim') + expectBlocked('http://[fe80::1]/file.zim') + expectBlocked('http://[::]/file.zim') + expectBlocked('http://[::ffff:127.0.0.1]/file.zim') +}) + +test('allows RFC1918 LAN literals and DNS names for NOMAD LAN appliances', () => { + expectAllowed('http://10.0.0.2/file.zim') + expectAllowed('http://172.16.0.2/file.zim') + expectAllowed('http://192.168.1.10/file.zim') + expectAllowed('http://my-nas.local/file.zim') +})