mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
- M7 (SSRF): add isSafePushEndpoint (https-only; reject internal/loopback/link-local/metadata IPs incl. IPv4-mapped); enforce in PushSubscribeSchema and re-check before webpush.sendNotification. + unit test. - M1 (command injection): validate tmux session names with isValidMuxName in sessionExists, killSession, and reconcileSessions before they reach a shell call site. - M5: keep the intentional /var/log + ~/logs log-tail roots (a tested feature) and document the wider read scope in docs/security-architecture.md section 5 instead of dropping it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
70 lines
3.2 KiB
TypeScript
70 lines
3.2 KiB
TypeScript
/**
|
|
* @fileoverview SSRF guard for web-push subscription endpoints (security review M7).
|
|
*
|
|
* A push `endpoint` is an attacker-suppliable URL that the server fetches via
|
|
* `webpush.sendNotification`. On the no-auth loopback default a local page (or any
|
|
* non-browser client) could register an endpoint pointing at the cloud metadata
|
|
* service (169.254.169.254) or an internal host, turning the server into an SSRF
|
|
* proxy. We require https and reject IP-literal hosts in private/loopback/
|
|
* link-local/reserved ranges. DNS-named hosts are allowed (every real push service
|
|
* — FCM, Mozilla, Apple, WNS — uses a public DNS name); this is checked both at
|
|
* subscribe time (schema) and again at send time (defense-in-depth).
|
|
*
|
|
* Note: a hostname that *resolves* to an internal IP (DNS rebinding) is not caught
|
|
* here without async resolution; the realistic, documented vector (a direct
|
|
* internal IP literal) is closed.
|
|
*/
|
|
import { isIP } from 'node:net';
|
|
|
|
/** True if `host` is an IP literal in a private, loopback, link-local, or reserved range. */
|
|
function isPrivateOrReservedIp(host: string): boolean {
|
|
const kind = isIP(host);
|
|
if (kind === 0) return false; // not an IP literal — a DNS name
|
|
|
|
if (kind === 4) {
|
|
const [a, b] = host.split('.').map(Number);
|
|
if (a === 0 || a === 10 || a === 127) return true; // unspecified, private, loopback
|
|
if (a === 169 && b === 254) return true; // link-local (incl. 169.254.169.254 metadata)
|
|
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
if (a === 192 && b === 168) return true; // private
|
|
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (RFC 6598)
|
|
if (a >= 224) return true; // multicast + reserved (224.0.0.0+)
|
|
return false;
|
|
}
|
|
|
|
// IPv6
|
|
const h = host.toLowerCase();
|
|
if (h === '::1' || h === '::') return true; // loopback, unspecified
|
|
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // fe80::/10 link-local
|
|
if (h.startsWith('fc') || h.startsWith('fd')) return true; // fc00::/7 unique-local
|
|
// IPv4-mapped (::ffff:a.b.c.d). URL/Node may normalize the dotted tail to hex
|
|
// (::ffff:7f00:1), so handle both forms and re-check the embedded IPv4.
|
|
const mappedDotted = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
if (mappedDotted) return isPrivateOrReservedIp(mappedDotted[1]);
|
|
const mappedHex = h.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
if (mappedHex) {
|
|
const hi = parseInt(mappedHex[1], 16);
|
|
const lo = parseInt(mappedHex[2], 16);
|
|
return isPrivateOrReservedIp(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Validate a web-push endpoint URL is safe to fetch server-side.
|
|
* Requires an https URL whose host is not an internal/reserved IP literal.
|
|
*/
|
|
export function isSafePushEndpoint(endpoint: string): boolean {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(endpoint);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (url.protocol !== 'https:') return false;
|
|
if (!url.hostname) return false;
|
|
// URL.hostname wraps IPv6 literals in brackets ([::1]); strip them for isIP().
|
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
return !isPrivateOrReservedIp(host);
|
|
}
|