Files
mortgagefi-helper/lib/ssrf-guard.ts
Siavash Sameni 6ae581ab2e feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit
UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component
classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe
GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin
of navbar, connect button, dapp page, loan cards, settings modal, and readme.
Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched.

Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]],
callouts, Home MOC, folders Architecture/Operations/Audits) and added a
full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy
key value from the security audit example (rotate it at Schedy).

Also commits the previously-untracked server layer: app/api (cron + tasks routes)
and lib (redis, ssrf-guard, task-store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:13:53 +04:00

96 lines
2.8 KiB
TypeScript

/**
* SSRF Guard — prevents tasks from targeting internal/private addresses.
* Used when creating scheduled tasks to ensure webhooks only hit allowed targets.
*/
const BLOCKED_HOSTS = new Set([
'localhost',
'nftcache',
'schedy',
'ntfy',
'frontend',
'web',
'redis',
'db',
'postgres',
'mysql',
'mongo',
]);
const BLOCKED_HEADERS = new Set([
'host',
'content-length',
'transfer-encoding',
'connection',
'upgrade',
'proxy-authorization',
'proxy-authenticate',
]);
export function isAllowedUrl(urlStr: string): { ok: true } | { ok: false; reason: string } {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
return { ok: false, reason: 'Invalid URL' };
}
// Enforce HTTPS in production; allow HTTP only for localhost dev
const isDev = process.env.NODE_ENV === 'development';
if (parsed.protocol !== 'https:' && !(isDev && parsed.hostname === 'localhost')) {
return { ok: false, reason: 'URL must use HTTPS' };
}
const hostname = parsed.hostname.toLowerCase();
// Block internal Docker / service hostnames
if (BLOCKED_HOSTS.has(hostname)) {
return { ok: false, reason: 'Blocked internal hostname' };
}
// Block IPv4 private/reserved ranges
const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4Match) {
const octets = ipv4Match.slice(1).map(Number);
if (octets.some((o) => o > 255)) {
return { ok: false, reason: 'Invalid IP address' };
}
const [a, b, c, d] = octets;
// 10.0.0.0/8
if (a === 10) return { ok: false, reason: 'Private IP range' };
// 172.16.0.0/12
if (a === 172 && b >= 16 && b <= 31) return { ok: false, reason: 'Private IP range' };
// 192.168.0.0/16
if (a === 192 && b === 168) return { ok: false, reason: 'Private IP range' };
// 127.0.0.0/8
if (a === 127) return { ok: false, reason: 'Loopback IP' };
// 169.254.0.0/16 (link-local)
if (a === 169 && b === 254) return { ok: false, reason: 'Link-local IP' };
// 0.0.0.0/8
if (a === 0) return { ok: false, reason: 'Reserved IP' };
}
// Block IPv6 loopback / private indicators
if (hostname === '::1' || hostname === '::' || hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) {
return { ok: false, reason: 'IPv6 private/loopback' };
}
// Block metadata endpoints
if (hostname === '169.254.169.254') {
return { ok: false, reason: 'Cloud metadata endpoint' };
}
return { ok: true };
}
export function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
const lower = k.toLowerCase();
if (!BLOCKED_HEADERS.has(lower)) {
out[k] = v;
}
}
return out;
}