b8211e9944
Two security advisories landed against the open #641 branch — bundling both because they touch independent surfaces and PR #641 is the next beta ship vehicle. **GHSA-9v4w-jrhx-g5wr (BOLA on /admin/photo-export/:eventId/*)** — the three /:eventId-scoped routes in `adminPhotoExport.js` (filtered, filter-summary, export) ran `adminAuth + requirePermission(...)` but not `requireEventOwnership`, so any non-super-admin admin/editor with photos.view (or photos.download) could enumerate + export the photos of events created by other admins — leaking `original_filename`, which routinely encodes client identity. Sibling `adminPhotos.js` applies the middleware on every :eventId route; this file was the single drift. Reporter: Wernerina. **GHSA-wmjx-pc37-272r (NAT64 SSRF in `isPrivateIPv6`)** — the old implementation did naive string-prefix checks (`startsWith('fc')`, `startsWith('fe80')`) and had zero coverage for NAT64 (`64:ff9b::/96` per RFC 6052, `64:ff9b:1::/48` per RFC 8215). On instances with NAT64/DNS64 egress, a webhook URL like `http://[64:ff9b:1::a9fe:a9fe]/` translated through the gateway and reached 169.254.169.254 — exfiltrating cloud metadata (IAM creds) into `webhook_deliveries.response_body`. Rewrote `isPrivateIPv6` to expand the address to its canonical 8-group form, block both NAT64 prefixes, decode embedded IPv4 from IPv4-mapped (`::ffff:0:0/96`) and deprecated IPv4-compatible (`::/96`) forms and re-check via `isPrivateIPv4`, and fail closed on any parse failure. Reporter: tonghuaroot. Added 34 unit tests covering: both NAT64 prefixes in hex + mixed dotted-quad notation, IPv4-mapped IPv6 hex + mixed, deprecated ::IPv4 form, legacy fc00::/fd00::/fe80::/::1/:: cases stay blocked, and public IPv6 (Google/Cloudflare/Google IPv6) negative controls stay allowed. Refs: GHSA-9v4w-jrhx-g5wr, GHSA-wmjx-pc37-272r
114 lines
3.8 KiB
JavaScript
114 lines
3.8 KiB
JavaScript
/**
|
|
* Tests for the SSRF guard in `networkValidation.js`.
|
|
*
|
|
* Regression coverage for GHSA-wmjx-pc37-272r — the original `isPrivateIPv6`
|
|
* was a string-prefix check that missed NAT64 (`64:ff9b::/96` per RFC 6052,
|
|
* `64:ff9b:1::/48` per RFC 8215), so a webhook URL like
|
|
* `http://[64:ff9b:1::a9fe:a9fe]/` could reach 169.254.169.254 on instances
|
|
* with NAT64/DNS64 egress.
|
|
*/
|
|
|
|
const { validateExternalUrl, isPrivateIP } = require('../../src/utils/networkValidation');
|
|
|
|
describe('validateExternalUrl — NAT64 + embedded-IPv4 SSRF', () => {
|
|
describe('NAT64 well-known prefix (RFC 6052, 64:ff9b::/96)', () => {
|
|
test.each([
|
|
['http://[64:ff9b::a9fe:a9fe]/latest/meta-data/', 'AWS metadata via NAT64 hex'],
|
|
['http://[64:ff9b::169.254.169.254]/', 'AWS metadata via NAT64 mixed notation'],
|
|
['http://[64:ff9b::7f00:1]/', 'loopback via NAT64'],
|
|
['http://[64:ff9b::a00:1]/', '10.0.0.1 via NAT64'],
|
|
])('blocks %s (%s)', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('NAT64 local-use prefix (RFC 8215, 64:ff9b:1::/48)', () => {
|
|
test.each([
|
|
['http://[64:ff9b:1::a9fe:a9fe]/', 'AWS metadata via local-use NAT64'],
|
|
['http://[64:ff9b:1::169.254.169.254]/', 'AWS metadata via mixed notation'],
|
|
['http://[64:ff9b:1::7f00:1]/', 'loopback via local-use NAT64'],
|
|
['http://[64:ff9b:1:abcd::1]/', 'arbitrary host inside the /48'],
|
|
])('blocks %s (%s)', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
|
|
test.each([
|
|
'http://[::ffff:127.0.0.1]/',
|
|
'http://[::ffff:7f00:1]/',
|
|
'http://[::ffff:169.254.169.254]/',
|
|
'http://[::ffff:a9fe:a9fe]/',
|
|
'http://[::ffff:10.0.0.1]/',
|
|
])('blocks %s', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('deprecated IPv4-compatible IPv6 (::/96)', () => {
|
|
test('blocks ::127.0.0.1', () => {
|
|
expect(validateExternalUrl('http://[::127.0.0.1]/').valid).toBe(false);
|
|
});
|
|
test('blocks ::169.254.169.254', () => {
|
|
expect(validateExternalUrl('http://[::169.254.169.254]/').valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('existing IPv6 private-range coverage stays intact', () => {
|
|
test.each([
|
|
'http://[::1]/',
|
|
'http://[fc00::1]/',
|
|
'http://[fd12:3456:789a::1]/',
|
|
'http://[fe80::1]/',
|
|
'http://[feb0::1]/',
|
|
'http://[::]/',
|
|
])('blocks %s', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('public IPv6 hosts stay allowed', () => {
|
|
test.each([
|
|
'https://[2001:4860:4860::8888]/',
|
|
'https://[2606:4700:4700::1111]/',
|
|
'https://[2a00:1450:4001:830::200e]/',
|
|
])('allows %s', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('existing IPv4 private-range coverage stays intact', () => {
|
|
test.each([
|
|
'http://127.0.0.1/',
|
|
'http://10.0.0.1/',
|
|
'http://172.16.0.1/',
|
|
'http://192.168.0.1/',
|
|
'http://169.254.169.254/',
|
|
'http://0.0.0.0/',
|
|
])('blocks %s', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('blocked hostnames', () => {
|
|
test.each([
|
|
'http://localhost/',
|
|
'http://metadata.google.internal/',
|
|
])('blocks %s', (url) => {
|
|
expect(validateExternalUrl(url).valid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('fail-closed parsing', () => {
|
|
test('isPrivateIP returns true for non-string', () => {
|
|
expect(isPrivateIP(null)).toBe(true);
|
|
expect(isPrivateIP(undefined)).toBe(true);
|
|
expect(isPrivateIP(42)).toBe(true);
|
|
});
|
|
test('invalid URLs are rejected', () => {
|
|
expect(validateExternalUrl('not a url').valid).toBe(false);
|
|
expect(validateExternalUrl('').valid).toBe(false);
|
|
});
|
|
});
|
|
});
|