Files
picpeak/backend/src/utils/networkValidation.js
T
Paul Nothaft b8211e9944 fix(security): close BOLA on photo-export + NAT64 SSRF in URL guard
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
2026-06-19 09:46:19 +02:00

181 lines
6.0 KiB
JavaScript

const { URL } = require('url');
const net = require('net');
/**
* Check if a hostname or IP resolves to a private/internal network address.
* Blocks SSRF attempts targeting internal infrastructure.
*/
function isPrivateIP(hostname) {
if (!hostname || typeof hostname !== 'string') return true;
const lower = hostname.toLowerCase().trim();
// Block known metadata / loopback hostnames
const blockedHostnames = [
'localhost',
'metadata.google.internal',
'metadata.google',
'169.254.169.254',
'0.0.0.0',
'::1',
'[::1]',
];
if (blockedHostnames.includes(lower)) return true;
// If it's an IP address, check ranges directly
if (net.isIPv4(lower)) {
return isPrivateIPv4(lower);
}
// IPv6 checks
if (net.isIPv6(lower) || lower.startsWith('[')) {
const cleanIp = lower.replace(/^\[|\]$/g, '');
return isPrivateIPv6(cleanIp);
}
// Hostname patterns that resolve to internal services
if (lower.endsWith('.internal') || lower.endsWith('.local') || lower.endsWith('.localhost')) {
return true;
}
return false;
}
function isPrivateIPv4(ip) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some(p => isNaN(p))) return true;
const [a, b] = parts;
// 127.0.0.0/8 — loopback
if (a === 127) return true;
// 10.0.0.0/8 — private
if (a === 10) return true;
// 172.16.0.0/12 — private
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 — private
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 — link-local
if (a === 169 && b === 254) return true;
// 0.0.0.0/8
if (a === 0) return true;
return false;
}
/**
* Expand an IPv6 address (including mixed dotted-quad notation) into its
* canonical 8-group form, each group a 4-char lowercase hex string. Returns
* null on any parse failure so callers can fail closed.
*/
function expandIPv6(ip) {
if (typeof ip !== 'string' || !net.isIPv6(ip)) return null;
let normalized = ip.toLowerCase();
// Mixed notation: trailing dotted-quad (e.g. ::ffff:192.0.2.1, 64:ff9b::169.254.169.254)
if (normalized.includes('.')) {
const lastColon = normalized.lastIndexOf(':');
const tail = normalized.slice(lastColon + 1);
const parts = tail.split('.').map(p => Number(p));
if (parts.length !== 4 || parts.some(p => !Number.isInteger(p) || p < 0 || p > 255)) {
return null;
}
const hex1 = ((parts[0] << 8) | parts[1]).toString(16).padStart(4, '0');
const hex2 = ((parts[2] << 8) | parts[3]).toString(16).padStart(4, '0');
normalized = normalized.slice(0, lastColon + 1) + hex1 + ':' + hex2;
}
let groups;
const dcIdx = normalized.indexOf('::');
if (dcIdx === -1) {
groups = normalized.split(':');
if (groups.length !== 8) return null;
} else {
const left = normalized.slice(0, dcIdx);
const right = normalized.slice(dcIdx + 2);
const leftGroups = left === '' ? [] : left.split(':');
const rightGroups = right === '' ? [] : right.split(':');
const missing = 8 - leftGroups.length - rightGroups.length;
if (missing < 0) return null;
groups = [...leftGroups, ...Array(missing).fill('0'), ...rightGroups];
}
const padded = groups.map(g => (/^[0-9a-f]{1,4}$/.test(g) ? g.padStart(4, '0') : null));
if (padded.some(g => g === null)) return null;
return padded;
}
function ipv4FromLow32(g6, g7) {
const hi = parseInt(g6, 16);
const lo = parseInt(g7, 16);
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
}
function isPrivateIPv6(ip) {
const groups = expandIPv6(ip);
// Fail closed: anything we cannot parse, we treat as private.
if (!groups) return true;
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
// :: unspecified
if (groups.every(g => g === '0000')) return true;
// ::1 loopback
if (g0 === '0000' && g1 === '0000' && g2 === '0000' && g3 === '0000'
&& g4 === '0000' && g5 === '0000' && g6 === '0000' && g7 === '0001') return true;
// fc00::/7 — unique-local (first byte 0xFC or 0xFD)
const firstByte = parseInt(g0.slice(0, 2), 16);
if (firstByte === 0xfc || firstByte === 0xfd) return true;
// fe80::/10 — link-local (first 10 bits cover fe80..febf)
const firstShort = parseInt(g0, 16);
if (firstShort >= 0xfe80 && firstShort <= 0xfebf) return true;
// 64:ff9b::/96 — NAT64 well-known prefix (RFC 6052). Translated to IPv4 at
// the NAT64 gateway, so a URL like http://[64:ff9b::a9fe:a9fe]/ reaches
// 169.254.169.254. Block the whole prefix — no legitimate outbound use.
if (g0 === '0064' && g1 === 'ff9b'
&& g2 === '0000' && g3 === '0000' && g4 === '0000' && g5 === '0000') {
return true;
}
// 64:ff9b:1::/48 — NAT64 local-use prefix (RFC 8215). Same reasoning.
if (g0 === '0064' && g1 === 'ff9b' && g2 === '0001') return true;
// ::ffff:0:0/96 — IPv4-mapped IPv6 (RFC 4291). Decode the embedded IPv4
// and re-run through the v4 check so `::ffff:127.0.0.1` and the literal
// hex form `::ffff:7f00:1` both get caught.
if (g0 === '0000' && g1 === '0000' && g2 === '0000' && g3 === '0000'
&& g4 === '0000' && g5 === 'ffff') {
return isPrivateIPv4(ipv4FromLow32(g6, g7));
}
// ::/96 — deprecated IPv4-compatible IPv6. Excludes the all-zero
// unspecified address (handled above). Decode + re-check.
if (g0 === '0000' && g1 === '0000' && g2 === '0000' && g3 === '0000'
&& g4 === '0000' && g5 === '0000' && !(g6 === '0000' && g7 === '0000')) {
return isPrivateIPv4(ipv4FromLow32(g6, g7));
}
return false;
}
/**
* Validate a URL string, rejecting private/internal targets.
* @param {string} urlString - URL to validate
* @returns {{ valid: boolean, error?: string }}
*/
function validateExternalUrl(urlString) {
try {
const parsed = new URL(urlString);
if (isPrivateIP(parsed.hostname)) {
return { valid: false, error: 'URL points to a private or internal network address' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid URL format' };
}
}
module.exports = { isPrivateIP, validateExternalUrl };