diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcbcbed903..6b726dfefd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. - Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. +- Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. ### Breaking diff --git a/SECURITY.md b/SECURITY.md index 1a26e7541c0..a5389757887 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,36 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai). Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. +### Report Acceptance Gate (Triage Fast Path) + +For fastest triage, include all of the following: + +- Exact vulnerable path (`file`, function, and line range) on a current revision. +- Tested version details (OpenClaw version and/or commit SHA). +- Reproducible PoC against latest `main` or latest released version. +- Demonstrated impact tied to OpenClaw's documented trust boundaries. +- Scope check explaining why the report is **not** covered by the Out of Scope section below. + +Reports that miss these requirements may be closed as `invalid` or `no-action`. + +### Common False-Positive Patterns + +These are frequently reported but are typically closed with no code change: + +- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). +- Operator-intended local features (for example TUI local `!` shell) presented as remote injection. +- Reports that assume per-user multi-tenant authorization on a shared gateway host/config. +- Missing HSTS findings on default local/loopback deployments. +- Slack webhook signature findings when HTTP mode already uses signing-secret verification. +- Discord inbound webhook signature findings for paths not used by this repo's Discord integration. +- Scanner-only claims against stale/nonexistent paths, or claims without a working repro. + +### Duplicate Report Handling + +- Search existing advisories before filing. +- Include likely duplicate GHSA IDs in your report when applicable. +- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report. + ## Security & Trust **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8cafb13839c..adde566e886 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2126,6 +2126,8 @@ See [Plugins](/tools/plugin). - `gateway.http.endpoints.responses.maxUrlParts` - `gateway.http.endpoints.responses.files.urlAllowlist` - `gateway.http.endpoints.responses.images.urlAllowlist` +- Optional response hardening header: + - `gateway.http.securityHeaders.strictTransportSecurity` (set only for HTTPS origins you control; see [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts)) ### Multi-instance isolation diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 7abbea866d4..3cee5259a0a 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -38,6 +38,40 @@ OpenClaw assumes the host and config boundary are trusted: - Running one Gateway for multiple mutually untrusted/adversarial operators is **not a recommended setup**. - For mixed-trust teams, split trust boundaries with separate gateways (or at minimum separate OS users/hosts). +## Trust boundary matrix + +Use this as the quick model when triaging risk: + +| Boundary or control | What it means | Common misread | +| ------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | +| `gateway.auth` (token/password/device auth) | Authenticates callers to gateway APIs | "Needs per-message signatures on every frame to be secure" | +| `sessionKey` | Routing key for context/session selection | "Session key is a user auth boundary" | +| Prompt/content guardrails | Reduce model abuse risk | "Prompt injection alone proves auth bypass" | +| `canvas.eval` / browser evaluate | Intentional operator capability when enabled | "Any JS eval primitive is automatically a vuln in this trust model" | +| Local TUI `!` shell | Explicit operator-triggered local execution | "Local shell convenience command is remote injection" | +| Node pairing and node commands | Operator-level remote execution on paired devices | "Remote device control should be treated as untrusted user access by default" | + +## Not vulnerabilities by design + +These patterns are commonly reported and are usually closed as no-action unless a real boundary bypass is shown: + +- Prompt-injection-only chains without a policy/auth/sandbox bypass. +- Claims that assume hostile multi-tenant operation on one shared host/config. +- Localhost-only deployment findings (for example HSTS on loopback-only gateway). +- Discord inbound webhook signature findings for inbound paths that do not exist in this repo. +- "Missing per-user authorization" findings that treat `sessionKey` as an auth token. + +## Researcher preflight checklist + +Before opening a GHSA, verify all of these: + +1. Repro still works on latest `main` or latest release. +2. Report includes exact code path (`file`, function, line range) and tested version/commit. +3. Impact crosses a documented trust boundary (not just prompt injection). +4. Claim is not listed in [Out of Scope](https://github.com/openclaw/openclaw/blob/main/SECURITY.md#out-of-scope). +5. Existing advisories were checked for duplicates (reuse canonical GHSA when applicable). +6. Deployment assumptions are explicit (loopback/local vs exposed, trusted vs untrusted operators). + ## Hardened baseline in 60 seconds Use this baseline first, then selectively re-enable tools per trusted agent: @@ -202,6 +236,14 @@ Bad reverse proxy behavior (append/preserve untrusted forwarding headers): proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ``` +## HSTS and origin notes + +- OpenClaw gateway is local/loopback first. If you terminate TLS at a reverse proxy, set HSTS on the proxy-facing HTTPS domain there. +- If the gateway itself terminates HTTPS, you can set `gateway.http.securityHeaders.strictTransportSecurity` to emit the HSTS header from OpenClaw responses. +- Detailed deployment guidance is in [Trusted Proxy Auth](/gateway/trusted-proxy-auth#tls-termination-and-hsts). +- For non-loopback Control UI deployments, explicitly configure `gateway.controlUi.allowedOrigins` instead of relying on permissive defaults. +- Treat DNS rebinding and proxy-host header behavior as deployment hardening concerns; keep `trustedProxies` tight and avoid exposing the gateway directly to the public internet. + ## Local session logs live on disk OpenClaw stores session transcripts on disk under `~/.openclaw/agents//sessions/*.jsonl`. diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index f9debcfaef0..2b30b234e24 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -4,6 +4,7 @@ read_when: - Running OpenClaw behind an identity-aware proxy - Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw - Fixing WebSocket 1008 unauthorized errors with reverse proxy setups + - Deciding where to set HSTS and other HTTP hardening headers --- # Trusted Proxy Auth @@ -75,6 +76,52 @@ If `gateway.bind` is `loopback`, include a loopback proxy address in | `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted | | `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. | +## TLS termination and HSTS + +Use one TLS termination point and apply HSTS there. + +### Recommended pattern: proxy TLS termination + +When your reverse proxy handles HTTPS for `https://control.example.com`, set +`Strict-Transport-Security` at the proxy for that domain. + +- Good fit for internet-facing deployments. +- Keeps certificate + HTTP hardening policy in one place. +- OpenClaw can stay on loopback HTTP behind the proxy. + +Example header value: + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains +``` + +### Gateway TLS termination + +If OpenClaw itself serves HTTPS directly (no TLS-terminating proxy), set: + +```json5 +{ + gateway: { + tls: { enabled: true }, + http: { + securityHeaders: { + strictTransportSecurity: "max-age=31536000; includeSubDomains", + }, + }, + }, +} +``` + +`strictTransportSecurity` accepts a string header value, or `false` to disable explicitly. + +### Rollout guidance + +- Start with a short max age first (for example `max-age=300`) while validating traffic. +- Increase to long-lived values (for example `max-age=31536000`) only after confidence is high. +- Add `includeSubDomains` only if every subdomain is HTTPS-ready. +- Use preload only if you intentionally meet preload requirements for your full domain set. +- Loopback-only local development does not benefit from HSTS. + ## Proxy Setup Examples ### Pomerium diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4d3a6bb160b..84536311cf7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -119,6 +119,10 @@ export const FIELD_HELP: Record = { "Gateway HTTP API configuration grouping endpoint toggles and transport-facing API exposure controls. Keep only required endpoints enabled to reduce attack surface.", "gateway.http.endpoints": "HTTP endpoint feature toggles under the gateway API surface for compatibility routes and optional integrations. Enable endpoints intentionally and monitor access patterns after rollout.", + "gateway.http.securityHeaders": + "Optional HTTP response security headers applied by the gateway process itself. Prefer setting these at your reverse proxy when TLS terminates there.", + "gateway.http.securityHeaders.strictTransportSecurity": + "Value for the Strict-Transport-Security response header. Set only on HTTPS origins that you fully control; use false to explicitly disable.", "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.token": "Bearer token used to authenticate this client to a remote gateway in token-auth deployments. Store via secret/env substitution and rotate alongside remote gateway auth changes.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index cb57dff167c..5142c3ac8b3 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -86,6 +86,8 @@ export const FIELD_LABELS: Record = { "gateway.tls.caPath": "Gateway TLS CA Path", "gateway.http": "Gateway HTTP API", "gateway.http.endpoints": "Gateway HTTP Endpoints", + "gateway.http.securityHeaders": "Gateway HTTP Security Headers", + "gateway.http.securityHeaders.strictTransportSecurity": "Strict Transport Security Header", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 13a36c7f4f7..edc86b78b0d 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -255,8 +255,19 @@ export type GatewayHttpEndpointsConfig = { responses?: GatewayHttpResponsesConfig; }; +export type GatewayHttpSecurityHeadersConfig = { + /** + * Value for the Strict-Transport-Security response header. + * Set to false to disable explicitly. + * + * Example: "max-age=31536000; includeSubDomains" + */ + strictTransportSecurity?: string | false; +}; + export type GatewayHttpConfig = { endpoints?: GatewayHttpEndpointsConfig; + securityHeaders?: GatewayHttpSecurityHeadersConfig; }; export type GatewayNodesConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 3f1b89f980f..8c5db8696c9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -562,6 +562,12 @@ export const OpenClawSchema = z }) .strict() .optional(), + securityHeaders: z + .object({ + strictTransportSecurity: z.union([z.string(), z.literal(false)]).optional(), + }) + .strict() + .optional(), }) .strict() .optional(), diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index a536df55a6b..7e0b84ab5d7 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -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) { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 30046fc9fb8..e67737b5b76 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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") { diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 2c2b30e9d15..6d111c40bb1 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -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(); + }); + }); }); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index e651801db22..5ddd9b789a5 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -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, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index f126850c288..af42df0fc42 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -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, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index bad38bb9db8..fdca08c2677 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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, diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 1a5ec95176b..f932e1e2a35 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -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",