mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
fix(gateway/auth): local trusted-proxy fallback to require token auth (#54536)
* fix(auth): improve local request and trusted proxy handling * fix(gateway): require token for local trusted-proxy fallback * docs(changelog): credit trusted-proxy auth fix * Update src/gateway/auth.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix(gateway): fail closed on forwarded local detection * docs(gateway): clarify fail-closed local detection * fix(gateway): harden trusted-proxy local fallback * fix(gateway): align trusted-proxy loopback validation * Update CHANGELOG.md --------- Co-authored-by: “zhangning” <zhangning.2025@bytedance.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/auth: make local-direct `trusted-proxy` fallback require the configured shared token instead of silently authenticating same-host callers, while keeping same-host reverse proxy identity-header flows on the normal trusted-proxy path. Thanks @zhangning-agent and @vincentkoc.
|
||||
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
|
||||
- LINE/ACP: add current-conversation binding and inbound binding-routing parity so `/acp spawn ... --thread here`, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels.
|
||||
- LINE/markdown: preserve underscores inside Latin, Cyrillic, and CJK words when stripping markdown, while still removing standalone `_italic_` markers on the shared text-runtime path used by LINE and TTS. (#47465) Thanks @jackjin1997.
|
||||
- TTS/Microsoft: auto-switch the default Edge voice to Chinese for CJK-dominant text without overriding explicitly selected Microsoft voices. (#52355) Thanks @extrasmall0.
|
||||
|
||||
@@ -632,4 +632,174 @@ describe("trusted-proxy auth", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
|
||||
describe("local-direct token fallback", () => {
|
||||
function authorizeLocalDirect(options?: {
|
||||
token?: string;
|
||||
connectToken?: string;
|
||||
trustedProxy?: GatewayConnectInput["auth"]["trustedProxy"];
|
||||
trustedProxies?: string[];
|
||||
}) {
|
||||
return authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
...(Object.hasOwn(options ?? {}, "trustedProxy")
|
||||
? { trustedProxy: options?.trustedProxy }
|
||||
: { trustedProxy: trustedProxyConfig }),
|
||||
token: options?.token,
|
||||
},
|
||||
connectAuth: options?.connectToken ? { token: options.connectToken } : null,
|
||||
trustedProxies: options?.trustedProxies ?? ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { host: "localhost" },
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
it("allows local-direct request with a valid token", async () => {
|
||||
const res = await authorizeLocalDirect({
|
||||
token: "secret",
|
||||
connectToken: "secret",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("token");
|
||||
});
|
||||
|
||||
it("rejects local-direct request without credentials", async () => {
|
||||
const res = await authorizeLocalDirect({
|
||||
token: "secret",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_missing");
|
||||
});
|
||||
|
||||
it("rejects local-direct request with a wrong token", async () => {
|
||||
const res = await authorizeLocalDirect({
|
||||
token: "secret",
|
||||
connectToken: "wrong",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
});
|
||||
|
||||
it("rejects local-direct request when no local token is configured", async () => {
|
||||
const res = await authorizeLocalDirect({
|
||||
connectToken: "secret",
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_missing_config");
|
||||
});
|
||||
|
||||
it("rejects trusted-proxy identity headers from loopback sources", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "localhost",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_loopback_source");
|
||||
});
|
||||
|
||||
it("fails closed when forwarded headers are present but the client chain resolves to loopback", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "secret",
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "localhost",
|
||||
"x-forwarded-for": "127.0.0.1",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_loopback_source");
|
||||
});
|
||||
|
||||
it("uses token fallback for direct loopback even when Host is not localish", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "secret",
|
||||
},
|
||||
connectAuth: { token: "secret" },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "evil.example",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("token");
|
||||
});
|
||||
|
||||
it("rejects same-host proxy request with missing required header", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "localhost",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
// missing x-forwarded-proto (requiredHeader)
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_loopback_source");
|
||||
});
|
||||
|
||||
it("still fails closed when trusted-proxy config is missing", async () => {
|
||||
const res = await authorizeLocalDirect({
|
||||
token: "secret",
|
||||
connectToken: "secret",
|
||||
trustedProxy: undefined,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_config_missing");
|
||||
});
|
||||
|
||||
it("still fails closed when trusted proxies are not configured", async () => {
|
||||
const res = await authorizeLocalDirect({
|
||||
token: "secret",
|
||||
connectToken: "secret",
|
||||
trustedProxies: [],
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_no_proxies_configured");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "./auth-rate-limit.js";
|
||||
import { resolveGatewayCredentialsFromValues } from "./credentials.js";
|
||||
import {
|
||||
isLocalishHost,
|
||||
isLoopbackAddress,
|
||||
resolveRequestClientIp,
|
||||
isTrustedProxyAddress,
|
||||
@@ -115,25 +114,25 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
|
||||
|
||||
export function isLocalDirectRequest(
|
||||
req?: IncomingMessage,
|
||||
trustedProxies?: string[],
|
||||
allowRealIpFallback = false,
|
||||
_trustedProxies?: string[],
|
||||
_allowRealIpFallback = false,
|
||||
): boolean {
|
||||
if (!req) {
|
||||
return false;
|
||||
}
|
||||
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasForwarded = Boolean(
|
||||
req.headers?.forwarded ||
|
||||
req.headers?.["x-forwarded-for"] ||
|
||||
req.headers?.["x-forwarded-proto"] ||
|
||||
req.headers?.["x-real-ip"] ||
|
||||
req.headers?.["x-forwarded-host"],
|
||||
);
|
||||
|
||||
const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies);
|
||||
return isLocalishHost(req.headers?.host) && (!hasForwarded || remoteIsTrustedProxy);
|
||||
if (!hasForwarded) {
|
||||
return isLoopbackAddress(req.socket?.remoteAddress);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
@@ -337,6 +336,9 @@ function authorizeTrustedProxy(params: {
|
||||
if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) {
|
||||
return { reason: "trusted_proxy_untrusted_source" };
|
||||
}
|
||||
if (isLoopbackAddress(remoteAddr)) {
|
||||
return { reason: "trusted_proxy_loopback_source" };
|
||||
}
|
||||
|
||||
const requiredHeaders = trustedProxyConfig.requiredHeaders ?? [];
|
||||
for (const header of requiredHeaders) {
|
||||
@@ -365,6 +367,30 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea
|
||||
return authSurface === "ws-control-ui";
|
||||
}
|
||||
|
||||
function authorizeTokenAuth(params: {
|
||||
authToken?: string;
|
||||
connectToken?: string;
|
||||
limiter?: AuthRateLimiter;
|
||||
ip?: string;
|
||||
rateLimitScope: string;
|
||||
}): GatewayAuthResult {
|
||||
if (!params.authToken) {
|
||||
return { ok: false, reason: "token_missing_config" };
|
||||
}
|
||||
if (!params.connectToken) {
|
||||
// Don't burn rate-limit slots for missing credentials — the client
|
||||
// simply hasn't provided a token yet (e.g. bare browser open).
|
||||
// Only actual *wrong* credentials should count as failures.
|
||||
return { ok: false, reason: "token_missing" };
|
||||
}
|
||||
if (!safeEqualSecret(params.connectToken, params.authToken)) {
|
||||
params.limiter?.recordFailure(params.ip, params.rateLimitScope);
|
||||
return { ok: false, reason: "token_mismatch" };
|
||||
}
|
||||
params.limiter?.reset(params.ip, params.rateLimitScope);
|
||||
return { ok: true, method: "token" };
|
||||
}
|
||||
|
||||
export async function authorizeGatewayConnect(
|
||||
params: AuthorizeGatewayConnectParams,
|
||||
): Promise<GatewayAuthResult> {
|
||||
@@ -372,6 +398,12 @@ export async function authorizeGatewayConnect(
|
||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||
const authSurface = params.authSurface ?? "http";
|
||||
const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface);
|
||||
const limiter = params.rateLimiter;
|
||||
const ip =
|
||||
params.clientIp ??
|
||||
resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ??
|
||||
req?.socket?.remoteAddress;
|
||||
const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET;
|
||||
const localDirect = isLocalDirectRequest(
|
||||
req,
|
||||
trustedProxies,
|
||||
@@ -379,6 +411,9 @@ export async function authorizeGatewayConnect(
|
||||
);
|
||||
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
// Same-host reverse proxies may forward identity headers without a full
|
||||
// forwarded chain; keep those on the trusted-proxy path so allowUsers and
|
||||
// requiredHeaders still apply. Only raw local-direct traffic falls back.
|
||||
if (!auth.trustedProxy) {
|
||||
return { ok: false, reason: "trusted_proxy_config_missing" };
|
||||
}
|
||||
@@ -386,6 +421,30 @@ export async function authorizeGatewayConnect(
|
||||
return { ok: false, reason: "trusted_proxy_no_proxies_configured" };
|
||||
}
|
||||
|
||||
const proxyUserHeader = auth.trustedProxy?.userHeader?.toLowerCase();
|
||||
const hasProxyIdentityHeader =
|
||||
proxyUserHeader !== undefined && Boolean(req?.headers?.[proxyUserHeader]);
|
||||
if (localDirect && !hasProxyIdentityHeader) {
|
||||
if (limiter) {
|
||||
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
|
||||
if (!rlCheck.allowed) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "rate_limited",
|
||||
rateLimited: true,
|
||||
retryAfterMs: rlCheck.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
return authorizeTokenAuth({
|
||||
authToken: auth.token,
|
||||
connectToken: connectAuth?.token,
|
||||
limiter,
|
||||
ip,
|
||||
rateLimitScope,
|
||||
});
|
||||
}
|
||||
|
||||
const result = authorizeTrustedProxy({
|
||||
req,
|
||||
trustedProxies,
|
||||
@@ -402,12 +461,6 @@ export async function authorizeGatewayConnect(
|
||||
return { ok: true, method: "none" };
|
||||
}
|
||||
|
||||
const limiter = params.rateLimiter;
|
||||
const ip =
|
||||
params.clientIp ??
|
||||
resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ??
|
||||
req?.socket?.remoteAddress;
|
||||
const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET;
|
||||
if (limiter) {
|
||||
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
|
||||
if (!rlCheck.allowed) {
|
||||
@@ -436,21 +489,13 @@ export async function authorizeGatewayConnect(
|
||||
}
|
||||
|
||||
if (auth.mode === "token") {
|
||||
if (!auth.token) {
|
||||
return { ok: false, reason: "token_missing_config" };
|
||||
}
|
||||
if (!connectAuth?.token) {
|
||||
// Don't burn rate-limit slots for missing credentials — the client
|
||||
// simply hasn't provided a token yet (e.g. bare browser open).
|
||||
// Only actual *wrong* credentials should count as failures.
|
||||
return { ok: false, reason: "token_missing" };
|
||||
}
|
||||
if (!safeEqualSecret(connectAuth.token, auth.token)) {
|
||||
limiter?.recordFailure(ip, rateLimitScope);
|
||||
return { ok: false, reason: "token_mismatch" };
|
||||
}
|
||||
limiter?.reset(ip, rateLimitScope);
|
||||
return { ok: true, method: "token" };
|
||||
return authorizeTokenAuth({
|
||||
authToken: auth.token,
|
||||
connectToken: connectAuth?.token,
|
||||
limiter,
|
||||
ip,
|
||||
rateLimitScope,
|
||||
});
|
||||
}
|
||||
|
||||
if (auth.mode === "password") {
|
||||
|
||||
@@ -76,18 +76,6 @@ describe("resolveGatewayRuntimeConfig", () => {
|
||||
expectedMessage:
|
||||
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
|
||||
},
|
||||
{
|
||||
name: "loopback binding without loopback trusted proxy",
|
||||
cfg: {
|
||||
gateway: {
|
||||
bind: "loopback" as const,
|
||||
auth: TRUSTED_PROXY_AUTH,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
},
|
||||
},
|
||||
expectedMessage:
|
||||
"gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR",
|
||||
},
|
||||
{
|
||||
name: "lan binding without trusted proxies",
|
||||
cfg: {
|
||||
@@ -106,6 +94,22 @@ describe("resolveGatewayRuntimeConfig", () => {
|
||||
expectedMessage,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows loopback binding with non-loopback trusted proxies", async () => {
|
||||
const result = await resolveGatewayRuntimeConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
auth: TRUSTED_PROXY_AUTH,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
},
|
||||
},
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
expect(result.authMode).toBe("trusted-proxy");
|
||||
expect(result.bindHost).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("token/password auth modes", () => {
|
||||
|
||||
@@ -11,12 +11,7 @@ import {
|
||||
} from "./auth.js";
|
||||
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
|
||||
import { resolveHooksConfig } from "./hooks.js";
|
||||
import {
|
||||
isLoopbackHost,
|
||||
isTrustedProxyAddress,
|
||||
isValidIPv4,
|
||||
resolveGatewayBindHost,
|
||||
} from "./net.js";
|
||||
import { isLoopbackHost, isValidIPv4, resolveGatewayBindHost } from "./net.js";
|
||||
import { mergeGatewayTailscaleConfig } from "./startup-auth.js";
|
||||
|
||||
export type GatewayRuntimeConfig = {
|
||||
@@ -152,16 +147,6 @@ export async function resolveGatewayRuntimeConfig(params: {
|
||||
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP",
|
||||
);
|
||||
}
|
||||
if (isLoopbackHost(bindHost)) {
|
||||
const hasLoopbackTrustedProxy =
|
||||
isTrustedProxyAddress("127.0.0.1", trustedProxies) ||
|
||||
isTrustedProxyAddress("::1", trustedProxies);
|
||||
if (!hasLoopbackTrustedProxy) {
|
||||
throw new Error(
|
||||
"gateway auth mode=trusted-proxy with bind=loopback requires gateway.trustedProxies to include 127.0.0.1, ::1, or a loopback CIDR",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user