Merge pull request #643 from the-luap/fix/security-ssrf-bola-trivy-sweep
fix(security): close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+6
-15
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user