mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
fix(security): re-auth on DB export/import + SSRF guard on web fetch
- /api/settings/database now requires current password (header for GET, body for POST) in addition to session; CLI-token requests exempt - add verifyDashboardPassword helper reusing login bcrypt check - profile UI prompts password via modal before export/import - /v1/web/fetch rejects internal/private/metadata targets via assertPublicUrl Refs GHSA-qvfm-67h2-2qfx, GHSA-qj3v-64wj-q825 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
// SSRF guard: block internal/private/metadata targets for server-side fetch.
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]);
|
||||
const BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"];
|
||||
|
||||
// Parse dotted IPv4 to 32-bit integer, or null if not a valid IPv4 literal.
|
||||
function ipv4ToInt(host) {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
let value = 0;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return null;
|
||||
const octet = Number(part);
|
||||
if (octet > 255) return null;
|
||||
value = value * 256 + octet;
|
||||
}
|
||||
return value >>> 0;
|
||||
}
|
||||
|
||||
// Private/reserved IPv4 ranges as [startInt, maskBits].
|
||||
const BLOCKED_V4_RANGES = [
|
||||
[ipv4ToInt("0.0.0.0"), 8],
|
||||
[ipv4ToInt("10.0.0.0"), 8],
|
||||
[ipv4ToInt("127.0.0.0"), 8],
|
||||
[ipv4ToInt("169.254.0.0"), 16],
|
||||
[ipv4ToInt("172.16.0.0"), 12],
|
||||
[ipv4ToInt("192.168.0.0"), 16],
|
||||
];
|
||||
|
||||
function isBlockedIpv4(host) {
|
||||
const ip = ipv4ToInt(host);
|
||||
if (ip === null) return false;
|
||||
return BLOCKED_V4_RANGES.some(([base, bits]) => {
|
||||
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
||||
return (ip & mask) === (base & mask);
|
||||
});
|
||||
}
|
||||
|
||||
function isBlockedIpv6(host) {
|
||||
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (h === "::1" || h === "::") return true;
|
||||
return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd");
|
||||
}
|
||||
|
||||
// Throw if URL targets a non-public host. Caller should map to 400.
|
||||
export function assertPublicUrl(rawUrl) {
|
||||
const parsed = new URL(rawUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
|
||||
if (BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked URL: internal host");
|
||||
if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked URL: internal host");
|
||||
if (isBlockedIpv4(host)) throw new Error("Blocked URL: private IP");
|
||||
if (host.includes(":") && isBlockedIpv6(host)) throw new Error("Blocked URL: private IP");
|
||||
}
|
||||
Reference in New Issue
Block a user