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
181 lines
6.0 KiB
JavaScript
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 };
|