fix(security): parse private URLs with ipaddr.js
This commit is contained in:
parent
a96e0d682f
commit
91fe8f2080
|
|
@ -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()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
Loading…
Reference in New Issue