Files
picpeak/backend/src/utils/networkValidation.js
T
Paul Nothaft 23cd9cb680 fix: address Shannon security assessment findings (37 vulnerabilities) (#254)
Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities
identified in the Shannon security assessment (2026-03-20).

Critical fixes:
- Command injection via rsync SSH key path (INJ-VULN-01)
- Self-escalation to super_admin role (AUTHZ-VULN-11)
- Invite super_admin backdoor (AUTHZ-VULN-12)
- Handlebars SSTI in email templates (INJ-VULN-05)

Authentication hardening:
- Rate limit on share-link login (AUTH-VULN-01)
- X-Forwarded-For spoofing bypass (AUTH-VULN-02)
- reCAPTCHA fails closed when misconfigured (AUTH-VULN-03)
- Token revocation on admin/gallery logout (AUTH-VULN-04/05)
- Cookie Secure flag defaults true in production (AUTH-VULN-06)
- Remove JWT from admin login response body (AUTH-VULN-07)
- Timing-safe gallery slug validation (AUTH-VULN-09)
- Account lockout fails closed on DB error (AUTH-VULN-12)
- Session endpoint checks token revocation

Path traversal & file access:
- checksums endpoint path containment (INJ-VULN-03)
- manifest validate path containment (INJ-VULN-04)

XSS prevention:
- Block SVG data URIs in CSS sanitizer (XSS-VULN-01)
- Email preview iframe sandbox (XSS-VULN-02)
- SSR branding HTML escaping (XSS-VULN-03)
- User-Agent sanitization in feedback (XSS-VULN-04)

Authorization (IDOR):
- Event ownership middleware for all admin routes
- Cross-admin user profile read restriction (AUTHZ-VULN-10)

SSRF & infrastructure:
- Private IP validation for SMTP, S3, rsync hosts
- Replace inline JWT with standard adminAuth middleware
- CSRF Content-Type enforcement on mutating API endpoints
- CSP headers in nginx location blocks

Token revocation fix:
- Remove overly broad orWhere clause that invalidated all future tokens
- Allow empty-body POST requests (logout) in CSRF middleware

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-22 12:40:01 +01:00

98 lines
2.6 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;
}
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;
// :: unspecified
if (lower === '::') return true;
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 };