mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
fix(gateway): require token for local trusted-proxy fallback
This commit is contained in:
@@ -634,16 +634,23 @@ describe("trusted-proxy auth", () => {
|
||||
});
|
||||
|
||||
describe("local-direct token fallback", () => {
|
||||
function authorizeLocalDirect(options?: { token?: string; connectToken?: string }) {
|
||||
function authorizeLocalDirect(options?: {
|
||||
token?: string;
|
||||
connectToken?: string;
|
||||
trustedProxy?: GatewayConnectInput["auth"]["trustedProxy"];
|
||||
trustedProxies?: string[];
|
||||
}) {
|
||||
return authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
...(Object.hasOwn(options ?? {}, "trustedProxy")
|
||||
? { trustedProxy: options?.trustedProxy }
|
||||
: { trustedProxy: trustedProxyConfig }),
|
||||
token: options?.token,
|
||||
},
|
||||
connectAuth: options?.connectToken ? { token: options.connectToken } : null,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
trustedProxies: options?.trustedProxies ?? ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { host: "localhost" },
|
||||
@@ -651,11 +658,38 @@ describe("trusted-proxy auth", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("allows local-direct request without credentials", async () => {
|
||||
const res = await authorizeLocalDirect({});
|
||||
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("trusted-proxy");
|
||||
expect(res.user).toBe("local");
|
||||
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("runs full proxy auth for same-host proxy that forwards only the identity header", async () => {
|
||||
@@ -705,5 +739,25 @@ describe("trusted-proxy auth", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,6 +377,27 @@ 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) {
|
||||
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> {
|
||||
@@ -384,6 +405,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,
|
||||
@@ -391,18 +418,9 @@ export async function authorizeGatewayConnect(
|
||||
);
|
||||
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
// A local-direct request with no proxy identity header is a raw CLI/sub-agent
|
||||
// connection — allow it directly as "local" without header checks.
|
||||
// If the identity header IS present (same-host reverse proxy forwarding user
|
||||
// identity without x-forwarded-for), fall through to authorizeTrustedProxy so
|
||||
// that allowUsers and userHeader are properly evaluated.
|
||||
const proxyUserHeader = auth.trustedProxy?.userHeader?.toLowerCase();
|
||||
const hasProxyIdentityHeader =
|
||||
proxyUserHeader !== undefined && Boolean(req?.headers?.[proxyUserHeader]);
|
||||
if (localDirect && !hasProxyIdentityHeader) {
|
||||
return { ok: true, method: "trusted-proxy", user: "local" };
|
||||
}
|
||||
|
||||
// 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" };
|
||||
}
|
||||
@@ -410,6 +428,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,
|
||||
@@ -426,12 +468,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) {
|
||||
@@ -460,21 +496,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") {
|
||||
|
||||
Reference in New Issue
Block a user