fix(gateway): add HSTS header hardening and docs

This commit is contained in:
Peter Steinberger
2026-02-23 19:47:09 +00:00
parent c88915b721
commit 9af3ec92a5
16 changed files with 275 additions and 2 deletions

View File

@@ -8,9 +8,16 @@ import { readJsonBody } from "./hooks.js";
* Content-Security-Policy are intentionally omitted here because some handlers
* (canvas host, A2UI) serve content that may be loaded inside frames.
*/
export function setDefaultSecurityHeaders(res: ServerResponse) {
export function setDefaultSecurityHeaders(
res: ServerResponse,
opts?: { strictTransportSecurity?: string },
) {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
const strictTransportSecurity = opts?.strictTransportSecurity;
if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) {
res.setHeader("Strict-Transport-Security", strictTransportSecurity);
}
}
export function sendJson(res: ServerResponse, status: number, body: unknown) {

View File

@@ -417,6 +417,7 @@ export function createGatewayHttpServer(opts: {
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
handleHooksRequest: HooksRequestHandler;
handlePluginRequest?: HooksRequestHandler;
resolvedAuth: ResolvedGatewayAuth;
@@ -433,6 +434,7 @@ export function createGatewayHttpServer(opts: {
openAiChatCompletionsEnabled,
openResponsesEnabled,
openResponsesConfig,
strictTransportSecurityHeader,
handleHooksRequest,
handlePluginRequest,
resolvedAuth,
@@ -447,7 +449,9 @@ export function createGatewayHttpServer(opts: {
});
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
setDefaultSecurityHeaders(res);
setDefaultSecurityHeaders(res, {
strictTransportSecurity: strictTransportSecurityHeader,
});
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") {

View File

@@ -189,4 +189,44 @@ describe("resolveGatewayRuntimeConfig", () => {
);
});
});
describe("HTTP security headers", () => {
it("resolves strict transport security header from config", async () => {
const result = await resolveGatewayRuntimeConfig({
cfg: {
gateway: {
bind: "loopback",
auth: { mode: "none" },
http: {
securityHeaders: {
strictTransportSecurity: " max-age=31536000; includeSubDomains ",
},
},
},
},
port: 18789,
});
expect(result.strictTransportSecurityHeader).toBe("max-age=31536000; includeSubDomains");
});
it("does not set strict transport security when explicitly disabled", async () => {
const result = await resolveGatewayRuntimeConfig({
cfg: {
gateway: {
bind: "loopback",
auth: { mode: "none" },
http: {
securityHeaders: {
strictTransportSecurity: false,
},
},
},
},
port: 18789,
});
expect(result.strictTransportSecurityHeader).toBeUndefined();
});
});
});

View File

@@ -25,6 +25,7 @@ export type GatewayRuntimeConfig = {
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
controlUiBasePath: string;
controlUiRoot?: string;
resolvedAuth: ResolvedGatewayAuth;
@@ -78,6 +79,15 @@ export async function resolveGatewayRuntimeConfig(params: {
false;
const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses;
const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false;
const strictTransportSecurityConfig =
params.cfg.gateway?.http?.securityHeaders?.strictTransportSecurity;
const strictTransportSecurityHeader =
strictTransportSecurityConfig === false
? undefined
: typeof strictTransportSecurityConfig === "string" &&
strictTransportSecurityConfig.trim().length > 0
? strictTransportSecurityConfig.trim()
: undefined;
const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
const controlUiRootRaw = params.cfg.gateway?.controlUi?.root;
const controlUiRoot =
@@ -147,6 +157,7 @@ export async function resolveGatewayRuntimeConfig(params: {
openResponsesConfig: openResponsesConfig
? { ...openResponsesConfig, enabled: openResponsesEnabled }
: undefined,
strictTransportSecurityHeader,
controlUiBasePath,
controlUiRoot,
resolvedAuth,

View File

@@ -41,6 +41,7 @@ export async function createGatewayRuntimeState(params: {
openAiChatCompletionsEnabled: boolean;
openResponsesEnabled: boolean;
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
strictTransportSecurityHeader?: string;
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
@@ -128,6 +129,7 @@ export async function createGatewayRuntimeState(params: {
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
openResponsesEnabled: params.openResponsesEnabled,
openResponsesConfig: params.openResponsesConfig,
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
handleHooksRequest,
handlePluginRequest,
resolvedAuth: params.resolvedAuth,

View File

@@ -301,6 +301,7 @@ export async function startGatewayServer(
openAiChatCompletionsEnabled,
openResponsesEnabled,
openResponsesConfig,
strictTransportSecurityHeader,
controlUiBasePath,
controlUiRoot: controlUiRootOverride,
resolvedAuth,
@@ -385,6 +386,7 @@ export async function startGatewayServer(
openAiChatCompletionsEnabled,
openResponsesEnabled,
openResponsesConfig,
strictTransportSecurityHeader,
resolvedAuth,
rateLimiter: authRateLimiter,
gatewayTls,

View File

@@ -66,6 +66,68 @@ async function dispatchRequest(
}
describe("gateway plugin HTTP auth boundary", () => {
test("applies default security headers and optional strict transport security", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "none",
token: undefined,
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix: "openclaw-plugin-http-security-headers-test-",
run: async () => {
const withoutHsts = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
resolvedAuth,
});
const withoutHstsResponse = createResponse();
await dispatchRequest(
withoutHsts,
createRequest({ path: "/missing" }),
withoutHstsResponse.res,
);
expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith(
"X-Content-Type-Options",
"nosniff",
);
expect(withoutHstsResponse.setHeader).toHaveBeenCalledWith(
"Referrer-Policy",
"no-referrer",
);
expect(withoutHstsResponse.setHeader).not.toHaveBeenCalledWith(
"Strict-Transport-Security",
expect.any(String),
);
const withHsts = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
strictTransportSecurityHeader: "max-age=31536000; includeSubDomains",
handleHooksRequest: async () => false,
resolvedAuth,
});
const withHstsResponse = createResponse();
await dispatchRequest(withHsts, createRequest({ path: "/missing" }), withHstsResponse.res);
expect(withHstsResponse.setHeader).toHaveBeenCalledWith(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
);
},
});
});
test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",