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 `