fix(security): parse private URLs with ipaddr.js

This commit is contained in:
gujishh 2026-05-21 17:40:50 +09:00
parent a96e0d682f
commit 91fe8f2080
2 changed files with 56 additions and 16 deletions

View File

@ -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()}`
)
}
}
/**

View File

@ -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')
})