From 3c581d1fb1d92da9e00d3ff139c46f080462e6e8 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 12 Jul 2026 20:21:45 +0800 Subject: [PATCH] fix: block IPv6 loopback in backup destination URL checks Expand compressed IPv6 hostnames before the private-address allowlist so forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints. Also reject IPv4-mapped addresses written as ::ffff:hex:hex. --- scripts/security-audit-backup-endpoint.mjs | 38 ++++++++++++++ src/services/backup-config.ts | 59 ++++++++++++++++++++-- 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 scripts/security-audit-backup-endpoint.mjs diff --git a/scripts/security-audit-backup-endpoint.mjs b/scripts/security-audit-backup-endpoint.mjs new file mode 100644 index 0000000..98df727 --- /dev/null +++ b/scripts/security-audit-backup-endpoint.mjs @@ -0,0 +1,38 @@ +import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts'; +import fs from 'node:fs'; + +const scratch = process.env.SCRATCH || '.'; +const cases = [ + 'http://127.0.0.1', + 'http://169.254.169.254', + 'http://[::1]', + 'http://[0:0:0:0:0:0:0:1]', + 'http://[::2]', + 'http://[::]', + 'http://[fe80::1]', + 'http://[fc00::1]', + 'https://example.com', +]; + +const out = []; +for (const url of cases) { + try { + const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL'); + out.push({ url, allowed: true, normalized }); + } catch (e) { + out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) }); + } +} + +const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`; +fs.writeFileSync(path, JSON.stringify(out, null, 2)); +console.log(JSON.stringify(out, null, 2)); + +// Security expectation: IPv6 loopback must NOT be allowed. +const loopback = out.find((row) => row.url === 'http://[::1]'); +if (loopback?.allowed) { + console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]'); + process.exitCode = 2; +} else { + console.log('IPv6 loopback rejected as expected'); +} diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index 47b14b1..902db37 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -99,23 +99,72 @@ function isBlockedIpv4Address(octets: number[]): boolean { ); } +/** + * Expand a hostname-form IPv6 literal to eight 4-digit hextets. + * Needed so compressed forms like "::1" are not misclassified by a naive + * "first non-empty hextet" check (which would read "1" and miss loopback). + */ +function expandIpv6Address(hostname: string): string[] | null { + const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, ''); + if (!normalized.includes(':')) return null; + if (normalized.includes('.')) { + // IPv4-embedded forms are handled separately by the caller. + return null; + } + if ((normalized.match(/::/g) || []).length > 1) return null; + + const sides = normalized.split('::'); + const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : []; + const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : []; + if (left.length + right.length > 8) return null; + if (sides.length === 1 && left.length !== 8) return null; + + const missing = 8 - left.length - right.length; + if (sides.length > 1 && missing < 0) return null; + const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : []; + const parts = [...left, ...middle, ...right]; + if (parts.length !== 8) return null; + + const hextets: string[] = []; + for (const part of parts) { + if (!/^[0-9a-f]{1,4}$/i.test(part)) return null; + hextets.push(part.padStart(4, '0')); + } + return hextets; +} + function isBlockedIpv6Address(hostname: string): boolean { if (!hostname.includes(':')) return false; - const normalized = hostname.toLowerCase(); - const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + + // IPv4-mapped dotted form: ::ffff:127.0.0.1 + const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i); if (mappedIpv4) { const octets = parseIpv4Address(mappedIpv4[1]); return !octets || isBlockedIpv4Address(octets); } - const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0'; - const firstHextet = Number.parseInt(firstHextetText, 16); + + // IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1 + const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (mappedHex) { + const hi = Number.parseInt(mappedHex[1], 16); + const lo = Number.parseInt(mappedHex[2], 16); + if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true; + const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff]; + return isBlockedIpv4Address(octets); + } + + const hextets = expandIpv6Address(normalized); + if (!hextets) return true; + const firstHextet = Number.parseInt(hextets[0], 16); if (!Number.isFinite(firstHextet)) return true; + // After expansion, loopback (::1) and unspecified (::) have first hextet 0. return ( firstHextet === 0 || (firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80 || (firstHextet & 0xff00) === 0xff00 || - normalized.startsWith('2001:db8:') + hextets.join(':').startsWith('2001:0db8:') ); }