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>
96 lines
2.8 KiB
TypeScript
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;
|
|
}
|