mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 00:31:24 +00:00
fix(gateway): add HSTS header hardening and docs
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user