mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
3 files changed
+136
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user