From b8211e9944da9e7b1c43a25e2f24c8a2425000cf Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 19 Jun 2026 09:24:13 +0200 Subject: [PATCH 1/2] fix(security): close BOLA on photo-export + NAT64 SSRF in URL guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/utils/networkValidation.test.js | 113 ++++++++++++++++++ backend/src/routes/adminPhotoExport.js | 7 +- backend/src/utils/networkValidation.js | 99 +++++++++++++-- 3 files changed, 208 insertions(+), 11 deletions(-) create mode 100644 backend/__tests__/utils/networkValidation.test.js diff --git a/backend/__tests__/utils/networkValidation.test.js b/backend/__tests__/utils/networkValidation.test.js new file mode 100644 index 00000000..2ce85027 --- /dev/null +++ b/backend/__tests__/utils/networkValidation.test.js @@ -0,0 +1,113 @@ +/** + * 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); + }); + }); +}); diff --git a/backend/src/routes/adminPhotoExport.js b/backend/src/routes/adminPhotoExport.js index 0390b5ef..421ade3f 100644 --- a/backend/src/routes/adminPhotoExport.js +++ b/backend/src/routes/adminPhotoExport.js @@ -9,6 +9,7 @@ const { body, query, validationResult } = require('express-validator'); const { db, withRetry } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder'); const { PhotoExportService } = require('../services/photoExportService'); @@ -18,7 +19,7 @@ const exportService = new PhotoExportService(); * GET /admin/photos/:eventId/filtered * Get filtered photos with pagination */ -router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [ +router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), requireEventOwnership, [ query('min_rating').optional().isFloat({ min: 0, max: 5 }), query('max_rating').optional().isFloat({ min: 0, max: 5 }), query('has_likes').optional().isBoolean(), @@ -132,7 +133,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [ * GET /admin/photos/:eventId/filter-summary * Get just the summary counts for filter UI */ -router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => { +router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => { try { const eventId = parseInt(req.params.eventId); @@ -154,7 +155,7 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view * POST /admin/photos/:eventId/export * Export selected or filtered photos */ -router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [ +router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), requireEventOwnership, [ body('photo_ids').optional().isArray(), body('photo_ids.*').optional().isInt(), body('filter').optional().isObject(), diff --git a/backend/src/utils/networkValidation.js b/backend/src/utils/networkValidation.js index e5a33984..d9b60d0b 100644 --- a/backend/src/utils/networkValidation.js +++ b/backend/src/utils/networkValidation.js @@ -63,16 +63,99 @@ function isPrivateIPv4(ip) { 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 lower = ip.toLowerCase(); - // ::1 loopback - if (lower === '::1' || lower === '0000:0000:0000:0000:0000:0000:0000:0001') return true; - // fc00::/7 — unique local - if (lower.startsWith('fc') || lower.startsWith('fd')) return true; - // fe80::/10 — link-local - if (lower.startsWith('fe80')) return true; + 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 (lower === '::') return true; + 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; } From d705059d3c2904184f037bbe0208fe128fdb9b63 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 19 Jun 2026 09:41:35 +0200 Subject: [PATCH 2/2] fix(deps): bump qs/brace-expansion overrides + add uuid override for node-cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-scanning Trivy alerts on the open beta (PR #641). Of the 10 open alerts, 6 are stale (lockfile already past the fix) or live in floating-tag base images (`nginx:1.28-alpine`, `node:22-alpine`) which auto-update on the next CI rebuild — no code change needed for those. The 3 actually present in the current `backend/package-lock.json`: - `qs 6.15.0 → 6.15.2` (CVE-2026-8723, alert #266). Bump override from `>=6.14.2` to `>=6.15.2`. - `brace-expansion 5.0.5 → 5.0.6` (CVE-2026-45149, alert #264). Bump override from `>=5.0.5` to `>=5.0.6`. - `uuid 8.3.2` transitively via `node-cron@3.0.3` (CVE-2026-41907, alert #265). Add top-level `uuid: ^11.1.1` override so node-cron's nested resolution collapses into our root uuid version. node-cron uses only `uuid.v4()` — API-stable across v8 → v11. Verified the scheduler still constructs tasks under the override. Lockfile regenerated; net -9 lines (one fewer uuid copy). Stale alerts that will close on next code-scan rebuild: - #205 postcss (frontend lockfile already at 8.5.14) - #221 i18next-http-backend (backend lockfile already at 3.0.6) Auto-resolved on next image rebuild (no Dockerfile change — floating tags): - #267 nginx (frontend `nginx:1.28-alpine`) - #223 ip-address, #156/#155 picomatch, #140 brace-expansion (all in the npm CLI shipped inside `node:22-alpine`) Refs: code-scanning alerts #264, #265, #266 --- backend/package-lock.json | 21 ++++++--------------- backend/package.json | 7 ++++--- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index ab876ed3..6f5affcb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4305,9 +4305,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -8964,15 +8964,6 @@ "node": ">=6.0.0" } }, - "node_modules/node-cron/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10259,9 +10250,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/backend/package.json b/backend/package.json index 1f1d2c6b..29c6f65b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -75,14 +75,15 @@ "glob": "^11.1.0", "js-yaml": "^4.1.1", "fast-xml-parser": ">=5.7.0", - "qs": ">=6.14.2", + "qs": ">=6.15.2", "tar": ">=7.5.13", - "brace-expansion": ">=5.0.5", + "brace-expansion": ">=5.0.6", "minimatch": ">=9.0.7", "path-to-regexp": "0.1.13", "lodash": ">=4.18.1", "follow-redirects": ">=1.16.0", "@tootallnate/once": ">=3.0.1", - "ip-address": ">=10.1.1" + "ip-address": ">=10.1.1", + "uuid": "^11.1.1" } }