security: auth redesign, SSRF protection, CORS lockdown, and 13 other fixes
- Auth (#2/#3): UUID PK, 256-bit keys, SHA-256 lookup + bcrypt hash - SSRF (#1): validate URLs, block private IPs, cloud metadata endpoints - CORS (#4): lock to pingql.com origins, not wildcard - SSE limit (#6): 10 connections per monitor max - ReDoS (#7): cap $regex patterns at 200 chars - Monitor limit (#8): 100 per account default - Cookie env config (#9): secure/domain from env vars - Bearer parsing (#10): case-insensitive RFC 6750 - Pings retention (#11): 90-day pruner, hourly interval - monitors.enabled index (#12): partial index for /internal/due - Runner locking (#14): locked_until for horizontal scale safety - COALESCE nullable bug (#17): dynamic PATCH with explicit undefined checks - MONITOR_TOKEN null guard (#18): startup validation + middleware hardening - reset-key cookie fix (#16): sets new cookie in response
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { createHash } from "crypto";
|
||||
import dns from "dns/promises";
|
||||
|
||||
const BLOCKED_TLDS = [".local", ".internal", ".corp", ".lan"];
|
||||
const BLOCKED_HOSTNAMES = ["localhost", "localhost."];
|
||||
|
||||
/**
|
||||
* Checks whether an IP address is in a private/reserved range.
|
||||
*/
|
||||
function isPrivateIP(ip: string): boolean {
|
||||
// IPv4
|
||||
if (ip === "0.0.0.0") return true;
|
||||
if (ip.startsWith("127.")) return true; // 127.0.0.0/8
|
||||
if (ip.startsWith("10.")) return true; // 10.0.0.0/8
|
||||
if (ip.startsWith("192.168.")) return true; // 192.168.0.0/16
|
||||
if (ip.startsWith("169.254.")) return true; // 169.254.0.0/16 (link-local + cloud metadata)
|
||||
|
||||
// 172.16.0.0/12: 172.16.x.x – 172.31.x.x
|
||||
if (ip.startsWith("172.")) {
|
||||
const second = parseInt(ip.split(".")[1] ?? "", 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
|
||||
// IPv6
|
||||
if (ip === "::1" || ip === "::") return true;
|
||||
if (ip.toLowerCase().startsWith("fe80")) return true; // fe80::/10
|
||||
if (ip.toLowerCase().startsWith("fd00:ec2::254")) return true; // AWS EC2 metadata
|
||||
if (ip.toLowerCase() === "::ffff:127.0.0.1") return true;
|
||||
if (ip.toLowerCase().startsWith("::ffff:")) {
|
||||
// IPv4-mapped IPv6 — extract the IPv4 part and re-check
|
||||
const v4 = ip.slice(7);
|
||||
return isPrivateIP(v4);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a monitor URL is safe to fetch (not targeting internal resources).
|
||||
* Returns null if safe, or an error string if blocked.
|
||||
*/
|
||||
export async function validateMonitorUrl(url: string): Promise<string | null> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return "Invalid URL";
|
||||
}
|
||||
|
||||
// Only allow http and https
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return `Blocked scheme: ${parsed.protocol} — only http: and https: are allowed`;
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
|
||||
// Block localhost by name
|
||||
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
||||
return "Blocked hostname: localhost is not allowed";
|
||||
}
|
||||
|
||||
// Block non-public TLDs
|
||||
for (const tld of BLOCKED_TLDS) {
|
||||
if (hostname.endsWith(tld)) {
|
||||
return `Blocked TLD: ${tld} is not allowed`;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve DNS and check all IPs
|
||||
try {
|
||||
const ips: string[] = [];
|
||||
try {
|
||||
const v4 = await dns.resolve4(hostname);
|
||||
ips.push(...v4);
|
||||
} catch {}
|
||||
try {
|
||||
const v6 = await dns.resolve6(hostname);
|
||||
ips.push(...v6);
|
||||
} catch {}
|
||||
|
||||
if (ips.length === 0) {
|
||||
return "Could not resolve hostname";
|
||||
}
|
||||
|
||||
for (const ip of ips) {
|
||||
if (isPrivateIP(ip)) {
|
||||
return `Blocked: ${hostname} resolves to private/reserved IP ${ip}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "DNS resolution failed";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user