Merge 2a7348428c62bf9b35f68bad2ee7e7cc50aaf20a into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
774717d12c
@ -650,3 +650,191 @@ describe("trusted-proxy auth", () => {
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("trusted-proxy shared-secret fallback", () => {
|
||||
const trustedProxyConfig = {
|
||||
userHeader: "x-forwarded-user",
|
||||
};
|
||||
|
||||
const untrustedReq = {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
},
|
||||
} as never;
|
||||
|
||||
// trustedProxies: ["10.0.0.1"] means 127.0.0.1 is NOT trusted
|
||||
const untrustedProxies = ["10.0.0.1"];
|
||||
|
||||
it("trusted-proxy via proxy succeeds (unchanged)", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "server-token",
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "10.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("trusted-proxy");
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
|
||||
it("rejects untrusted IP when no token/password configured (unchanged)", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
},
|
||||
connectAuth: null,
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_untrusted_source");
|
||||
});
|
||||
|
||||
it("falls back to token when untrusted IP provides valid token", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "server-token",
|
||||
},
|
||||
connectAuth: { token: "server-token" },
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("token");
|
||||
});
|
||||
|
||||
it("rejects untrusted IP with invalid token", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "server-token",
|
||||
},
|
||||
connectAuth: { token: "wrong-token" },
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
});
|
||||
|
||||
it("falls back to password when untrusted IP provides valid password", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
password: "server-pass",
|
||||
},
|
||||
connectAuth: { password: "server-pass" },
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("password");
|
||||
});
|
||||
|
||||
it("rejects untrusted IP with invalid password", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
password: "server-pass",
|
||||
},
|
||||
connectAuth: { password: "wrong-pass" },
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("password_mismatch");
|
||||
});
|
||||
|
||||
it("rejects when client provides token but no server token configured", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
// no token or password configured on server
|
||||
},
|
||||
connectAuth: { token: "client-token" },
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("trusted_proxy_untrusted_source");
|
||||
});
|
||||
|
||||
it("rejects when rate limited during fallback", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
limiter.check.mockReturnValue({ allowed: false, remaining: 0, retryAfterMs: 5000 });
|
||||
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "server-token",
|
||||
},
|
||||
connectAuth: { token: "server-token" },
|
||||
trustedProxies: untrustedProxies,
|
||||
req: untrustedReq,
|
||||
rateLimiter: limiter,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("rate_limited");
|
||||
expect(res.rateLimited).toBe(true);
|
||||
});
|
||||
|
||||
it("uses proxy method when request comes from trusted proxy even if token is provided", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
allowTailscale: false,
|
||||
trustedProxy: trustedProxyConfig,
|
||||
token: "server-token",
|
||||
},
|
||||
connectAuth: { token: "server-token" },
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: {
|
||||
host: "gateway.local",
|
||||
"x-forwarded-user": "nick@example.com",
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("trusted-proxy");
|
||||
expect(res.user).toBe("nick@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
@ -269,8 +269,7 @@ export function resolveGatewayAuth(params: {
|
||||
}
|
||||
|
||||
const allowTailscale =
|
||||
authConfig.allowTailscale ??
|
||||
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
|
||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
||||
|
||||
return {
|
||||
mode,
|
||||
@ -379,6 +378,13 @@ export async function authorizeGatewayConnect(
|
||||
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;
|
||||
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
if (!auth.trustedProxy) {
|
||||
return { ok: false, reason: "trusted_proxy_config_missing" };
|
||||
@ -396,6 +402,48 @@ export async function authorizeGatewayConnect(
|
||||
if ("user" in result) {
|
||||
return { ok: true, method: "trusted-proxy", user: result.user };
|
||||
}
|
||||
|
||||
// Trusted-proxy auth failed — try shared-secret fallback for internal
|
||||
// services (CLI, node host, ACP) that bypass the reverse proxy.
|
||||
// If no token/password is configured, there's no fallback available.
|
||||
if (!auth.token && !auth.password) {
|
||||
return { ok: false, reason: result.reason };
|
||||
}
|
||||
|
||||
// Rate-limit fallback attempts
|
||||
if (limiter) {
|
||||
const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope);
|
||||
if (!rlCheck.allowed) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "rate_limited",
|
||||
rateLimited: true,
|
||||
retryAfterMs: rlCheck.retryAfterMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try token fallback
|
||||
if (connectAuth?.token && auth.token) {
|
||||
if (safeEqualSecret(connectAuth.token, auth.token)) {
|
||||
limiter?.reset(ip, rateLimitScope);
|
||||
return { ok: true, method: "token" };
|
||||
}
|
||||
limiter?.recordFailure(ip, rateLimitScope);
|
||||
return { ok: false, reason: "token_mismatch" };
|
||||
}
|
||||
|
||||
// Try password fallback
|
||||
if (connectAuth?.password && auth.password) {
|
||||
if (safeEqualSecret(connectAuth.password, auth.password)) {
|
||||
limiter?.reset(ip, rateLimitScope);
|
||||
return { ok: true, method: "password" };
|
||||
}
|
||||
limiter?.recordFailure(ip, rateLimitScope);
|
||||
return { ok: false, reason: "password_mismatch" };
|
||||
}
|
||||
|
||||
// Client didn't provide matching credentials — return original proxy failure
|
||||
return { ok: false, reason: result.reason };
|
||||
}
|
||||
|
||||
@ -403,12 +451,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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user