diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9b80db1..bcc9d6a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy. - Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root `openclaw/plugin-sdk` compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras. - Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras. +- Gateway/password CLI hardening: add `openclaw gateway run --password-file`, warn when inline `--password` is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc. - Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan. - Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras. - Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic `openclaw/plugin-sdk` imports to scoped subpaths (or `openclaw/plugin-sdk/core`) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root `openclaw/plugin-sdk` support for external/community plugins. Thanks @gumadeiras. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 48157e52a..95c20e3aa 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -46,7 +46,8 @@ Notes: - `--bind `: listener bind mode. - `--auth `: auth mode override. - `--token `: token override (also sets `OPENCLAW_GATEWAY_TOKEN` for the process). -- `--password `: password override (also sets `OPENCLAW_GATEWAY_PASSWORD` for the process). +- `--password `: password override. Warning: inline passwords can be exposed in local process listings. +- `--password-file `: read the gateway password from a file. - `--tailscale `: expose the Gateway via Tailscale. - `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown. - `--allow-unconfigured`: allow gateway start without `gateway.mode=local` in config. @@ -170,6 +171,7 @@ Notes: - `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`. - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext. +- For password auth on `gateway run`, prefer `OPENCLAW_GATEWAY_PASSWORD`, `--password-file`, or a SecretRef-backed `gateway.auth.password` over inline `--password`. - In inferred auth mode, shell-only `OPENCLAW_GATEWAY_PASSWORD`/`CLAWDBOT_GATEWAY_PASSWORD` does not relax install token requirements; use durable config (`gateway.auth.password` or config `env`) when installing a managed service. - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, install is blocked until mode is set explicitly. - Lifecycle commands accept `--json` for scripting. diff --git a/docs/cli/index.md b/docs/cli/index.md index 84ded847d..634e2cdef 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -745,6 +745,7 @@ Options: - `--token ` - `--auth ` - `--password ` +- `--password-file ` - `--tailscale ` - `--tailscale-reset-on-exit` - `--allow-unconfigured` diff --git a/src/acp/secret-file.test.ts b/src/acp/secret-file.test.ts new file mode 100644 index 000000000..4db2d265d --- /dev/null +++ b/src/acp/secret-file.test.ts @@ -0,0 +1,54 @@ +import { mkdir, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTrackedTempDirs } from "../test-utils/tracked-temp-dirs.js"; +import { MAX_SECRET_FILE_BYTES, readSecretFromFile } from "./secret-file.js"; + +const tempDirs = createTrackedTempDirs(); +const createTempDir = () => tempDirs.make("openclaw-secret-file-test-"); + +afterEach(async () => { + await tempDirs.cleanup(); +}); + +describe("readSecretFromFile", () => { + it("reads and trims a regular secret file", async () => { + const dir = await createTempDir(); + const file = path.join(dir, "secret.txt"); + await writeFile(file, " top-secret \n", "utf8"); + + expect(readSecretFromFile(file, "Gateway password")).toBe("top-secret"); + }); + + it("rejects files larger than the secret-file limit", async () => { + const dir = await createTempDir(); + const file = path.join(dir, "secret.txt"); + await writeFile(file, "x".repeat(MAX_SECRET_FILE_BYTES + 1), "utf8"); + + expect(() => readSecretFromFile(file, "Gateway password")).toThrow( + `Gateway password file at ${file} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`, + ); + }); + + it("rejects non-regular files", async () => { + const dir = await createTempDir(); + const nestedDir = path.join(dir, "secret-dir"); + await mkdir(nestedDir); + + expect(() => readSecretFromFile(nestedDir, "Gateway password")).toThrow( + `Gateway password file at ${nestedDir} must be a regular file.`, + ); + }); + + it("rejects symlinks", async () => { + const dir = await createTempDir(); + const target = path.join(dir, "target.txt"); + const link = path.join(dir, "secret-link.txt"); + await writeFile(target, "top-secret\n", "utf8"); + await symlink(target, link); + + expect(() => readSecretFromFile(link, "Gateway password")).toThrow( + `Gateway password file at ${link} must not be a symlink.`, + ); + }); +}); diff --git a/src/acp/secret-file.ts b/src/acp/secret-file.ts index 537c92066..45ec36d28 100644 --- a/src/acp/secret-file.ts +++ b/src/acp/secret-file.ts @@ -1,11 +1,32 @@ import fs from "node:fs"; import { resolveUserPath } from "../utils.js"; +export const MAX_SECRET_FILE_BYTES = 16 * 1024; + export function readSecretFromFile(filePath: string, label: string): string { const resolvedPath = resolveUserPath(filePath.trim()); if (!resolvedPath) { throw new Error(`${label} file path is empty.`); } + + let stat: fs.Stats; + try { + stat = fs.lstatSync(resolvedPath); + } catch (err) { + throw new Error(`Failed to inspect ${label} file at ${resolvedPath}: ${String(err)}`, { + cause: err, + }); + } + if (stat.isSymbolicLink()) { + throw new Error(`${label} file at ${resolvedPath} must not be a symlink.`); + } + if (!stat.isFile()) { + throw new Error(`${label} file at ${resolvedPath} must be a regular file.`); + } + if (stat.size > MAX_SECRET_FILE_BYTES) { + throw new Error(`${label} file at ${resolvedPath} exceeds ${MAX_SECRET_FILE_BYTES} bytes.`); + } + let raw = ""; try { raw = fs.readFileSync(resolvedPath, "utf8"); diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 47d24049e..94af511e7 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "../test-runtime-capture.js"; @@ -239,4 +242,77 @@ describe("gateway run option collisions", () => { }), ); }); + + it("reads gateway password from --password-file", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-")); + try { + const passwordFile = path.join(tempDir, "gateway-password.txt"); + await fs.writeFile(passwordFile, "pw_from_file\n", "utf8"); + + await runGatewayCli([ + "gateway", + "run", + "--auth", + "password", + "--password-file", + passwordFile, + "--allow-unconfigured", + ]); + + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + auth: expect.objectContaining({ + mode: "password", + password: "pw_from_file", + }), + }), + ); + expect(runtimeErrors).not.toContain( + "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("warns when gateway password is passed inline", async () => { + await runGatewayCli([ + "gateway", + "run", + "--auth", + "password", + "--password", + "pw_inline", + "--allow-unconfigured", + ]); + + expect(runtimeErrors).toContain( + "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", + ); + }); + + it("rejects using both --password and --password-file", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-run-")); + try { + const passwordFile = path.join(tempDir, "gateway-password.txt"); + await fs.writeFile(passwordFile, "pw_from_file\n", "utf8"); + + await expect( + runGatewayCli([ + "gateway", + "run", + "--password", + "pw_inline", + "--password-file", + passwordFile, + "--allow-unconfigured", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors).toContain("Use either --password or --password-file."); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 7d456992f..0aa0e8ff3 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; +import { readSecretFromFile } from "../../acp/secret-file.js"; import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js"; import { CONFIG_PATH, @@ -40,6 +41,7 @@ type GatewayRunOpts = { token?: unknown; auth?: unknown; password?: unknown; + passwordFile?: unknown; tailscale?: unknown; tailscaleResetOnExit?: boolean; allowUnconfigured?: boolean; @@ -62,6 +64,7 @@ const GATEWAY_RUN_VALUE_KEYS = [ "token", "auth", "password", + "passwordFile", "tailscale", "wsLog", "rawStreamPath", @@ -87,6 +90,24 @@ const GATEWAY_AUTH_MODES: readonly GatewayAuthMode[] = [ ]; const GATEWAY_TAILSCALE_MODES: readonly GatewayTailscaleMode[] = ["off", "serve", "funnel"]; +function warnInlinePasswordFlag() { + defaultRuntime.error( + "Warning: --password can be exposed via process listings. Prefer --password-file or OPENCLAW_GATEWAY_PASSWORD.", + ); +} + +function resolveGatewayPasswordOption(opts: GatewayRunOpts): string | undefined { + const direct = toOptionString(opts.password); + const file = toOptionString(opts.passwordFile); + if (direct && file) { + throw new Error("Use either --password or --password-file."); + } + if (file) { + return readSecretFromFile(file, "Gateway password"); + } + return direct; +} + function parseEnumOption( raw: string | undefined, allowed: readonly T[], @@ -277,7 +298,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - const passwordRaw = toOptionString(opts.password); + let passwordRaw: string | undefined; + try { + passwordRaw = resolveGatewayPasswordOption(opts); + } catch (err) { + defaultRuntime.error(err instanceof Error ? err.message : String(err)); + defaultRuntime.exit(1); + return; + } + if (toOptionString(opts.password)) { + warnInlinePasswordFlag(); + } const tokenRaw = toOptionString(opts.token); const snapshot = await readConfigFileSnapshot().catch(() => null); @@ -439,6 +470,7 @@ export function addGatewayRunCommand(cmd: Command): Command { ) .option("--auth ", `Gateway auth mode (${formatModeChoices(GATEWAY_AUTH_MODES)})`) .option("--password ", "Password for auth mode=password") + .option("--password-file ", "Read gateway password from file") .option( "--tailscale ", `Tailscale exposure mode (${formatModeChoices(GATEWAY_TAILSCALE_MODES)})`,