mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
feat(security): audit gateway HTTP no-auth exposure
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectExposureMatrixFindings,
|
||||
collectGatewayHttpNoAuthFindings,
|
||||
collectGatewayHttpSessionKeyOverrideFindings,
|
||||
collectHooksHardeningFindings,
|
||||
collectMinimalProfileOverrideFindings,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user