openclaw/src/gateway/credential-planner.ts
Josh Avant 0125ce1f44
Gateway: fail closed unresolved local auth SecretRefs (#42672)
* Gateway: fail closed unresolved local auth SecretRefs

* Docs: align node-host gateway auth precedence

* CI: resolve rebase breakages in checks lanes

* Tests: isolate LOCAL_REMOTE_FALLBACK_TOKEN env state

* Gateway: remove stale remote.enabled auth-surface semantics

* Changelog: note gateway SecretRef fail-closed fix
2026-03-10 21:41:56 -05:00

217 lines
6.8 KiB
TypeScript

import type { OpenClawConfig } from "../config/config.js";
import { containsEnvVarReference } from "../config/env-substitution.js";
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
export type GatewayCredentialInputPath =
| "gateway.auth.token"
| "gateway.auth.password"
| "gateway.remote.token"
| "gateway.remote.password";
export type GatewayConfiguredCredentialInput = {
path: GatewayCredentialInputPath;
configured: boolean;
value?: string;
refPath?: GatewayCredentialInputPath;
hasSecretRef: boolean;
};
export type GatewayCredentialPlan = {
configuredMode: "local" | "remote";
authMode?: string;
envToken?: string;
envPassword?: string;
localToken: GatewayConfiguredCredentialInput;
localPassword: GatewayConfiguredCredentialInput;
remoteToken: GatewayConfiguredCredentialInput;
remotePassword: GatewayConfiguredCredentialInput;
localTokenCanWin: boolean;
localPasswordCanWin: boolean;
localTokenSurfaceActive: boolean;
tokenCanWin: boolean;
passwordCanWin: boolean;
remoteMode: boolean;
remoteUrlConfigured: boolean;
tailscaleRemoteExposure: boolean;
remoteConfiguredSurface: boolean;
remoteTokenFallbackActive: boolean;
remoteTokenActive: boolean;
remotePasswordFallbackActive: boolean;
remotePasswordActive: boolean;
};
type GatewaySecretDefaults = NonNullable<OpenClawConfig["secrets"]>["defaults"];
function readGatewayEnv(
env: NodeJS.ProcessEnv,
names: readonly string[],
includeLegacyEnv: boolean,
): string | undefined {
const keys = includeLegacyEnv ? names : names.slice(0, 1);
for (const name of keys) {
const value = trimToUndefined(env[name]);
if (value) {
return value;
}
}
return undefined;
}
export function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Like trimToUndefined but also rejects unresolved env var placeholders (e.g. `${VAR}`).
* This prevents literal placeholder strings like `${OPENCLAW_GATEWAY_TOKEN}` from being
* accepted as valid credentials when the referenced env var is missing.
* Note: legitimate credential values containing literal `${UPPER_CASE}` patterns will
* also be rejected, but this is an extremely unlikely edge case.
*/
export function trimCredentialToUndefined(value: unknown): string | undefined {
const trimmed = trimToUndefined(value);
if (trimmed && containsEnvVarReference(trimmed)) {
return undefined;
}
return trimmed;
}
export function readGatewayTokenEnv(
env: NodeJS.ProcessEnv = process.env,
includeLegacyEnv = true,
): string | undefined {
return readGatewayEnv(
env,
["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN"],
includeLegacyEnv,
);
}
export function readGatewayPasswordEnv(
env: NodeJS.ProcessEnv = process.env,
includeLegacyEnv = true,
): string | undefined {
return readGatewayEnv(
env,
["OPENCLAW_GATEWAY_PASSWORD", "CLAWDBOT_GATEWAY_PASSWORD"],
includeLegacyEnv,
);
}
export function hasGatewayTokenEnvCandidate(
env: NodeJS.ProcessEnv = process.env,
includeLegacyEnv = true,
): boolean {
return Boolean(readGatewayTokenEnv(env, includeLegacyEnv));
}
export function hasGatewayPasswordEnvCandidate(
env: NodeJS.ProcessEnv = process.env,
includeLegacyEnv = true,
): boolean {
return Boolean(readGatewayPasswordEnv(env, includeLegacyEnv));
}
function resolveConfiguredGatewayCredentialInput(params: {
value: unknown;
defaults?: GatewaySecretDefaults;
path: GatewayCredentialInputPath;
}): GatewayConfiguredCredentialInput {
const ref = resolveSecretInputRef({
value: params.value,
defaults: params.defaults,
}).ref;
return {
path: params.path,
configured: hasConfiguredSecretInput(params.value, params.defaults),
value: ref ? undefined : trimToUndefined(params.value),
refPath: ref ? params.path : undefined,
hasSecretRef: ref !== null,
};
}
export function createGatewayCredentialPlan(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includeLegacyEnv?: boolean;
defaults?: GatewaySecretDefaults;
}): GatewayCredentialPlan {
const env = params.env ?? process.env;
const includeLegacyEnv = params.includeLegacyEnv ?? true;
const gateway = params.config.gateway;
const remote = gateway?.remote;
const defaults = params.defaults ?? params.config.secrets?.defaults;
const authMode = gateway?.auth?.mode;
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
const localToken = resolveConfiguredGatewayCredentialInput({
value: gateway?.auth?.token,
defaults,
path: "gateway.auth.token",
});
const localPassword = resolveConfiguredGatewayCredentialInput({
value: gateway?.auth?.password,
defaults,
path: "gateway.auth.password",
});
const remoteToken = resolveConfiguredGatewayCredentialInput({
value: remote?.token,
defaults,
path: "gateway.remote.token",
});
const remotePassword = resolveConfiguredGatewayCredentialInput({
value: remote?.password,
defaults,
path: "gateway.remote.password",
});
const localTokenCanWin =
authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy";
const tokenCanWin = Boolean(envToken || localToken.configured || remoteToken.configured);
const passwordCanWin =
authMode === "password" ||
(authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin);
const localTokenSurfaceActive =
localTokenCanWin &&
!envToken &&
(authMode === "token" ||
(authMode === undefined && !(envPassword || localPassword.configured)));
const remoteMode = gateway?.mode === "remote";
const remoteUrlConfigured = Boolean(trimToUndefined(remote?.url));
const tailscaleRemoteExposure =
gateway?.tailscale?.mode === "serve" || gateway?.tailscale?.mode === "funnel";
const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure;
const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localToken.configured;
const remotePasswordFallbackActive = !envPassword && !localPassword.configured && passwordCanWin;
return {
configuredMode: gateway?.mode === "remote" ? "remote" : "local",
authMode,
envToken,
envPassword,
localToken,
localPassword,
remoteToken,
remotePassword,
localTokenCanWin,
localPasswordCanWin: passwordCanWin,
localTokenSurfaceActive,
tokenCanWin,
passwordCanWin,
remoteMode,
remoteUrlConfigured,
tailscaleRemoteExposure,
remoteConfiguredSurface,
remoteTokenFallbackActive,
remoteTokenActive: remoteConfiguredSurface || remoteTokenFallbackActive,
remotePasswordFallbackActive,
remotePasswordActive: remoteConfiguredSurface || remotePasswordFallbackActive,
};
}