From cf5a0fcee15e9d9748e7a074d2bac073b9918203 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 7 Oct 2026 13:43:52 +0530 Subject: [PATCH 1/3] =?UTF-8?q?fix(server):=20harden=20the=20production=20?= =?UTF-8?q?edge=20=E2=80=94=20framing=20headers,=20per-client=20sign-in=20?= =?UTF-8?q?limits,=20DB=20readiness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API sent only nosniff/referrer/no-store while serving bearer-equivalent signed links (file bytes, browser preview/console) that users open in a browser: any third-party page could frame them and injected markup in HTML responses ran unsandboxed. The sign-in limiter was one process-global 30/minute counter, so a single hostile client could lock every legitimate user out. /api/health always answered ok, so deploys received traffic with the database unreachable. - Route-aware security headers (security-headers.ts): strict CSP for JSON/HTML, console allowances preserved plus frame-ancestors allowlist built from the configured clients (the web app iframes console and PDFs cross-origin, so DENY/SAMEORIGIN would break it), framing-only policy on byte responses, Permissions-Policy lockdown, HSTS when publicUrl is https. - Per-client sign-in budget (10/min via the existing rate-limit store/key helpers) replacing the global counter. - GET /api/ready probing SELECT 1 with 503 fail-closed; render.yaml now gates traffic on it. /api/health semantics unchanged. - 11 regression tests in tests/server-edge.test.ts. --- apps/server/src/app.ts | 55 ++++--- apps/server/src/db.ts | 4 + apps/server/src/rate-limit.ts | 10 ++ apps/server/src/security-headers.ts | 101 +++++++++++++ render.yaml | 4 +- tests/server-edge.test.ts | 214 ++++++++++++++++++++++++++++ 6 files changed, 371 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/security-headers.ts create mode 100644 tests/server-edge.test.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 258d7a22b..c86b9ea31 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -24,7 +24,8 @@ import { AgentService } from "./engine/service.ts"; import { AppError } from "./errors.ts"; import { Files } from "./files.ts"; import { GoogleAuth } from "./google-auth.ts"; -import { rateLimit } from "./rate-limit.ts"; +import { createRateLimitStore, rateLimit, resolveRequestKey } from "./rate-limit.ts"; +import { baseSecurityHeaders, contentSecurityPolicy, securityHeaders } from "./security-headers.ts"; import { WorkspaceService } from "./workspace.ts"; export async function createApp( @@ -51,12 +52,12 @@ export async function createApp( const runtime = makeRuntime(config, agent, auth, intelligence); const app = new Hono<{ Variables: { owner: string } }>(); const origins = new Set([...config.allowedOrigins, new URL(config.publicUrl).origin]); + // Hardening first so rejected requests (origin gate, rate limits, validation) + // carry the same headers as successful ones. + app.use("*", securityHeaders(config)); app.use("*", async (c, next) => { const origin = c.req.header("origin"); if (origin && !origins.has(origin)) return c.json({ error: "Origin is not allowed" }, 403); - c.header("X-Content-Type-Options", "nosniff"); - c.header("Referrer-Policy", "no-referrer"); - c.header("Cache-Control", "no-store"); await next(); }); app.use( @@ -103,14 +104,28 @@ export async function createApp( browserConfigured: Boolean(config.workerUrl && config.workerToken), }), ); - let loginWindow = 0, - loginAttempts = 0; - app.post("/api/session", async (c) => { - if (Date.now() - loginWindow > 60000) { - loginWindow = Date.now(); - loginAttempts = 0; + app.get("/api/ready", async (c) => { + // Load balancers cannot authenticate, so readiness stays open like health — + // but unlike health it actually probes the store, so a pod whose database + // is unreachable stops receiving traffic instead of serving 500s. + try { + await db.ping(); + return c.json({ ok: true, mode: config.mode, database: "up" }); + } catch { + return c.json({ ok: false, mode: config.mode, database: "down" }, 503); } - if (++loginAttempts > 30) + }); + // Sign-in attempts are cheap for an attacker (one JSON body) and expensive to + // get wrong, so each client gets its own small bucket. The previous limiter + // was a single process-global 30/minute counter shared by every address, + // which meant one hostile client could lock every legitimate user out. + const signInAttempts = createRateLimitStore({ + windowMs: 60_000, + maxRequests: 10, + maxEntries: 5_000, + }); + app.post("/api/session", async (c) => { + if (!signInAttempts.take(resolveRequestKey(c, Boolean(config.trustProxy)), Date.now())) throw new AppError("Too many sign-in attempts. Try again in a minute.", 429); const body = z.object({ accessKey: z.string().optional() }).parse(await c.req.json()); const session = await auth.session(body.accessKey); @@ -319,10 +334,8 @@ export async function createApp( }); app.get("/api/browsers/:id/console", async (c) => { await browser.get(c.get("owner"), c.req.param("id")); - c.header( - "Content-Security-Policy", - "default-src 'self'; img-src 'self' blob:; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'", - ); + // Content-Security-Policy for this page comes from the security-headers + // middleware, which preserves these allowances and adds framing protection. return c.html(browser.console(c.get("owner"), c.req.param("id"))); }); app.post("/api/browsers/:id/console", async (c) => { @@ -345,7 +358,17 @@ export async function createApp( }, }), ); - return new Response(body, { status: response.status, headers: response.headers }); + // A returned Response bypasses context headers, so apply the edge policy + // explicitly instead of inheriting whatever the runtime happened to set. + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(baseSecurityHeaders(config))) + if (!headers.has(name)) headers.set(name, value); + if (!headers.has("Content-Security-Policy")) + headers.set( + "Content-Security-Policy", + contentSecurityPolicy(c.req.method, c.req.path, config), + ); + return new Response(body, { status: response.status, headers }); }); app.get("/", (c) => c.json({ name: "OpenMuse", app: "http://localhost:8081", health: "/api/health" }), diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 4f057b6c9..2034e548a 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -131,6 +131,10 @@ export class Store { `UPDATE records SET data=data || '{"status":"outcome_unknown","error":"Server restarted during execution. Check the provider before creating another action."}'::jsonb WHERE kind='actions' AND data->>'status'='executing'`, ); } + /** Liveness probe for the readiness endpoint; rejects when the database is unreachable. */ + async ping(): Promise { + await this.db.query("SELECT 1"); + } async take(owner: string, kind: string, id: string): Promise { const result = await this.db.query( "DELETE FROM records WHERE owner=$1 AND kind=$2 AND id=$3 RETURNING data", diff --git a/apps/server/src/rate-limit.ts b/apps/server/src/rate-limit.ts index 95c1f5ff6..e4189bd49 100644 --- a/apps/server/src/rate-limit.ts +++ b/apps/server/src/rate-limit.ts @@ -70,6 +70,16 @@ function connectionAddress(c: Context): string | undefined { } } +/** Per-client bucket key for routes with their own limiter (e.g. sign-in). */ +export function resolveRequestKey(c: Context, trustProxy: boolean): string { + return resolveClientKey({ + trustProxy, + forwardedFor: c.req.header("x-forwarded-for"), + realIp: c.req.header("x-real-ip"), + connectionAddress: connectionAddress(c), + }); +} + export function rateLimit( trustProxy: boolean, options: RateLimitStoreOptions & { getAddress?: (c: Context) => string | undefined } = {}, diff --git a/apps/server/src/security-headers.ts b/apps/server/src/security-headers.ts new file mode 100644 index 000000000..cf17e50ed --- /dev/null +++ b/apps/server/src/security-headers.ts @@ -0,0 +1,101 @@ +import type { Context } from "hono"; +import type { Config } from "./config.ts"; + +/** + * Response hardening for the API edge. + * + * The API previously sent only `X-Content-Type-Options`, `Referrer-Policy` + * and `Cache-Control`. Signed file/preview/console links are bearer-equivalent + * URLs that users open in a browser, so without framing and content policies + * any third-party page could embed them (clickjacking) and any injected markup + * in an HTML response would run unsandboxed. + * + * Two first-party embeds constrain the design, both verified in the clients: + * - `apps/mobile/src/BrowserConsole.web.tsx` loads `/api/browsers/:id/console` + * in an `