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:
19
lib/redis.ts
Normal file
19
lib/redis.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
// Upstash Redis client for serverless/Vercel compatibility.
|
||||
// For self-hosted Docker, run redis-rest-proxy or use Upstash Cloud free tier.
|
||||
// Env vars: UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
|
||||
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
|
||||
export const redis = url && token
|
||||
? new Redis({ url, token })
|
||||
: null;
|
||||
|
||||
export function ensureRedis() {
|
||||
if (!redis) {
|
||||
throw new Error('Redis is not configured. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.');
|
||||
}
|
||||
return redis;
|
||||
}
|
||||
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;
|
||||
}
|
||||
91
lib/task-store.ts
Normal file
91
lib/task-store.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ensureRedis, redis } from './redis';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
url: string;
|
||||
executeAt: number; // epoch seconds
|
||||
headers: Record<string, string>;
|
||||
payload: any;
|
||||
retries: number;
|
||||
retryInterval: number; // milliseconds
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const TASK_PREFIX = 'mortgagefi:task:';
|
||||
const TASK_ZSET = 'mortgagefi:tasks:by:time';
|
||||
const RATE_LIMIT_PREFIX = 'mortgagefi:ratelimit:';
|
||||
|
||||
export async function saveTask(task: Task): Promise<void> {
|
||||
const r = ensureRedis();
|
||||
const pipe = r.pipeline();
|
||||
pipe.set(`${TASK_PREFIX}${task.id}`, JSON.stringify(task));
|
||||
pipe.zadd(TASK_ZSET, { score: task.executeAt, member: task.id });
|
||||
await pipe.exec();
|
||||
}
|
||||
|
||||
export async function getTask(id: string): Promise<Task | null> {
|
||||
const r = ensureRedis();
|
||||
const data = await r.get<string>(`${TASK_PREFIX}${id}`);
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data) as Task;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(id: string): Promise<void> {
|
||||
const r = ensureRedis();
|
||||
const pipe = r.pipeline();
|
||||
pipe.del(`${TASK_PREFIX}${id}`);
|
||||
pipe.zrem(TASK_ZSET, id);
|
||||
await pipe.exec();
|
||||
}
|
||||
|
||||
export async function listDueTasks(before: number): Promise<Task[]> {
|
||||
const r = ensureRedis();
|
||||
const ids = await r.zrange<string[]>(TASK_ZSET, 0, before, { byScore: true });
|
||||
if (!ids || ids.length === 0) return [];
|
||||
|
||||
const tasks: Task[] = [];
|
||||
for (const id of ids) {
|
||||
const t = await getTask(id);
|
||||
if (t) tasks.push(t);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function listAllTasks(): Promise<Task[]> {
|
||||
const r = ensureRedis();
|
||||
const keys = await r.keys(`${TASK_PREFIX}*`);
|
||||
if (!keys || keys.length === 0) return [];
|
||||
|
||||
const tasks: Task[] = [];
|
||||
for (const key of keys) {
|
||||
const data = await r.get<string>(key);
|
||||
if (data) {
|
||||
try {
|
||||
tasks.push(JSON.parse(data) as Task);
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
}
|
||||
return tasks.sort((a, b) => a.executeAt - b.executeAt);
|
||||
}
|
||||
|
||||
// Simple per-IP rate limiter using Redis
|
||||
export async function checkRateLimit(ip: string, maxRequests: number, windowSeconds: number): Promise<boolean> {
|
||||
if (!redis) return true; // if redis not configured, allow (dev mode)
|
||||
const key = `${RATE_LIMIT_PREFIX}${ip}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const windowStart = now - windowSeconds;
|
||||
|
||||
const pipe = redis.pipeline();
|
||||
pipe.zremrangebyscore(key, 0, windowStart);
|
||||
pipe.zcard(key);
|
||||
pipe.zadd(key, { score: now, member: `${now}:${Math.random()}` });
|
||||
pipe.expire(key, windowSeconds + 1);
|
||||
|
||||
const results = await pipe.exec();
|
||||
const count = (results?.[1] as number) || 0;
|
||||
return count < maxRequests;
|
||||
}
|
||||
Reference in New Issue
Block a user