Personal save-for-later and Miniflux e-reader proxy for Xteink X4 (wip)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: CSRF

+161 -12
+32
src/server/index.ts
··· 3 3 import { serve } from "@hono/node-server"; 4 4 import { serveStatic } from "@hono/node-server/serve-static"; 5 5 import { Hono } from "hono"; 6 + import { csrf } from "hono/csrf"; 6 7 import { logger } from "hono/logger"; 8 + import { secureHeaders } from "hono/secure-headers"; 7 9 8 10 import { config, isLoopback, minifluxAllowed, publicBase } from "./config.js"; 9 11 import { MinifluxClient } from "./miniflux.js"; ··· 14 16 import { Syncer } from "./sync.js"; 15 17 import { RecordCache } from "./record-cache.js"; 16 18 import { JetstreamListener } from "./jetstream.js"; 19 + import { sweepOauthState, OAUTH_STATE_TTL_MS } from "./oauth-stores.js"; 17 20 import { authRoutes } from "./routes-auth.js"; 18 21 import { apiRoutes } from "./routes-api.js"; 19 22 import { deviceRoutes } from "./routes-device.js"; ··· 26 29 27 30 const app = new Hono(); 28 31 app.use(logger()); 32 + app.use( 33 + secureHeaders({ 34 + xFrameOptions: "DENY", 35 + contentSecurityPolicy: { 36 + frameAncestors: ["'none'"], 37 + }, 38 + }), 39 + ); 40 + 41 + // CSRF guard for cookie-authenticated routes. The `/device/*` tree is Bearer- 42 + // authenticated (no cookie), so it's not CSRFable and is intentionally excluded. 43 + // In loopback/dev, the UI is served by Vite on :5173 and proxies to :8787, so we 44 + // must accept that origin too. 45 + const csrfOrigins = isLoopback() 46 + ? [ 47 + `http://localhost:${config.port}`, 48 + `http://127.0.0.1:${config.port}`, 49 + "http://localhost:5173", 50 + "http://127.0.0.1:5173", 51 + ] 52 + : [publicBase()]; 53 + const csrfGuard = csrf({ origin: csrfOrigins }); 54 + app.use("/auth/*", csrfGuard); 55 + app.use("/api/*", csrfGuard); 29 56 30 57 // Production OAuth needs the client metadata document served at a stable URL. 31 58 if (!isLoopback()) { ··· 66 93 } 67 94 } 68 95 })(); 96 + 97 + // Drop orphaned oauth_state rows (abandoned authorize flows) now and on a 98 + // timer so the table can't grow without bound. 99 + sweepOauthState(); 100 + setInterval(() => sweepOauthState(), OAUTH_STATE_TTL_MS); 69 101 70 102 // Jetstream listener for real-time cache updates. 71 103 const jetstream = new JetstreamListener(cache, () => oauth.listDids());
+13
src/server/oauth-stores.ts
··· 64 64 return rows.map((r) => r.did); 65 65 } 66 66 } 67 + 68 + // OAuth state rows are written at authorize() and consumed at callback(); the 69 + // library deletes on success, but abandoned flows (closed tab, network error) 70 + // leave orphans. 10 minutes is well past any reasonable completion window. 71 + export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; 72 + 73 + export function sweepOauthState(maxAgeMs: number = OAUTH_STATE_TTL_MS): number { 74 + const cutoff = Date.now() - maxAgeMs; 75 + const res = db() 76 + .prepare("DELETE FROM oauth_state WHERE updated_at < ?") 77 + .run(cutoff); 78 + return Number(res.changes ?? 0); 79 + }
+9 -1
src/server/oauth.ts
··· 90 90 } 91 91 92 92 async function revokeDid(did: string): Promise<void> { 93 - await sessionStore.del(did); 93 + // client.revoke tells the PDS to invalidate the refresh/access tokens and 94 + // deletes the local session row. If the PDS is unreachable we still drop 95 + // the local session so the logout completes from the user's perspective. 96 + try { 97 + await client.revoke(did); 98 + } catch (e) { 99 + console.error(`oauth: client.revoke failed for ${did}:`, e); 100 + await sessionStore.del(did).catch(() => {}); 101 + } 94 102 deleteBrowserSessionsForDid(did); 95 103 } 96 104
+2 -2
src/server/readability.ts
··· 1 1 import { Readability } from "@mozilla/readability"; 2 2 import { parseHTML } from "linkedom"; 3 + import { safeFetch } from "./safe-fetch.js"; 3 4 4 5 const BLOCK = new Set([ 5 6 "P", ··· 41 42 export async function fetchAndExtract( 42 43 url: string, 43 44 ): Promise<{ title: string; body: string }> { 44 - const res = await fetch(url, { 45 + const res = await safeFetch(url, { 45 46 headers: { 46 47 "User-Agent": "Nightshade/1.0", 47 48 Accept: "text/html,application/xhtml+xml", 48 49 }, 49 - redirect: "follow", 50 50 }); 51 51 if (!res.ok) throw new Error(`http ${res.status}`); 52 52 const html = await res.text();
+12 -1
src/server/routes-auth.ts
··· 10 10 } from "./browser-sessions.js"; 11 11 import type { DeviceTokenStore } from "./device-tokens.js"; 12 12 import { isLoopback } from "./config.js"; 13 + import { sweepOauthState } from "./oauth-stores.js"; 13 14 14 15 export function authRoutes(oauth: NightshadeOAuth, tokens: DeviceTokenStore) { 15 16 const app = new Hono(); ··· 68 69 69 70 app.post("/logout", async (c) => { 70 71 const id = getCookie(c, BROWSER_SESSION_COOKIE); 71 - if (id) deleteBrowserSession(id); 72 + if (id) { 73 + const bs = getBrowserSession(id); 74 + if (bs) { 75 + // Revoke at the PDS and drop every browser session for this DID — not 76 + // just the one the cookie points at — so other tabs/devices lose access. 77 + await oauth.revokeDid(bs.did); 78 + } else { 79 + deleteBrowserSession(id); 80 + } 81 + } 82 + sweepOauthState(); 72 83 deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" }); 73 84 return c.body(null, 204); 74 85 });
+3 -8
src/server/routes-device.ts
··· 140 140 141 141 function extractToken(req: Request): string | null { 142 142 const auth = req.headers.get("authorization"); 143 - if (auth) { 144 - const m = auth.match(/^Bearer\s+(.+)$/i); 145 - if (m) return m[1]!.trim(); 146 - } 147 - const url = new URL(req.url); 148 - const q = url.searchParams.get("token"); 149 - if (q) return q.trim(); 150 - return null; 143 + if (!auth) return null; 144 + const m = auth.match(/^Bearer\s+(.+)$/i); 145 + return m ? m[1]!.trim() : null; 151 146 } 152 147 153 148 function extractFromStoredHtml(html: string): string {
+90
src/server/safe-fetch.ts
··· 1 + import { lookup } from "node:dns/promises"; 2 + import { isIP, isIPv4, isIPv6 } from "node:net"; 3 + 4 + const MAX_REDIRECTS = 5; 5 + 6 + // Pre-resolve hostnames and reject private/loopback/link-local/metadata ranges 7 + // before hitting the network. A DNS lookup here and a later socket lookup can 8 + // race (TOCTOU) — a host can resolve publicly now and privately on the real 9 + // connection. This is best-effort; pair with network-level egress filtering 10 + // where possible. 11 + export async function safeFetch( 12 + urlStr: string, 13 + init: RequestInit = {}, 14 + ): Promise<Response> { 15 + let current = urlStr; 16 + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { 17 + const parsed = new URL(current); 18 + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { 19 + throw new Error(`unsupported protocol: ${parsed.protocol}`); 20 + } 21 + await assertPublicHost(parsed.hostname); 22 + const res = await fetch(current, { ...init, redirect: "manual" }); 23 + if (res.status >= 300 && res.status < 400) { 24 + const loc = res.headers.get("location"); 25 + if (!loc) return res; 26 + try { 27 + res.body?.cancel(); 28 + } catch { 29 + // ignore 30 + } 31 + current = new URL(loc, current).toString(); 32 + continue; 33 + } 34 + return res; 35 + } 36 + throw new Error("too many redirects"); 37 + } 38 + 39 + async function assertPublicHost(hostname: string): Promise<void> { 40 + const bare = hostname.startsWith("[") && hostname.endsWith("]") 41 + ? hostname.slice(1, -1) 42 + : hostname; 43 + if (isIP(bare)) { 44 + if (isBlockedIp(bare)) throw new Error(`blocked host: ${hostname}`); 45 + return; 46 + } 47 + const addrs = await lookup(bare, { all: true }); 48 + if (addrs.length === 0) throw new Error(`no address for ${hostname}`); 49 + for (const a of addrs) { 50 + if (isBlockedIp(a.address)) { 51 + throw new Error(`blocked host: ${hostname} (${a.address})`); 52 + } 53 + } 54 + } 55 + 56 + export function isBlockedIp(ip: string): boolean { 57 + if (isIPv4(ip)) return isBlockedIpv4(ip); 58 + if (isIPv6(ip)) return isBlockedIpv6(ip); 59 + return true; 60 + } 61 + 62 + function isBlockedIpv4(ip: string): boolean { 63 + const parts = ip.split(".").map(Number); 64 + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return true; 65 + const [a, b] = parts as [number, number, number, number]; 66 + if (a === 0) return true; // 0.0.0.0/8 67 + if (a === 10) return true; // private 10.0.0.0/8 68 + if (a === 127) return true; // loopback 69 + if (a === 169 && b === 254) return true; // link-local + cloud metadata 70 + if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12 71 + if (a === 192 && b === 168) return true; // private 192.168.0.0/16 72 + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10 73 + if (a >= 224) return true; // multicast + reserved 74 + return false; 75 + } 76 + 77 + function isBlockedIpv6(ip: string): boolean { 78 + const lower = ip.toLowerCase(); 79 + if (lower === "::" || lower === "::1") return true; 80 + // Link-local fe80::/10 81 + if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true; 82 + // Unique local fc00::/7 83 + if (/^f[cd][0-9a-f]{0,2}:/.test(lower)) return true; 84 + // Multicast ff00::/8 85 + if (lower.startsWith("ff")) return true; 86 + // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible 87 + const mapped = lower.match(/:(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/); 88 + if (mapped && isIPv4(mapped[1]!)) return isBlockedIpv4(mapped[1]!); 89 + return false; 90 + }