fix: allow trusted-proxy local password fallback

This commit is contained in:
Peter Steinberger
2026-04-27 21:44:13 +01:00
parent 61a18e5596
commit 1a98938479
10 changed files with 265 additions and 76 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Gateway/supervisor: exit cleanly when a supervised restart finds an existing healthy gateway and bound retries when the existing gateway stays unhealthy, so stale lock contention cannot loop indefinitely. Refs #72846. Thanks @azgardtek.
- Gateway/startup: scope primary-model provider discovery during channel prewarm to the configured provider owner and add split startup trace timings, so boot avoids staging unrelated bundled provider dependencies while setup discovery remains broad. Fixes #73002. Thanks @Schnup03.
- Channels/Microsoft Teams: unwrap staged CommonJS JWT runtime dependencies before Bot Connector token validation so inbound Teams messages no longer 401 after the bundled runtime-deps move. Fixes #73026. Thanks @kbrown10000.
- Gateway/auth: allow local direct callers in trusted-proxy mode to use the configured gateway password as an internal fallback while keeping token fallback rejected. Fixes #17761. Thanks @dashed, @vincentkoc, and @jetd1.
- Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov.
- Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz.
- Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03.

View File

@@ -394,7 +394,7 @@ See [Plugins](/tools/plugin).
- **Auth**: required by default. Non-loopback binds require gateway auth. In practice that means a shared token/password or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. Onboarding wizard generates a token by default.
- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs), set `gateway.auth.mode` explicitly to `token` or `password`. Startup and service install/repair flows fail when both are configured and mode is unset.
- `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
- `gateway.auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source; same-host loopback reverse proxies do not satisfy trusted-proxy auth.
- `gateway.auth.mode: "trusted-proxy"`: delegate browser/user auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source; same-host loopback reverse proxies do not satisfy trusted-proxy identity auth. Internal same-host callers can use `gateway.auth.password` as a local direct fallback; `gateway.auth.token` remains mutually exclusive with trusted-proxy mode.
- `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`). HTTP API endpoints do **not** use that Tailscale header auth; they follow the gateway's normal HTTP auth mode instead. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
- `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
- On the async Tailscale Serve Control UI path, failed attempts for the same `{scope, clientIp}` are serialized before the failure write. Concurrent bad attempts from the same client can therefore trip the limiter on the second request instead of both racing through as plain mismatches.

View File

@@ -293,7 +293,7 @@ If you see a `mixed_trusted_proxy_token` error on startup:
- Remove the shared token when using trusted-proxy mode, or
- Switch `gateway.auth.mode` to `"token"` if you intend token-based auth.
Loopback trusted-proxy auth also fails closed: same-host callers must supply the configured identity headers through a trusted proxy instead of being silently authenticated.
Loopback trusted-proxy identity headers still fail closed: same-host callers are not silently authenticated as proxy users. Internal OpenClaw callers that bypass the proxy may authenticate with `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead. Token fallback remains intentionally unsupported in trusted-proxy mode.
## Operator scopes header
@@ -327,6 +327,7 @@ Before enabling trusted-proxy auth, verify:
- [ ] **allowedOrigins is explicit**: Non-loopback Control UI uses explicit `gateway.controlUi.allowedOrigins`.
- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated.
- [ ] **No mixed token config**: Do not set both `gateway.auth.token` and `gateway.auth.mode: "trusted-proxy"`.
- [ ] **Local password fallback is private**: If you configure `gateway.auth.password` for internal direct callers, keep the Gateway port firewalled so non-proxy remote clients cannot reach it directly.
## Security audit

View File

@@ -902,6 +902,9 @@ describe("trusted-proxy auth", () => {
function authorizeLocalDirect(options?: {
token?: string;
connectToken?: string;
password?: string;
connectPassword?: string;
rateLimiter?: AuthRateLimiter;
trustedProxy?: GatewayConnectInput["auth"]["trustedProxy"];
trustedProxies?: string[];
}) {
@@ -913,8 +916,13 @@ describe("trusted-proxy auth", () => {
? { trustedProxy: options?.trustedProxy }
: { trustedProxy: trustedProxyConfig }),
token: options?.token,
password: options?.password, // pragma: allowlist secret
},
connectAuth: options?.connectToken ? { token: options.connectToken } : null,
connectAuth:
options?.connectToken || options?.connectPassword
? { token: options.connectToken, password: options.connectPassword }
: null,
rateLimiter: options?.rateLimiter,
trustedProxies: options?.trustedProxies ?? ["127.0.0.1"],
req: {
socket: { remoteAddress: "127.0.0.1" },
@@ -956,6 +964,67 @@ describe("trusted-proxy auth", () => {
expect(res.reason).toBe("trusted_proxy_loopback_source");
});
it("accepts local-direct password fallback when trusted-proxy auth fails", async () => {
const limiter = createLimiterSpy();
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
connectPassword: "local-password", // pragma: allowlist secret
rateLimiter: limiter,
});
expect(res).toEqual({ ok: true, method: "password" });
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.reset).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.recordFailure).not.toHaveBeenCalled();
});
it("rejects wrong local-direct password fallback and records the failure", async () => {
const limiter = createLimiterSpy();
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
connectPassword: "wrong-password", // pragma: allowlist secret
rateLimiter: limiter,
});
expect(res).toEqual({ ok: false, reason: "password_mismatch" });
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.reset).not.toHaveBeenCalled();
});
it("enforces rate-limit lockout before local-direct password fallback", async () => {
const limiter = createLimiterSpy();
limiter.check.mockReturnValueOnce({
allowed: false,
remaining: 0,
retryAfterMs: 2500,
});
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
connectPassword: "local-password", // pragma: allowlist secret
rateLimiter: limiter,
});
expect(res).toEqual({
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: 2500,
});
expect(limiter.recordFailure).not.toHaveBeenCalled();
expect(limiter.reset).not.toHaveBeenCalled();
});
it("keeps local-direct trusted-proxy on proxy failure when no password is supplied", async () => {
const res = await authorizeLocalDirect({
password: "local-password", // pragma: allowlist secret
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_loopback_source");
});
it("rejects trusted-proxy identity headers from loopback sources", async () => {
const res = await authorizeGatewayConnect({
auth: {
@@ -984,9 +1053,9 @@ describe("trusted-proxy auth", () => {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: trustedProxyConfig,
token: "secret",
password: "secret", // pragma: allowlist secret
},
connectAuth: null,
connectAuth: { password: "secret" },
trustedProxies: ["127.0.0.1"],
req: {
socket: { remoteAddress: "127.0.0.1" },
@@ -1048,8 +1117,8 @@ describe("trusted-proxy auth", () => {
it("still fails closed when trusted-proxy config is missing", async () => {
const res = await authorizeLocalDirect({
token: "secret",
connectToken: "secret",
password: "secret", // pragma: allowlist secret
connectPassword: "secret", // pragma: allowlist secret
trustedProxy: undefined,
});
expect(res.ok).toBe(false);
@@ -1058,8 +1127,8 @@ describe("trusted-proxy auth", () => {
it("still fails closed when trusted proxies are not configured", async () => {
const res = await authorizeLocalDirect({
token: "secret",
connectToken: "secret",
password: "secret", // pragma: allowlist secret
connectPassword: "secret", // pragma: allowlist secret
trustedProxies: [],
});
expect(res.ok).toBe(false);

View File

@@ -358,6 +358,28 @@ function authorizeTokenAuth(params: {
return { ok: true, method: "token" };
}
function authorizePasswordAuth(params: {
authPassword?: string;
connectPassword?: string;
limiter?: AuthRateLimiter;
ip?: string;
rateLimitScope: string;
}): GatewayAuthResult {
if (!params.authPassword) {
return { ok: false, reason: "password_missing_config" };
}
if (!params.connectPassword) {
// Same as token_missing — don't penalize absent credentials.
return { ok: false, reason: "password_missing" };
}
if (!safeEqualSecret(params.connectPassword, params.authPassword)) {
params.limiter?.recordFailure(params.ip, params.rateLimitScope);
return { ok: false, reason: "password_mismatch" };
}
params.limiter?.reset(params.ip, params.rateLimitScope);
return { ok: true, method: "password" };
}
export async function authorizeGatewayConnect(
params: AuthorizeGatewayConnectParams,
): Promise<GatewayAuthResult> {
@@ -439,6 +461,26 @@ async function authorizeGatewayConnectCore(
}
return { ok: true, method: "trusted-proxy", user: result.user };
}
if (localDirect && auth.password && connectAuth?.password) {
if (limiter) {
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
if (!rlCheck.allowed) {
return {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: rlCheck.retryAfterMs,
};
}
}
return authorizePasswordAuth({
authPassword: auth.password,
connectPassword: connectAuth.password,
limiter,
ip,
rateLimitScope,
});
}
return { ok: false, reason: result.reason };
}
@@ -489,20 +531,13 @@ async function authorizeGatewayConnectCore(
}
if (auth.mode === "password") {
const password = connectAuth?.password;
if (!auth.password) {
return { ok: false, reason: "password_missing_config" };
}
if (!password) {
// Same as token_missing — don't penalize absent credentials.
return { ok: false, reason: "password_missing" };
}
if (!safeEqualSecret(password, auth.password)) {
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "password_mismatch" };
}
limiter?.reset(ip, rateLimitScope);
return { ok: true, method: "password" };
return authorizePasswordAuth({
authPassword: auth.password,
connectPassword: connectAuth?.password,
limiter,
ip,
rateLimitScope,
});
}
limiter?.recordFailure(ip, rateLimitScope);

View File

@@ -1266,31 +1266,72 @@ describe("callGateway password resolution", () => {
await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.token");
});
it.each(["none", "trusted-proxy"] as const)(
"ignores unresolved local password ref when auth mode is %s",
async (mode) => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode,
password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" },
},
it("ignores unresolved local password ref when auth mode is none", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode: "none",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" },
},
secrets: {
providers: {
default: { source: "env" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
} as unknown as OpenClawConfig);
},
} as unknown as OpenClawConfig);
await callGateway({ method: "health" });
await callGateway({ method: "health" });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
},
);
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
});
it("resolves local password refs when auth mode is trusted-proxy", async () => {
process.env.LOCAL_TRUSTED_PROXY_PASSWORD = "resolved-trusted-proxy-password";
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode: "trusted-proxy",
password: { source: "env", provider: "default", id: "LOCAL_TRUSTED_PROXY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig);
await callGateway({ method: "health" });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBe("resolved-trusted-proxy-password"); // pragma: allowlist secret
});
it("fails closed when trusted-proxy local password ref cannot resolve", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: {
mode: "trusted-proxy",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig);
await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.password");
});
it("does not resolve local password ref when remote password is already configured", async () => {
getRuntimeConfig.mockReturnValue({
@@ -1467,33 +1508,55 @@ describe("callGateway password resolution", () => {
expect(lastClientOptions?.password).toBeUndefined();
});
it.each(["none", "trusted-proxy"] as const)(
"does not resolve remote refs on non-remote gateway calls when auth mode is %s",
async (mode) => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode },
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" },
password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" },
},
it("does not resolve remote refs on non-remote gateway calls when auth mode is none", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "none" },
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" },
password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" },
},
secrets: {
providers: {
default: { source: "env" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
} as unknown as OpenClawConfig);
},
} as unknown as OpenClawConfig);
await callGateway({ method: "health" });
await callGateway({ method: "health" });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
},
);
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
});
it("does not resolve remote refs on non-remote gateway calls when auth mode is trusted-proxy", async () => {
getRuntimeConfig.mockReturnValue({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "trusted-proxy" },
remote: {
url: "wss://remote.example:18789",
token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" },
password: { source: "env", provider: "default", id: "MISSING_REMOTE_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig);
await callGateway({ method: "health" });
expect(lastClientOptions?.token).toBeUndefined();
expect(lastClientOptions?.password).toBeUndefined();
});
it.each(explicitAuthCases)("uses explicit $label when url override is set", async (testCase) => {
process.env[testCase.envKey] = testCase.envValue;

View File

@@ -125,7 +125,8 @@ export function createGatewayCredentialPlan(params: {
const tokenCanWin = Boolean(envToken || localToken.configured || remoteToken.configured);
const passwordCanWin =
authMode === "password" ||
(authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin);
authMode === "trusted-proxy" ||
(authMode !== "token" && authMode !== "none" && !tokenCanWin);
const localTokenSurfaceActive =
localTokenCanWin &&
!envToken &&

View File

@@ -112,9 +112,12 @@ function localAuthModeAllowsGatewaySecretInputPath(params: {
path: SupportedGatewaySecretInputPath;
}): boolean {
const { authMode, path } = params;
if (authMode === "none" || authMode === "trusted-proxy") {
if (authMode === "none") {
return false;
}
if (authMode === "trusted-proxy") {
return !isTokenGatewaySecretInputPath(path);
}
if (authMode === "token") {
return isTokenGatewaySecretInputPath(path);
}

View File

@@ -283,11 +283,29 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
});
it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => {
const resolved = resolveLocalModeWithUnresolvedPassword("trusted-proxy");
it("throws when trusted-proxy local password SecretRef cannot resolve", () => {
expect(() => resolveLocalModeWithUnresolvedPassword("trusted-proxy")).toThrow(
"gateway.auth.password",
);
});
it("resolves trusted-proxy local password credentials", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: {
mode: "trusted-proxy",
password: "local-trusted-proxy-password", // pragma: allowlist secret
},
},
}),
env: {} as NodeJS.ProcessEnv,
});
expect(resolved).toEqual({
token: undefined,
password: undefined,
password: "local-trusted-proxy-password", // pragma: allowlist secret
});
});

View File

@@ -120,10 +120,8 @@ function resolveLocalGatewayCredentials(params: {
});
const localPasswordCanWin =
params.plan.authMode === "password" ||
(params.plan.authMode !== "token" &&
params.plan.authMode !== "none" &&
params.plan.authMode !== "trusted-proxy" &&
!localResolved.token);
params.plan.authMode === "trusted-proxy" ||
(params.plan.authMode !== "token" && params.plan.authMode !== "none" && !localResolved.token);
const localTokenCanWin =
params.plan.authMode === "token" ||
(params.plan.authMode !== "password" &&