mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(gateway): add HSTS header hardening and docs
This commit is contained in:
@@ -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.
|
- 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.
|
- 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
|
### Breaking
|
||||||
|
|
||||||
|
|||||||
30
SECURITY.md
30
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.
|
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
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -2126,6 +2126,8 @@ See [Plugins](/tools/plugin).
|
|||||||
- `gateway.http.endpoints.responses.maxUrlParts`
|
- `gateway.http.endpoints.responses.maxUrlParts`
|
||||||
- `gateway.http.endpoints.responses.files.urlAllowlist`
|
- `gateway.http.endpoints.responses.files.urlAllowlist`
|
||||||
- `gateway.http.endpoints.responses.images.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
|
### Multi-instance isolation
|
||||||
|
|
||||||
|
|||||||
@@ -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**.
|
- 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).
|
- 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
|
## Hardened baseline in 60 seconds
|
||||||
|
|
||||||
Use this baseline first, then selectively re-enable tools per trusted agent:
|
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;
|
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
|
## Local session logs live on disk
|
||||||
|
|
||||||
OpenClaw stores session transcripts on disk under `~/.openclaw/agents/<agentId>/sessions/*.jsonl`.
|
OpenClaw stores session transcripts on disk under `~/.openclaw/agents/<agentId>/sessions/*.jsonl`.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ read_when:
|
|||||||
- Running OpenClaw behind an identity-aware proxy
|
- Running OpenClaw behind an identity-aware proxy
|
||||||
- Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw
|
- Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw
|
||||||
- Fixing WebSocket 1008 unauthorized errors with reverse proxy setups
|
- Fixing WebSocket 1008 unauthorized errors with reverse proxy setups
|
||||||
|
- Deciding where to set HSTS and other HTTP hardening headers
|
||||||
---
|
---
|
||||||
|
|
||||||
# Trusted Proxy Auth
|
# 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.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. |
|
| `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
|
## Proxy Setup Examples
|
||||||
|
|
||||||
### Pomerium
|
### Pomerium
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ export const FIELD_HELP: Record<string, string> = {
|
|||||||
"Gateway HTTP API configuration grouping endpoint toggles and transport-facing API exposure controls. Keep only required endpoints enabled to reduce attack surface.",
|
"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":
|
"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.",
|
"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.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
|
||||||
"gateway.remote.token":
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||||||
"gateway.tls.caPath": "Gateway TLS CA Path",
|
"gateway.tls.caPath": "Gateway TLS CA Path",
|
||||||
"gateway.http": "Gateway HTTP API",
|
"gateway.http": "Gateway HTTP API",
|
||||||
"gateway.http.endpoints": "Gateway HTTP Endpoints",
|
"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.url": "Remote Gateway URL",
|
||||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||||
|
|||||||
@@ -255,8 +255,19 @@ export type GatewayHttpEndpointsConfig = {
|
|||||||
responses?: GatewayHttpResponsesConfig;
|
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 = {
|
export type GatewayHttpConfig = {
|
||||||
endpoints?: GatewayHttpEndpointsConfig;
|
endpoints?: GatewayHttpEndpointsConfig;
|
||||||
|
securityHeaders?: GatewayHttpSecurityHeadersConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayNodesConfig = {
|
export type GatewayNodesConfig = {
|
||||||
|
|||||||
@@ -562,6 +562,12 @@ export const OpenClawSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
securityHeaders: z
|
||||||
|
.object({
|
||||||
|
strictTransportSecurity: z.union([z.string(), z.literal(false)]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ import { readJsonBody } from "./hooks.js";
|
|||||||
* Content-Security-Policy are intentionally omitted here because some handlers
|
* Content-Security-Policy are intentionally omitted here because some handlers
|
||||||
* (canvas host, A2UI) serve content that may be loaded inside frames.
|
* (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("X-Content-Type-Options", "nosniff");
|
||||||
res.setHeader("Referrer-Policy", "no-referrer");
|
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) {
|
export function sendJson(res: ServerResponse, status: number, body: unknown) {
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ export function createGatewayHttpServer(opts: {
|
|||||||
openAiChatCompletionsEnabled: boolean;
|
openAiChatCompletionsEnabled: boolean;
|
||||||
openResponsesEnabled: boolean;
|
openResponsesEnabled: boolean;
|
||||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
|
strictTransportSecurityHeader?: string;
|
||||||
handleHooksRequest: HooksRequestHandler;
|
handleHooksRequest: HooksRequestHandler;
|
||||||
handlePluginRequest?: HooksRequestHandler;
|
handlePluginRequest?: HooksRequestHandler;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
@@ -433,6 +434,7 @@ export function createGatewayHttpServer(opts: {
|
|||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
openResponsesEnabled,
|
openResponsesEnabled,
|
||||||
openResponsesConfig,
|
openResponsesConfig,
|
||||||
|
strictTransportSecurityHeader,
|
||||||
handleHooksRequest,
|
handleHooksRequest,
|
||||||
handlePluginRequest,
|
handlePluginRequest,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
@@ -447,7 +449,9 @@ export function createGatewayHttpServer(opts: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||||
setDefaultSecurityHeaders(res);
|
setDefaultSecurityHeaders(res, {
|
||||||
|
strictTransportSecurity: strictTransportSecurityHeader,
|
||||||
|
});
|
||||||
|
|
||||||
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
||||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") {
|
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;
|
openAiChatCompletionsEnabled: boolean;
|
||||||
openResponsesEnabled: boolean;
|
openResponsesEnabled: boolean;
|
||||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
|
strictTransportSecurityHeader?: string;
|
||||||
controlUiBasePath: string;
|
controlUiBasePath: string;
|
||||||
controlUiRoot?: string;
|
controlUiRoot?: string;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
@@ -78,6 +79,15 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
false;
|
false;
|
||||||
const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses;
|
const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses;
|
||||||
const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false;
|
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 controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
|
||||||
const controlUiRootRaw = params.cfg.gateway?.controlUi?.root;
|
const controlUiRootRaw = params.cfg.gateway?.controlUi?.root;
|
||||||
const controlUiRoot =
|
const controlUiRoot =
|
||||||
@@ -147,6 +157,7 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
openResponsesConfig: openResponsesConfig
|
openResponsesConfig: openResponsesConfig
|
||||||
? { ...openResponsesConfig, enabled: openResponsesEnabled }
|
? { ...openResponsesConfig, enabled: openResponsesEnabled }
|
||||||
: undefined,
|
: undefined,
|
||||||
|
strictTransportSecurityHeader,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
controlUiRoot,
|
controlUiRoot,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
openAiChatCompletionsEnabled: boolean;
|
openAiChatCompletionsEnabled: boolean;
|
||||||
openResponsesEnabled: boolean;
|
openResponsesEnabled: boolean;
|
||||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
|
strictTransportSecurityHeader?: string;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
/** Optional rate limiter for auth brute-force protection. */
|
/** Optional rate limiter for auth brute-force protection. */
|
||||||
rateLimiter?: AuthRateLimiter;
|
rateLimiter?: AuthRateLimiter;
|
||||||
@@ -128,6 +129,7 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||||
openResponsesEnabled: params.openResponsesEnabled,
|
openResponsesEnabled: params.openResponsesEnabled,
|
||||||
openResponsesConfig: params.openResponsesConfig,
|
openResponsesConfig: params.openResponsesConfig,
|
||||||
|
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
|
||||||
handleHooksRequest,
|
handleHooksRequest,
|
||||||
handlePluginRequest,
|
handlePluginRequest,
|
||||||
resolvedAuth: params.resolvedAuth,
|
resolvedAuth: params.resolvedAuth,
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ export async function startGatewayServer(
|
|||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
openResponsesEnabled,
|
openResponsesEnabled,
|
||||||
openResponsesConfig,
|
openResponsesConfig,
|
||||||
|
strictTransportSecurityHeader,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
controlUiRoot: controlUiRootOverride,
|
controlUiRoot: controlUiRootOverride,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
@@ -385,6 +386,7 @@ export async function startGatewayServer(
|
|||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
openResponsesEnabled,
|
openResponsesEnabled,
|
||||||
openResponsesConfig,
|
openResponsesConfig,
|
||||||
|
strictTransportSecurityHeader,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
rateLimiter: authRateLimiter,
|
rateLimiter: authRateLimiter,
|
||||||
gatewayTls,
|
gatewayTls,
|
||||||
|
|||||||
@@ -66,6 +66,68 @@ async function dispatchRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway plugin HTTP auth boundary", () => {
|
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 () => {
|
test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => {
|
||||||
const resolvedAuth: ResolvedGatewayAuth = {
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
mode: "token",
|
mode: "token",
|
||||||
|
|||||||
Reference in New Issue
Block a user