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>
This commit is contained in:
95
lib/ssrf-guard.ts
Normal file
95
lib/ssrf-guard.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user