diff --git a/.env.example b/.env.example index 4ffae83bb..f55df2038 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ DATA_DIR=.openmuse TASK_WORKER_ENABLED=true # Rate limiting keys on the socket address. Set true only behind a reverse proxy that # overwrites X-Forwarded-For; otherwise callers can spoof it. +# The per-client sign-in budget (10/min) and shared API budget depend on this: +# behind a proxy without TRUST_PROXY=true every user shares one bucket. # TRUST_PROXY=false # General delegated work: select AGENT_BACKEND=model and a provider/model. diff --git a/apps/mobile/src/PdfReader.web.tsx b/apps/mobile/src/PdfReader.web.tsx index 017fdf562..4eccbb0de 100644 --- a/apps/mobile/src/PdfReader.web.tsx +++ b/apps/mobile/src/PdfReader.web.tsx @@ -44,6 +44,9 @@ export default function PdfReader({ url, pageCount }: PdfReaderProps) { key={`${page}:${zoom}`} title="PDF document reader" src={`${url}#page=${page}&zoom=${zoom}`} + // The file-download Permissions-Policy leaves fullscreen to this + // origin; the iframe still needs the delegation for the viewer button. + allow="fullscreen" style={{ height: 570, width: "100%", border: 0, borderRadius: 12, background: "#e7e9e3" }} /> Use the reader toolbar to download or print a copy. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 258d7a22b..bf130264e 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 { createEdgeHeaders, isSignedRoute } from "./security-headers.ts"; import { WorkspaceService } from "./workspace.ts"; export async function createApp( @@ -51,12 +52,16 @@ 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]); + // One prebuilt policy for both response paths: the middleware below and the + // CopilotKit raw-Response passthrough share headersFor(), so a change to one + // cannot silently miss the other. + const edgeHeaders = createEdgeHeaders(config); + // Hardening first so rejected requests (origin gate, rate limits, validation) + // carry the same headers as successful ones. + app.use("*", edgeHeaders.middleware); 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( @@ -75,6 +80,53 @@ export async function createApp( onError: (c) => c.json({ error: "Request is too large; PDFs must be 10 MB or smaller" }, 413), }), ); + // Health probes stay outside the shared budget so load-balancer checks are + // never turned away on a busy minute: they are registered before the + // `/api/*` limiter, which is the single exemption mechanism. + app.get("/api/health", (c) => + c.json({ + ok: true, + mode: config.mode, + agentConfigured: agentConfigured(config), + browserConfigured: Boolean(config.workerUrl && config.workerToken), + }), + ); + // Coalesced probe: PGlite runs one query at a time, so concurrent Render + // checks share a single in-flight ping instead of queueing behind each other + // and making recovery slower. The per-request timeout still bounds the HTTP + // response; the shared ping keeps waiting once and is reused, never piled up. + let readyInFlight: Promise | null = null; + app.get("/api/ready", async (c) => { + // Load balancers cannot authenticate, so readiness stays open like health. + // With the default embedded PGlite store this only proves the process is + // alive; with DATABASE_URL set it proves Postgres actually answers. + // PGlite runs one query at a time, so bound the probe: a slow query + // elsewhere must not make Render restart a healthy server. + let timer: ReturnType | undefined; + const timeout = new Promise( + (_, reject) => (timer = setTimeout(() => reject(new Error("readiness timeout")), 1500)), + ); + // Attach a noop handler up front so the rejection is never unhandled when + // the probe wins the race and the timer fires later (cleared below anyway). + timeout.catch(() => {}); + try { + if (!readyInFlight) { + readyInFlight = db.ping().finally(() => { + readyInFlight = null; + }); + } + const probe = readyInFlight; + // A late timer rejection after the probe wins must not surface as an + // unhandled rejection on the shared promise. + probe.catch(() => {}); + await Promise.race([probe, timeout]); + return c.json({ ok: true, mode: config.mode, database: "up" }); + } catch { + return c.json({ ok: false, mode: config.mode, database: "down" }, 503); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + }); app.use("/api/*", rateLimit(Boolean(config.trustProxy))); app.onError((error, c) => { if (error instanceof z.ZodError) @@ -95,23 +147,30 @@ export async function createApp( 502, ); }); - app.get("/api/health", (c) => - c.json({ - ok: true, - mode: config.mode, - agentConfigured: agentConfigured(config), - browserConfigured: Boolean(config.workerUrl && config.workerToken), - }), - ); - let loginWindow = 0, - loginAttempts = 0; + // Sign-in attempts are cheap for an attacker (one JSON body) and expensive to + // get wrong, so each client gets its own small bucket (10/min). A hard + // overall cap would pull against that goal — one address burning through it + // would lock everyone else out — so the flood monitor below only logs a + // warning instead of blocking. It counts only attempts that pass the + // per-client check, so already-rejected floods cannot consume it. Per-client + // keys depend on TRUST_PROXY: behind a proxy without it, every user shares + // one bucket, so deploy with TRUST_PROXY=true (see render.yaml / .env.example). + const signInPerClient = createRateLimitStore({ + windowMs: 60_000, + maxRequests: 10, + maxEntries: 5_000, + }); + const signInFlood = createRateLimitStore({ + windowMs: 60_000, + maxRequests: 100, + maxEntries: 2, + }); app.post("/api/session", async (c) => { - if (Date.now() - loginWindow > 60000) { - loginWindow = Date.now(); - loginAttempts = 0; - } - if (++loginAttempts > 30) + const now = Date.now(); + if (!signInPerClient.take(resolveRequestKey(c, Boolean(config.trustProxy)), now)) throw new AppError("Too many sign-in attempts. Try again in a minute.", 429); + if (!signInFlood.take("global", now)) + console.warn("[OpenMuse] sign-in flood: over 100 attempts in a minute"); const body = z.object({ accessKey: z.string().optional() }).parse(await c.req.json()); const session = await auth.session(body.accessKey); await workspace.ensureSample("local-user", actions); @@ -131,10 +190,9 @@ export async function createApp( ); }); app.use("/api/*", async (c, next) => { - const signedRoute = - /^\/api\/files\/[^/]+\/content$|^\/api\/browsers\/[^/]+\/(?:preview|console)$/.test( - c.req.path, - ); + // Shared with security-headers.ts so a new signed route cannot get the + // strictest CSP by forgetting one of the two lists. + const signedRoute = isSignedRoute(c.req.path); const owner = signedRoute && c.req.query("signature") ? auth.verify(new URL(c.req.url)) @@ -319,10 +377,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 +401,13 @@ export async function createApp( }, }), ); - return new Response(body, { status: response.status, headers: response.headers }); + // A returned Response bypasses context headers, so apply the same edge + // policy the middleware sets instead of inheriting whatever the runtime + // happened to set. + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(edgeHeaders.headersFor(c.req.method, c.req.path))) + if (!headers.has(name)) headers.set(name, value); + 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..2b5777d76 100644 --- a/apps/server/src/rate-limit.ts +++ b/apps/server/src/rate-limit.ts @@ -26,17 +26,38 @@ export function createRateLimitStore(options: RateLimitStoreOptions = {}) { } function take(key: string, now: number): boolean { - let entry = entries.get(key); - if (entry && entry.expiresAt <= now) entry = undefined; - if (!entry) { + const existing = entries.get(key); + if (existing && existing.expiresAt > now) { + // Active hit: refresh recency so eviction drops the + // least-recently-active bucket, not merely the earliest-inserted one. + existing.count += 1; + entries.delete(key); + entries.set(key, existing); + return existing.count <= maxRequests; + } + // New key or expired renewal. Renewals must move to the back: Map.set() on + // an existing key keeps its original (front) position, so an actively + // signing-in client would otherwise be evicted before long-gone entries. + const hadExpired = existing !== undefined; + if (hadExpired) { + entries.delete(key); + // The window turned over for this key, so peers likely expired too: + // reclaim them now instead of leaving stale buckets behind. + cleanupExpired(now); + } + if (entries.size >= maxEntries) { + cleanupExpired(now); if (entries.size >= maxEntries) { - cleanupExpired(now); - if (entries.size >= maxEntries) return false; + // Evict the least-recently-used entry instead of locking new keys out. + // Failing closed here would reintroduce the lockout a per-client + // limiter is meant to prevent (one crowded window blocks newcomers). + const oldest = entries.keys().next(); + if (!oldest.done) entries.delete(oldest.value); + else return false; } - entry = { count: 0, expiresAt: now + windowMs }; - entries.set(key, entry); } - entry.count += 1; + const entry = { count: 1, expiresAt: now + windowMs }; + entries.set(key, entry); return entry.count <= maxRequests; } @@ -45,6 +66,8 @@ export function createRateLimitStore(options: RateLimitStoreOptions = {}) { // This limiter runs before authentication, so the key must never be derived from // caller-supplied credentials (e.g. Authorization): any string would mint a new bucket. +// When trusted, the client address is the hop our own proxy appended (the last +// entry in X-Forwarded-For), not the leftmost entry, which any caller can spoof. export function resolveClientKey(input: { trustProxy: boolean; forwardedFor?: string; @@ -52,8 +75,12 @@ export function resolveClientKey(input: { connectionAddress?: string; }): string { if (input.trustProxy) { - const forwarded = input.forwardedFor?.split(",")[0]?.trim(); - if (forwarded) return `proxy:${forwarded}`; + const hops = input.forwardedFor + ?.split(",") + .map((hop) => hop.trim()) + .filter(Boolean); + const lastHop = hops?.length ? hops[hops.length - 1] : undefined; + if (lastHop) return `proxy:${lastHop}`; if (input.realIp) return `proxy:${input.realIp}`; } return `conn:${input.connectionAddress ?? "unknown"}`; @@ -70,6 +97,20 @@ 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, + getAddress: (c: Context) => string | undefined = connectionAddress, +): string { + return resolveClientKey({ + trustProxy, + forwardedFor: c.req.header("x-forwarded-for"), + realIp: c.req.header("x-real-ip"), + connectionAddress: getAddress(c), + }); +} + export function rateLimit( trustProxy: boolean, options: RateLimitStoreOptions & { getAddress?: (c: Context) => string | undefined } = {}, @@ -77,12 +118,9 @@ export function rateLimit( const store = createRateLimitStore(options); const getAddress = options.getAddress ?? connectionAddress; return async (c: Context, next: () => Promise) => { - const key = resolveClientKey({ - trustProxy, - forwardedFor: c.req.header("x-forwarded-for"), - realIp: c.req.header("x-real-ip"), - connectionAddress: getAddress(c), - }); + // Health/readiness exemption lives in app.ts registration order (probes are + // registered before this middleware), so this limiter stays route-agnostic. + const key = resolveRequestKey(c, trustProxy, getAddress); if (!store.take(key, Date.now())) throw new AppError("Too many requests. Try again in a minute.", 429); await next(); diff --git a/apps/server/src/security-headers.ts b/apps/server/src/security-headers.ts new file mode 100644 index 000000000..9b48272e8 --- /dev/null +++ b/apps/server/src/security-headers.ts @@ -0,0 +1,167 @@ +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 `