feat(security): audit gateway HTTP no-auth exposure

This commit is contained in:
Peter Steinberger
2026-02-19 14:25:45 +01:00
parent 808a60d3bd
commit e3e0ffd801
7 changed files with 130 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings.
- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates.
### Fixes

View File

@@ -27,6 +27,7 @@ The audit warns when multiple DM senders share the main session and recommends *
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy.
It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint).
## JSON output

View File

@@ -114,6 +114,7 @@ High-signal `checkId` values you will most likely see in real deployments (not e
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
| `gateway.control_ui.insecure_auth` | critical | Token-only over HTTP, no device identity | `gateway.controlUi.allowInsecureAuth` | no |

View File

@@ -1,20 +1,20 @@
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
import {
resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js";
/**
* Synchronous security audit collector functions.
*
* These functions analyze config-based security properties without I/O.
*/
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
import {
resolveSandboxConfigForAgent,
resolveSandboxToolPolicyForAgent,
} from "../agents/sandbox.js";
import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-security.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { resolveBrowserConfig } from "../browser/config.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { resolveNodeCommandAllowlist } from "../gateway/node-command-policy.js";
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
@@ -535,6 +535,40 @@ export function collectGatewayHttpSessionKeyOverrideFindings(
return findings;
}
export function collectGatewayHttpNoAuthFindings(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
if (auth.mode !== "none") {
return findings;
}
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
const enabledEndpoints = [
"/tools/invoke",
chatCompletionsEnabled ? "/v1/chat/completions" : null,
responsesEnabled ? "/v1/responses" : null,
].filter((entry): entry is string => Boolean(entry));
const remoteExposure = isGatewayRemotelyExposed(cfg);
findings.push({
checkId: "gateway.http.no_auth",
severity: remoteExposure ? "critical" : "warn",
title: "Gateway HTTP APIs are reachable without auth",
detail:
`gateway.auth.mode="none" leaves ${enabledEndpoints.join(", ")} callable without a shared secret. ` +
"Treat this as trusted-local only and avoid exposing the gateway beyond loopback.",
remediation:
"Set gateway.auth.mode to token/password (recommended). If you intentionally keep mode=none, keep gateway.bind=loopback and disable optional HTTP endpoints.",
});
return findings;
}
export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const configuredPaths: string[] = [];

View File

@@ -11,6 +11,7 @@
export {
collectAttackSurfaceSummaryFindings,
collectExposureMatrixFindings,
collectGatewayHttpNoAuthFindings,
collectGatewayHttpSessionKeyOverrideFindings,
collectHooksHardeningFindings,
collectMinimalProfileOverrideFindings,

View File

@@ -1262,6 +1262,87 @@ describe("security audit", () => {
);
});
it("warns when gateway HTTP APIs run with auth.mode=none on loopback", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "loopback",
auth: { mode: "none" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "warn" }),
]),
);
const finding = res.findings.find((entry) => entry.checkId === "gateway.http.no_auth");
expect(finding?.detail).toContain("/tools/invoke");
expect(finding?.detail).toContain("/v1/chat/completions");
});
it("flags gateway HTTP APIs with auth.mode=none as critical when remotely exposed", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: { mode: "none" },
http: {
endpoints: {
responses: { enabled: true },
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "gateway.http.no_auth", severity: "critical" }),
]),
);
});
it("does not report gateway.http.no_auth when auth mode is token", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "loopback",
auth: { mode: "token", token: "secret" },
http: {
endpoints: {
chatCompletions: { enabled: true },
responses: { enabled: true },
},
},
},
};
const res = await runSecurityAudit({
config: cfg,
env: {},
includeFilesystem: false,
includeChannelSecurity: false,
});
expect(res.findings.some((entry) => entry.checkId === "gateway.http.no_auth")).toBe(false);
});
it("reports HTTP API session-key override surfaces when enabled", async () => {
const cfg: OpenClawConfig = {
gateway: {

View File

@@ -1,8 +1,9 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ExecFn } from "./windows-acl.js";
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
import { resolveBrowserControlAuth } from "../browser/control-auth.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
@@ -12,6 +13,7 @@ import { collectChannelSecurityFindings } from "./audit-channel.js";
import {
collectAttackSurfaceSummaryFindings,
collectExposureMatrixFindings,
collectGatewayHttpNoAuthFindings,
collectGatewayHttpSessionKeyOverrideFindings,
collectHooksHardeningFindings,
collectIncludeFilePermFindings,
@@ -35,7 +37,6 @@ import {
inspectPathPermissions,
} from "./audit-fs.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditSeverity = "info" | "warn" | "critical";
@@ -621,6 +622,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));
findings.push(...collectHooksHardeningFindings(cfg, env));
findings.push(...collectGatewayHttpNoAuthFindings(cfg, env));
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
findings.push(...collectSandboxDockerNoopFindings(cfg));
findings.push(...collectSandboxDangerousConfigFindings(cfg));