Merge 4136fa520fa5b49bbf43c00ebfc9c3a88d482b57 into 43513cd1df63af0704dfb351ee7864607f955dcc

This commit is contained in:
Rick Stoner 2026-03-21 01:44:50 -04:00 committed by GitHub
commit 4693ca6c80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 172 additions and 31 deletions

View File

@ -139,25 +139,6 @@ describe("gateway auth", () => {
});
});
it("treats env-template auth secrets as SecretRefs instead of plaintext", () => {
expect(
resolveGatewayAuth({
authConfig: {
token: "${OPENCLAW_GATEWAY_TOKEN}",
password: "${OPENCLAW_GATEWAY_PASSWORD}",
},
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
token: "env-token",
password: "env-password",
mode: "password",
});
});
it("resolves explicit auth mode none from config", () => {
expect(
resolveGatewayAuth({
@ -508,6 +489,84 @@ describe("trusted-proxy auth", () => {
expect(res.user).toBe("nick@example.com");
});
it("allows local-direct shared-token auth in trusted-proxy mode", async () => {
const localToken = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
token: "secret",
trustedProxy: trustedProxyConfig,
},
connectAuth: { token: "secret" },
trustedProxies: ["127.0.0.1"],
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "127.0.0.1:19001",
},
} as never,
});
expect(localToken.ok).toBe(true);
expect(localToken.method).toBe("token");
});
it("does not let local fallback preempt valid trusted-proxy header auth", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
token: "secret",
trustedProxy: trustedProxyConfig,
},
connectAuth: { token: "wrong" },
trustedProxies: ["127.0.0.1"],
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "nick@example.com",
"x-forwarded-proto": "https",
},
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("trusted-proxy");
expect(res.user).toBe("nick@example.com");
});
it("applies rate limiting before trusted-proxy local shared-secret fallback", async () => {
const limiter = createLimiterSpy();
limiter.check.mockReturnValue({
allowed: false,
remaining: 0,
retryAfterMs: 60_000,
});
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
token: "secret",
trustedProxy: trustedProxyConfig,
},
connectAuth: { token: "secret" },
trustedProxies: ["127.0.0.1"],
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "127.0.0.1:19001",
},
} as never,
rateLimiter: limiter,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("rate_limited");
expect(limiter.check).toHaveBeenCalled();
});
it("rejects request from untrusted source", async () => {
const res = await authorizeTrustedProxy({
remoteAddress: "192.168.1.100",

View File

@ -4,7 +4,6 @@ import type {
GatewayTailscaleMode,
GatewayTrustedProxyConfig,
} from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import {
@ -84,6 +83,14 @@ export type AuthorizeGatewayConnectParams = {
allowRealIpFallback?: boolean;
};
type SharedSecretAuthParams = {
auth: ResolvedGatewayAuth;
connectAuth?: ConnectAuth | null;
limiter?: AuthRateLimiter;
ip?: string;
rateLimitScope: string;
};
type TailscaleUser = {
login: string;
name: string;
@ -235,11 +242,9 @@ export function resolveGatewayAuth(params: {
}
}
const env = params.env ?? process.env;
const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref;
const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref;
const resolvedCredentials = resolveGatewayCredentialsFromValues({
configToken: tokenRef ? undefined : authConfig.token,
configPassword: passwordRef ? undefined : authConfig.password,
configToken: authConfig.token,
configPassword: authConfig.password,
env,
includeLegacyEnv: false,
tokenPrecedence: "config-first",
@ -366,6 +371,48 @@ function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolea
return authSurface === "ws-control-ui";
}
function authorizeSharedSecretFallback(params: SharedSecretAuthParams): GatewayAuthResult | null {
const { auth, connectAuth, limiter, ip, rateLimitScope } = params;
if (auth.password && connectAuth?.password) {
if (!safeEqualSecret(connectAuth.password, auth.password)) {
limiter?.recordFailure(ip, rateLimitScope);
return { ok: false, reason: "password_mismatch" };
}
limiter?.reset(ip, rateLimitScope);
return { ok: true, method: "password" };
}
if (auth.token && connectAuth?.token) {
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 null;
}
function hasConfiguredTrustedProxyHeaders(
req: IncomingMessage | undefined,
trustedProxyConfig: GatewayTrustedProxyConfig | undefined,
): boolean {
if (!req || !trustedProxyConfig) {
return false;
}
const headers = [trustedProxyConfig.userHeader, ...(trustedProxyConfig.requiredHeaders ?? [])]
.map((header) => header?.trim().toLowerCase())
.filter((header): header is string => Boolean(header));
return headers.some((header) => {
const value = headerValue(req.headers[header]);
return typeof value === "string" && value.trim() !== "";
});
}
export async function authorizeGatewayConnect(
params: AuthorizeGatewayConnectParams,
): Promise<GatewayAuthResult> {
@ -378,8 +425,43 @@ export async function authorizeGatewayConnect(
trustedProxies,
params.allowRealIpFallback === true,
);
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 localLoopbackWithoutProxyHeaders =
Boolean(req) &&
isLoopbackAddress(req?.socket?.remoteAddress) &&
!hasConfiguredTrustedProxyHeaders(req, auth.trustedProxy);
if (auth.mode === "trusted-proxy") {
if (localLoopbackWithoutProxyHeaders && limiter) {
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
if (!rlCheck.allowed) {
return {
ok: false,
reason: "rate_limited",
rateLimited: true,
retryAfterMs: rlCheck.retryAfterMs,
};
}
}
if (localLoopbackWithoutProxyHeaders) {
const sharedSecretFallback = authorizeSharedSecretFallback({
auth,
connectAuth,
limiter,
ip,
rateLimitScope,
});
if (sharedSecretFallback) {
return sharedSecretFallback;
}
}
if (!auth.trustedProxy) {
return { ok: false, reason: "trusted_proxy_config_missing" };
}
@ -403,12 +485,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) {

View File

@ -242,7 +242,13 @@ export class GatewayBrowserClient {
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const scopes = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
];
const role = "operator";
const explicitGatewayToken = this.opts.token?.trim() || undefined;
const explicitPassword = this.opts.password?.trim() || undefined;