Merge 4136fa520fa5b49bbf43c00ebfc9c3a88d482b57 into 43513cd1df63af0704dfb351ee7864607f955dcc
This commit is contained in:
commit
4693ca6c80
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user