diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index f9f4e63..1aec9b6 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -14,7 +14,7 @@ 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(/\.+$/, '') // `URL.hostname` strips the surrounding brackets from IPv6 literals // (e.g. `http://[::1]/` → hostname `::1`), so IPv6 patterns must match diff --git a/admin/tests/unit/validators/common.spec.ts b/admin/tests/unit/validators/common.spec.ts new file mode 100644 index 0000000..6325119 --- /dev/null +++ b/admin/tests/unit/validators/common.spec.ts @@ -0,0 +1,44 @@ +import { test } from '@japa/runner' + +import { assertNotPrivateUrl } from '#validators/common' + +test.group('assertNotPrivateUrl', () => { + test('rejects loopback and link-local hosts', ({ assert }) => { + for (const url of [ + 'http://localhost/file.zim', + 'http://127.0.0.1/file.zim', + 'http://0.0.0.0/file.zim', + 'http://169.254.169.254/latest/meta-data', + 'http://[::1]/file.zim', + 'http://[fe80::1]/file.zim', + 'http://[::ffff:7f00:1]/file.zim', + 'http://[::]/file.zim', + ]) { + assert.throws( + () => assertNotPrivateUrl(url), + /Download URL must not point to a loopback or link-local address/ + ) + } + }) + + test('rejects localhost with trailing root dots', ({ assert }) => { + for (const url of ['http://localhost./file.zim', 'http://LOCALHOST./file.zim']) { + assert.throws( + () => assertNotPrivateUrl(url), + /Download URL must not point to a loopback or link-local address/ + ) + } + }) + + test('allows public and LAN hosts', ({ assert }) => { + for (const url of [ + 'https://example.com/file.zim', + 'http://my-nas:8080/file.zim', + 'http://192.168.1.10/file.zim', + 'http://10.0.0.5/file.zim', + 'http://172.16.0.10/file.zim', + ]) { + assert.doesNotThrow(() => assertNotPrivateUrl(url)) + } + }) +})