fix(server): add security headers and reject cross-site API posts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-06 12:04:06 -04:00
1 parent c51a668ace
commit bd46b17711
3 files changed
+136

No files matched your search

+8
View File
@@ -2,6 +2,7 @@ import { type Context, Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { secureHeaders } from "hono/secure-headers";
import { CACHE_DIR } from "@sofa/config";
import { ensureBackupDir } from "@sofa/core/backup";
@@ -18,6 +19,7 @@ import { apiBodyLimit, UPLOAD_BODY_LIMIT } from "./body-limits";
import { getJobSchedules, startJobs, stopJobs } from "./cron";
import { handler as rpcHandler } from "./orpc/handler";
import { openApiHandler } from "./orpc/openapi-handler";
import { rejectCrossSiteRequests, SECURITY_HEADERS_OPTIONS } from "./request-guards";
import authRoutes from "./routes/auth";
import avatarsRoutes from "./routes/avatars";
import backupsRoutes from "./routes/backups";
@@ -81,6 +83,8 @@ app.use(
}),
);
app.use("*", secureHeaders(SECURITY_HEADERS_OPTIONS));
app.use("*", async (c, next) => {
if (isDatabaseAccessBlocked() && c.req.path !== "/api/health") {
return c.json({ error: "Service unavailable during database restore" }, 503);
@@ -88,6 +92,10 @@ app.use("*", async (c, next) => {
await next();
});
// Browsers may only call the API from Sofa's own origin(s).
app.use("/rpc/*", rejectCrossSiteRequests);
app.use("/api/v1/*", rejectCrossSiteRequests);
// Request body limits (see body-limits.ts); Better Auth and webhook bodies are small.
app.use("/rpc/*", apiBodyLimit);
app.use("/api/v1/*", apiBodyLimit);
+50
View File
@@ -0,0 +1,50 @@
import type { Context, MiddlewareHandler } from "hono";
import type { secureHeaders } from "hono/secure-headers";
/**
* Security headers. HSTS is left to the reverse proxy; the referrer policy must allow
* YouTube trailer embeds; CORP/COOP are off so external dashboards and OIDC flows keep working.
*/
export const SECURITY_HEADERS_OPTIONS: NonNullable<Parameters<typeof secureHeaders>[0]> = {
contentSecurityPolicy: { frameAncestors: ["'self'"] },
referrerPolicy: "strict-origin-when-cross-origin",
strictTransportSecurity: false,
crossOriginResourcePolicy: false,
crossOriginOpenerPolicy: false,
};
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function originOf(url: string | undefined): string | null {
if (!url || !URL.canParse(url)) return null;
return new URL(url).origin;
}
function allowedOrigins(c: Context): Set<string> {
const origins = new Set<string>([new URL(c.req.url).origin]);
for (const candidate of [
process.env.BETTER_AUTH_URL,
process.env.CORS_ORIGIN || "http://localhost:3000",
]) {
const origin = originOf(candidate);
if (origin) origins.add(origin);
}
return origins;
}
/**
* Reject state-changing requests that a browser sent from another site. Requests without
* Origin/Sec-Fetch-Site (the native app, scripts, media servers) are allowed; their
* authentication (cookie or token) is checked downstream.
*/
export const rejectCrossSiteRequests: MiddlewareHandler = async (c, next) => {
if (SAFE_METHODS.has(c.req.method)) return next();
const origin = c.req.header("origin");
if (origin) {
if (!allowedOrigins(c).has(origin)) return c.json({ error: "Forbidden" }, 403);
return next();
}
const site = c.req.header("sec-fetch-site");
if (site === "cross-site" || site === "same-site") return c.json({ error: "Forbidden" }, 403);
return next();
};
+78
View File
@@ -0,0 +1,78 @@
import { Hono } from "hono";
import { secureHeaders } from "hono/secure-headers";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { rejectCrossSiteRequests, SECURITY_HEADERS_OPTIONS } from "../src/request-guards";
describe("rejectCrossSiteRequests", () => {
const original = {
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
CORS_ORIGIN: process.env.CORS_ORIGIN,
};
const app = new Hono();
app.use("/rpc/*", rejectCrossSiteRequests);
app.all("/rpc/*", (c) => c.text("ok"));
const post = (headers: Record<string, string>) =>
app.request("http://localhost/rpc/x", { method: "POST", headers });
beforeEach(() => {
process.env.BETTER_AUTH_URL = "https://sofa.example.com";
delete process.env.CORS_ORIGIN;
});
afterEach(() => {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test("rejects a POST from a foreign Origin", async () => {
const res = await post({ origin: "https://evil.example.net", "content-type": "text/plain" });
expect(res.status).toBe(403);
});
test("allows the BETTER_AUTH_URL origin", async () => {
expect((await post({ origin: "https://sofa.example.com" })).status).toBe(200);
});
test("allows the request's own origin", async () => {
expect((await post({ origin: "http://localhost" })).status).toBe(200);
});
test("rejects a cross-site POST without Origin", async () => {
expect((await post({ "sec-fetch-site": "cross-site" })).status).toBe(403);
});
test("allows a POST with neither Origin nor Sec-Fetch-Site", async () => {
expect((await post({})).status).toBe(200);
});
test("allows safe methods from any Origin", async () => {
const res = await app.request("http://localhost/rpc/x", {
headers: { origin: "https://evil.example.net" },
});
expect(res.status).toBe(200);
});
test("allows the CORS_ORIGIN origin", async () => {
process.env.CORS_ORIGIN = "https://web.example.com";
expect((await post({ origin: "https://web.example.com" })).status).toBe(200);
});
});
describe("security headers", () => {
test("sets the expected headers", async () => {
const app = new Hono();
app.use("*", secureHeaders(SECURITY_HEADERS_OPTIONS));
app.get("/", (c) => c.text("ok"));
const res = await app.request("http://localhost/");
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
expect(res.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
expect(res.headers.get("content-security-policy")).toContain("frame-ancestors 'self'");
expect(res.headers.get("strict-transport-security")).toBeNull();
expect(res.headers.get("cross-origin-resource-policy")).toBeNull();
});
});