fix(gateway): allow local shared-secret auth in trusted-proxy mode

This commit is contained in:
rick 2026-03-03 21:03:21 -06:00
parent 89fc92c3ca
commit 1cab57f257
2 changed files with 79 additions and 6 deletions

View File

@ -395,6 +395,28 @@ 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("rejects request from untrusted source", async () => {
const res = await authorizeTrustedProxy({
remoteAddress: "192.168.1.100",

View File

@ -75,6 +75,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;
@ -361,6 +369,30 @@ 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;
}
export async function authorizeGatewayConnect(
params: AuthorizeGatewayConnectParams,
): Promise<GatewayAuthResult> {
@ -373,8 +405,33 @@ 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) &&
!req?.headers?.["x-forwarded-for"] &&
!req?.headers?.["x-real-ip"] &&
!req?.headers?.["x-forwarded-host"];
if (auth.mode === "trusted-proxy") {
if (localDirect || localLoopbackWithoutProxyHeaders) {
const sharedSecretFallback = authorizeSharedSecretFallback({
auth,
connectAuth,
limiter,
ip,
rateLimitScope,
});
if (sharedSecretFallback) {
return sharedSecretFallback;
}
}
if (!auth.trustedProxy) {
return { ok: false, reason: "trusted_proxy_config_missing" };
}
@ -398,12 +455,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) {