project-nomad/admin/tests/unit/private_url.spec.ts
Chris Sherwood ca5ec1767f
fix(security): harden assertNotPrivateUrl with ipaddr.js + host normalization
Replaces the regex blocklist in assertNotPrivateUrl with ipaddr.js range
classification and normalizes the host before checking it. Consolidates two
community proposals (#930 ipaddr.js parsing, #912 trailing-dot normalization)
into one validator so the SSRF-critical path lives in-house with full tests.

- Classify literal IPs by range (loopback / linkLocal / unspecified) via
  ipaddr.js instead of a hand-maintained regex list, which also catches
  alternate IPv4 encodings and avoids over-blocking mapped public IPs (the old
  `::ffff:` regex blocked every mapped address, including public ones). IPv4-
  mapped IPv6 is reduced to its embedded IPv4 before classification.
- Strip a trailing root dot from the host so `localhost.` / `127.0.0.1.` can't
  bypass the checks (they resolve to the same target as the dotless form, #911).
- Strip IPv6 brackets and lowercase for the localhost comparison.
- RFC1918, bare LAN hostnames (e.g. `nomad3`), and external FQDNs remain
  allowed — LAN appliances need them, and DNS rebinding is a fetch-time concern
  outside this guard's scope.

Adds a consolidated unit spec covering loopback/link-local/unspecified literals,
alternate encodings, IPv4-mapped v6, mixed-case + trailing-dot localhost, and
the allowed LAN/FQDN/mapped-public cases.

Resolves #922. Supersedes #930 and #912 (thanks @Gujiassh and @luyua9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 04:46:55 +00:00

54 lines
2.0 KiB
TypeScript

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://127.1.2.3/file.zim')
expectBlocked('http://0.0.0.0/file.zim')
// Alternate encodings normalized to 127.0.0.1 / 0.0.0.0 by the WHATWG URL parser
expectBlocked('http://0177.0.0.1/file.zim')
expectBlocked('http://2130706433/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 IPv4-mapped loopback', () => {
expectBlocked('http://[::1]/file.zim')
expectBlocked('http://[fe80::1]/file.zim')
expectBlocked('http://[::]/file.zim')
expectBlocked('http://[::ffff:127.0.0.1]/file.zim')
expectBlocked('http://[::ffff:7f00:1]/file.zim')
})
test('blocks localhost, including mixed-case and trailing-root-dot forms (#911)', () => {
expectBlocked('http://localhost/file.zim')
expectBlocked('http://LOCALHOST/file.zim')
expectBlocked('http://localhost./file.zim')
expectBlocked('http://LocalHost./file.zim')
})
test('allows RFC1918 LAN literals, bare LAN hostnames, and public FQDNs', () => {
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://nomad3/file.zim')
expectAllowed('http://my-nas.local/file.zim')
expectAllowed('https://downloads.example.com/file.zim')
// A mapped *public* IP must not be blocked
expectAllowed('http://[::ffff:8.8.8.8]/file.zim')
})