Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ Run `nb --help` or `nb <command> --help` for full usage. If you haven't run `bun
| `ALLOWED_ORIGINS` | Comma-separated allowed CORS origins (for cookie-based auth) |
| `MCP_MAX_SESSIONS` | Max concurrent MCP sessions (default: 100) |
| `MCP_SESSION_TTL_MS` | MCP session inactivity TTL in ms (default: 1800000) |
| `NB_HSTS` | `Strict-Transport-Security` value (default: `max-age=31536000; includeSubDomains`). Set to `""` to disable — e.g., when a reverse proxy already emits this header |
| `NB_CSP` | `Content-Security-Policy` value (default: `default-src 'none'; frame-ancestors 'none'; base-uri 'none'`). Set to `""` to disable |

## Headless / Pipe Mode

Expand Down
45 changes: 41 additions & 4 deletions src/api/middleware/security-headers.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,54 @@
import { createMiddleware } from "hono/factory";

/**
* Security headers middleware.
* Sets standard browser security headers on every response.
* Does NOT set HSTS or CSP — those belong on the reverse proxy.
* Default HSTS: 1-year, applies to subdomains. No `preload` — opting into the
* preload list is a deliberate operator choice.
*/
export function securityHeaders() {
export const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";

/**
* Default CSP: locks the API down to nothing. JSON and SSE responses are
* unaffected. Bundle UI HTML served from /v1/apps/... is consumed by the
* iframe bridge via fetch + srcdoc (where the response CSP does not apply),
* so a restrictive header actively protects against someone opening that
* HTML directly in a browser.
*/
export const DEFAULT_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";

export interface SecurityHeadersOptions {
/**
* Strict-Transport-Security value. `undefined` uses the default, empty
* string disables the header. `NB_HSTS` env var takes precedence.
*/
hsts?: string;
/**
* Content-Security-Policy value. `undefined` uses the default, empty string
* disables the header. `NB_CSP` env var takes precedence.
*/
csp?: string;
}

/**
* Security headers middleware. Sets standard browser security headers on
* every response.
*
* HSTS and CSP are included with conservative defaults so direct-exposure
* self-hosted deployments are not left naked when no reverse proxy sits in
* front. Operators who terminate TLS at a proxy that already emits these
* headers can disable them by setting `NB_HSTS=""` / `NB_CSP=""`, or override
* to a stricter/looser value via env var or option.
*/
export function securityHeaders(options: SecurityHeadersOptions = {}) {
const hsts = process.env.NB_HSTS ?? options.hsts ?? DEFAULT_HSTS;
const csp = process.env.NB_CSP ?? options.csp ?? DEFAULT_CSP;
return createMiddleware(async (c, next) => {
await next();
c.res.headers.set("X-Content-Type-Options", "nosniff");
c.res.headers.set("X-Frame-Options", "DENY");
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
c.res.headers.set("X-XSS-Protection", "0");
c.res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
if (hsts) c.res.headers.set("Strict-Transport-Security", hsts);
if (csp) c.res.headers.set("Content-Security-Policy", csp);
});
}
83 changes: 69 additions & 14 deletions test/unit/api/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,113 @@
import { describe, test, expect } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { securityHeaders } from "../../../src/api/middleware/security-headers.ts";
import {
DEFAULT_CSP,
DEFAULT_HSTS,
securityHeaders,
} from "../../../src/api/middleware/security-headers.ts";

function createTestApp() {
function createTestApp(options?: Parameters<typeof securityHeaders>[0]) {
const app = new Hono();
app.use("*", securityHeaders());
app.use("*", securityHeaders(options));
app.get("/test", (c) => c.json({ ok: true }));
app.post("/test", (c) => c.json({ ok: true }));
return app;
}

describe("securityHeaders middleware", () => {
const app = createTestApp();
// Env vars leak across modules in Bun; snapshot/restore to keep tests isolated.
let savedHsts: string | undefined;
let savedCsp: string | undefined;

beforeEach(() => {
savedHsts = process.env.NB_HSTS;
savedCsp = process.env.NB_CSP;
delete process.env.NB_HSTS;
delete process.env.NB_CSP;
});

afterEach(() => {
if (savedHsts === undefined) delete process.env.NB_HSTS;
else process.env.NB_HSTS = savedHsts;
if (savedCsp === undefined) delete process.env.NB_CSP;
else process.env.NB_CSP = savedCsp;
});

test("sets X-Content-Type-Options: nosniff on all responses", async () => {
const res = await app.request("/test");
const res = await createTestApp().request("/test");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
});

test("sets X-Frame-Options: DENY", async () => {
const res = await app.request("/test");
const res = await createTestApp().request("/test");
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
});

test("sets Referrer-Policy: strict-origin-when-cross-origin", async () => {
const res = await app.request("/test");
const res = await createTestApp().request("/test");
expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
});

test("sets X-XSS-Protection: 0", async () => {
const res = await app.request("/test");
const res = await createTestApp().request("/test");
expect(res.headers.get("X-XSS-Protection")).toBe("0");
});

test("sets Permissions-Policy", async () => {
const res = await app.request("/test");
const res = await createTestApp().request("/test");
expect(res.headers.get("Permissions-Policy")).toBe("camera=(), microphone=(), geolocation=()");
});

test("does not set HSTS or CSP", async () => {
const res = await app.request("/test");
test("sets HSTS and CSP defaults for direct-exposure deployments", async () => {
const res = await createTestApp().request("/test");
expect(res.headers.get("Strict-Transport-Security")).toBe(DEFAULT_HSTS);
expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_CSP);
});

test("option overrides default HSTS/CSP", async () => {
const res = await createTestApp({
hsts: "max-age=60",
csp: "default-src 'self'",
}).request("/test");
expect(res.headers.get("Strict-Transport-Security")).toBe("max-age=60");
expect(res.headers.get("Content-Security-Policy")).toBe("default-src 'self'");
});

test("empty-string option disables HSTS/CSP (delegates to reverse proxy)", async () => {
const res = await createTestApp({ hsts: "", csp: "" }).request("/test");
expect(res.headers.get("Strict-Transport-Security")).toBeNull();
expect(res.headers.get("Content-Security-Policy")).toBeNull();
});

test("NB_HSTS env var overrides option", async () => {
process.env.NB_HSTS = "max-age=42";
const res = await createTestApp({ hsts: "max-age=60" }).request("/test");
expect(res.headers.get("Strict-Transport-Security")).toBe("max-age=42");
});

test("NB_CSP env var overrides option", async () => {
process.env.NB_CSP = "default-src https:";
const res = await createTestApp({ csp: "default-src 'self'" }).request("/test");
expect(res.headers.get("Content-Security-Policy")).toBe("default-src https:");
});

test("NB_HSTS='' env var disables HSTS even when option is set", async () => {
process.env.NB_HSTS = "";
const res = await createTestApp({ hsts: "max-age=60" }).request("/test");
expect(res.headers.get("Strict-Transport-Security")).toBeNull();
});

test("sets headers on POST responses too", async () => {
const res = await app.request("/test", { method: "POST" });
const res = await createTestApp().request("/test", { method: "POST" });
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
expect(res.headers.get("Strict-Transport-Security")).toBe(DEFAULT_HSTS);
});

test("sets headers on 404 responses", async () => {
const res = await app.request("/nonexistent");
const res = await createTestApp().request("/nonexistent");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("X-Frame-Options")).toBe("DENY");
expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_CSP);
});
});