···33import { serve } from "@hono/node-server";
44import { serveStatic } from "@hono/node-server/serve-static";
55import { Hono } from "hono";
66+import { csrf } from "hono/csrf";
67import { logger } from "hono/logger";
88+import { secureHeaders } from "hono/secure-headers";
79810import { config, isLoopback, minifluxAllowed, publicBase } from "./config.js";
911import { MinifluxClient } from "./miniflux.js";
···1416import { Syncer } from "./sync.js";
1517import { RecordCache } from "./record-cache.js";
1618import { JetstreamListener } from "./jetstream.js";
1919+import { sweepOauthState, OAUTH_STATE_TTL_MS } from "./oauth-stores.js";
1720import { authRoutes } from "./routes-auth.js";
1821import { apiRoutes } from "./routes-api.js";
1922import { deviceRoutes } from "./routes-device.js";
···26292730const app = new Hono();
2831app.use(logger());
3232+app.use(
3333+ secureHeaders({
3434+ xFrameOptions: "DENY",
3535+ contentSecurityPolicy: {
3636+ frameAncestors: ["'none'"],
3737+ },
3838+ }),
3939+);
4040+4141+// CSRF guard for cookie-authenticated routes. The `/device/*` tree is Bearer-
4242+// authenticated (no cookie), so it's not CSRFable and is intentionally excluded.
4343+// In loopback/dev, the UI is served by Vite on :5173 and proxies to :8787, so we
4444+// must accept that origin too.
4545+const csrfOrigins = isLoopback()
4646+ ? [
4747+ `http://localhost:${config.port}`,
4848+ `http://127.0.0.1:${config.port}`,
4949+ "http://localhost:5173",
5050+ "http://127.0.0.1:5173",
5151+ ]
5252+ : [publicBase()];
5353+const csrfGuard = csrf({ origin: csrfOrigins });
5454+app.use("/auth/*", csrfGuard);
5555+app.use("/api/*", csrfGuard);
29563057// Production OAuth needs the client metadata document served at a stable URL.
3158if (!isLoopback()) {
···6693 }
6794 }
6895})();
9696+9797+// Drop orphaned oauth_state rows (abandoned authorize flows) now and on a
9898+// timer so the table can't grow without bound.
9999+sweepOauthState();
100100+setInterval(() => sweepOauthState(), OAUTH_STATE_TTL_MS);
6910170102// Jetstream listener for real-time cache updates.
71103const jetstream = new JetstreamListener(cache, () => oauth.listDids());
+13
src/server/oauth-stores.ts
···6464 return rows.map((r) => r.did);
6565 }
6666}
6767+6868+// OAuth state rows are written at authorize() and consumed at callback(); the
6969+// library deletes on success, but abandoned flows (closed tab, network error)
7070+// leave orphans. 10 minutes is well past any reasonable completion window.
7171+export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
7272+7373+export function sweepOauthState(maxAgeMs: number = OAUTH_STATE_TTL_MS): number {
7474+ const cutoff = Date.now() - maxAgeMs;
7575+ const res = db()
7676+ .prepare("DELETE FROM oauth_state WHERE updated_at < ?")
7777+ .run(cutoff);
7878+ return Number(res.changes ?? 0);
7979+}
+9-1
src/server/oauth.ts
···9090 }
91919292 async function revokeDid(did: string): Promise<void> {
9393- await sessionStore.del(did);
9393+ // client.revoke tells the PDS to invalidate the refresh/access tokens and
9494+ // deletes the local session row. If the PDS is unreachable we still drop
9595+ // the local session so the logout completes from the user's perspective.
9696+ try {
9797+ await client.revoke(did);
9898+ } catch (e) {
9999+ console.error(`oauth: client.revoke failed for ${did}:`, e);
100100+ await sessionStore.del(did).catch(() => {});
101101+ }
94102 deleteBrowserSessionsForDid(did);
95103 }
96104
+2-2
src/server/readability.ts
···11import { Readability } from "@mozilla/readability";
22import { parseHTML } from "linkedom";
33+import { safeFetch } from "./safe-fetch.js";
3445const BLOCK = new Set([
56 "P",
···4142export async function fetchAndExtract(
4243 url: string,
4344): Promise<{ title: string; body: string }> {
4444- const res = await fetch(url, {
4545+ const res = await safeFetch(url, {
4546 headers: {
4647 "User-Agent": "Nightshade/1.0",
4748 Accept: "text/html,application/xhtml+xml",
4849 },
4949- redirect: "follow",
5050 });
5151 if (!res.ok) throw new Error(`http ${res.status}`);
5252 const html = await res.text();
+12-1
src/server/routes-auth.ts
···1010} from "./browser-sessions.js";
1111import type { DeviceTokenStore } from "./device-tokens.js";
1212import { isLoopback } from "./config.js";
1313+import { sweepOauthState } from "./oauth-stores.js";
13141415export function authRoutes(oauth: NightshadeOAuth, tokens: DeviceTokenStore) {
1516 const app = new Hono();
···68696970 app.post("/logout", async (c) => {
7071 const id = getCookie(c, BROWSER_SESSION_COOKIE);
7171- if (id) deleteBrowserSession(id);
7272+ if (id) {
7373+ const bs = getBrowserSession(id);
7474+ if (bs) {
7575+ // Revoke at the PDS and drop every browser session for this DID — not
7676+ // just the one the cookie points at — so other tabs/devices lose access.
7777+ await oauth.revokeDid(bs.did);
7878+ } else {
7979+ deleteBrowserSession(id);
8080+ }
8181+ }
8282+ sweepOauthState();
7283 deleteCookie(c, BROWSER_SESSION_COOKIE, { path: "/" });
7384 return c.body(null, 204);
7485 });
+3-8
src/server/routes-device.ts
···140140141141function extractToken(req: Request): string | null {
142142 const auth = req.headers.get("authorization");
143143- if (auth) {
144144- const m = auth.match(/^Bearer\s+(.+)$/i);
145145- if (m) return m[1]!.trim();
146146- }
147147- const url = new URL(req.url);
148148- const q = url.searchParams.get("token");
149149- if (q) return q.trim();
150150- return null;
143143+ if (!auth) return null;
144144+ const m = auth.match(/^Bearer\s+(.+)$/i);
145145+ return m ? m[1]!.trim() : null;
151146}
152147153148function extractFromStoredHtml(html: string): string {
+90
src/server/safe-fetch.ts
···11+import { lookup } from "node:dns/promises";
22+import { isIP, isIPv4, isIPv6 } from "node:net";
33+44+const MAX_REDIRECTS = 5;
55+66+// Pre-resolve hostnames and reject private/loopback/link-local/metadata ranges
77+// before hitting the network. A DNS lookup here and a later socket lookup can
88+// race (TOCTOU) — a host can resolve publicly now and privately on the real
99+// connection. This is best-effort; pair with network-level egress filtering
1010+// where possible.
1111+export async function safeFetch(
1212+ urlStr: string,
1313+ init: RequestInit = {},
1414+): Promise<Response> {
1515+ let current = urlStr;
1616+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
1717+ const parsed = new URL(current);
1818+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1919+ throw new Error(`unsupported protocol: ${parsed.protocol}`);
2020+ }
2121+ await assertPublicHost(parsed.hostname);
2222+ const res = await fetch(current, { ...init, redirect: "manual" });
2323+ if (res.status >= 300 && res.status < 400) {
2424+ const loc = res.headers.get("location");
2525+ if (!loc) return res;
2626+ try {
2727+ res.body?.cancel();
2828+ } catch {
2929+ // ignore
3030+ }
3131+ current = new URL(loc, current).toString();
3232+ continue;
3333+ }
3434+ return res;
3535+ }
3636+ throw new Error("too many redirects");
3737+}
3838+3939+async function assertPublicHost(hostname: string): Promise<void> {
4040+ const bare = hostname.startsWith("[") && hostname.endsWith("]")
4141+ ? hostname.slice(1, -1)
4242+ : hostname;
4343+ if (isIP(bare)) {
4444+ if (isBlockedIp(bare)) throw new Error(`blocked host: ${hostname}`);
4545+ return;
4646+ }
4747+ const addrs = await lookup(bare, { all: true });
4848+ if (addrs.length === 0) throw new Error(`no address for ${hostname}`);
4949+ for (const a of addrs) {
5050+ if (isBlockedIp(a.address)) {
5151+ throw new Error(`blocked host: ${hostname} (${a.address})`);
5252+ }
5353+ }
5454+}
5555+5656+export function isBlockedIp(ip: string): boolean {
5757+ if (isIPv4(ip)) return isBlockedIpv4(ip);
5858+ if (isIPv6(ip)) return isBlockedIpv6(ip);
5959+ return true;
6060+}
6161+6262+function isBlockedIpv4(ip: string): boolean {
6363+ const parts = ip.split(".").map(Number);
6464+ if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return true;
6565+ const [a, b] = parts as [number, number, number, number];
6666+ if (a === 0) return true; // 0.0.0.0/8
6767+ if (a === 10) return true; // private 10.0.0.0/8
6868+ if (a === 127) return true; // loopback
6969+ if (a === 169 && b === 254) return true; // link-local + cloud metadata
7070+ if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12
7171+ if (a === 192 && b === 168) return true; // private 192.168.0.0/16
7272+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
7373+ if (a >= 224) return true; // multicast + reserved
7474+ return false;
7575+}
7676+7777+function isBlockedIpv6(ip: string): boolean {
7878+ const lower = ip.toLowerCase();
7979+ if (lower === "::" || lower === "::1") return true;
8080+ // Link-local fe80::/10
8181+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
8282+ // Unique local fc00::/7
8383+ if (/^f[cd][0-9a-f]{0,2}:/.test(lower)) return true;
8484+ // Multicast ff00::/8
8585+ if (lower.startsWith("ff")) return true;
8686+ // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible
8787+ const mapped = lower.match(/:(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
8888+ if (mapped && isIPv4(mapped[1]!)) return isBlockedIpv4(mapped[1]!);
8989+ return false;
9090+}