fix(gateway): honor trusted proxy hook auth rate limits
This commit is contained in:
@@ -105,7 +105,7 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequestClientIp(
|
export function resolveRequestClientIp(
|
||||||
req?: IncomingMessage,
|
req?: IncomingMessage,
|
||||||
trustedProxies?: string[],
|
trustedProxies?: string[],
|
||||||
allowRealIpFallback = false,
|
allowRealIpFallback = false,
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ export function createGatewayRequest(params: {
|
|||||||
method?: string;
|
method?: string;
|
||||||
remoteAddress?: string;
|
remoteAddress?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
}): IncomingMessage {
|
}): IncomingMessage {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
host: params.host ?? "localhost:18789",
|
host: params.host ?? "localhost:18789",
|
||||||
|
...params.headers,
|
||||||
};
|
};
|
||||||
if (params.authorization) {
|
if (params.authorization) {
|
||||||
headers.authorization = params.authorization;
|
headers.authorization = params.authorization;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function createRequest(params?: {
|
|||||||
authorization?: string;
|
authorization?: string;
|
||||||
remoteAddress?: string;
|
remoteAddress?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
}): IncomingMessage {
|
}): IncomingMessage {
|
||||||
return createGatewayRequest({
|
return createGatewayRequest({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -30,6 +31,7 @@ function createRequest(params?: {
|
|||||||
host: "127.0.0.1:18789",
|
host: "127.0.0.1:18789",
|
||||||
authorization: params?.authorization ?? "Bearer hook-secret",
|
authorization: params?.authorization ?? "Bearer hook-secret",
|
||||||
remoteAddress: params?.remoteAddress,
|
remoteAddress: params?.remoteAddress,
|
||||||
|
headers: params?.headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ function createHandler(params?: {
|
|||||||
dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"];
|
dispatchWakeHook?: HooksHandlerDeps["dispatchWakeHook"];
|
||||||
dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"];
|
dispatchAgentHook?: HooksHandlerDeps["dispatchAgentHook"];
|
||||||
bindHost?: string;
|
bindHost?: string;
|
||||||
|
getClientIpConfig?: HooksHandlerDeps["getClientIpConfig"];
|
||||||
}) {
|
}) {
|
||||||
return createHooksRequestHandler({
|
return createHooksRequestHandler({
|
||||||
getHooksConfig: () => createHooksConfig(),
|
getHooksConfig: () => createHooksConfig(),
|
||||||
@@ -63,6 +66,7 @@ function createHandler(params?: {
|
|||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
} as unknown as ReturnType<typeof createSubsystemLogger>,
|
||||||
|
getClientIpConfig: params?.getClientIpConfig,
|
||||||
dispatchWakeHook:
|
dispatchWakeHook:
|
||||||
params?.dispatchWakeHook ??
|
params?.dispatchWakeHook ??
|
||||||
((() => {
|
((() => {
|
||||||
@@ -121,6 +125,36 @@ describe("createHooksRequestHandler timeout status mapping", () => {
|
|||||||
expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String));
|
expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses trusted proxy forwarded client ip for hook auth throttling", async () => {
|
||||||
|
const handler = createHandler({
|
||||||
|
getClientIpConfig: () => ({ trustedProxies: ["10.0.0.1"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const req = createRequest({
|
||||||
|
authorization: "Bearer wrong",
|
||||||
|
remoteAddress: "10.0.0.1",
|
||||||
|
headers: { "x-forwarded-for": "1.2.3.4" },
|
||||||
|
});
|
||||||
|
const { res } = createResponse();
|
||||||
|
const handled = await handler(req, res);
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedReq = createRequest({
|
||||||
|
authorization: "Bearer wrong",
|
||||||
|
remoteAddress: "10.0.0.1",
|
||||||
|
headers: { "x-forwarded-for": "1.2.3.4, 10.0.0.1" },
|
||||||
|
});
|
||||||
|
const { res: forwardedRes, setHeader } = createResponse();
|
||||||
|
const handled = await handler(forwardedReq, forwardedRes);
|
||||||
|
|
||||||
|
expect(handled).toBe(true);
|
||||||
|
expect(forwardedRes.statusCode).toBe(429);
|
||||||
|
expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
test.each(["0.0.0.0", "::"])(
|
test.each(["0.0.0.0", "::"])(
|
||||||
"does not throw when bindHost=%s while parsing non-hook request URL",
|
"does not throw when bindHost=%s while parsing non-hook request URL",
|
||||||
async (bindHost) => {
|
async (bindHost) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
authorizeHttpGatewayConnect,
|
authorizeHttpGatewayConnect,
|
||||||
isLocalDirectRequest,
|
isLocalDirectRequest,
|
||||||
|
resolveRequestClientIp,
|
||||||
type GatewayAuthResult,
|
type GatewayAuthResult,
|
||||||
type ResolvedGatewayAuth,
|
type ResolvedGatewayAuth,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
@@ -351,9 +352,13 @@ export function createHooksRequestHandler(
|
|||||||
bindHost: string;
|
bindHost: string;
|
||||||
port: number;
|
port: number;
|
||||||
logHooks: SubsystemLogger;
|
logHooks: SubsystemLogger;
|
||||||
|
getClientIpConfig?: () => {
|
||||||
|
trustedProxies?: string[];
|
||||||
|
allowRealIpFallback?: boolean;
|
||||||
|
};
|
||||||
} & HookDispatchers,
|
} & HookDispatchers,
|
||||||
): HooksRequestHandler {
|
): HooksRequestHandler {
|
||||||
const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
|
const { getHooksConfig, logHooks, dispatchAgentHook, dispatchWakeHook, getClientIpConfig } = opts;
|
||||||
const hookAuthLimiter = createAuthRateLimiter({
|
const hookAuthLimiter = createAuthRateLimiter({
|
||||||
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
|
maxAttempts: HOOK_AUTH_FAILURE_LIMIT,
|
||||||
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
|
windowMs: HOOK_AUTH_FAILURE_WINDOW_MS,
|
||||||
@@ -364,7 +369,14 @@ export function createHooksRequestHandler(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resolveHookClientKey = (req: IncomingMessage): string => {
|
const resolveHookClientKey = (req: IncomingMessage): string => {
|
||||||
return normalizeRateLimitClientIp(req.socket?.remoteAddress);
|
const clientIpConfig = getClientIpConfig?.();
|
||||||
|
const clientIp =
|
||||||
|
resolveRequestClientIp(
|
||||||
|
req,
|
||||||
|
clientIpConfig?.trustedProxies,
|
||||||
|
clientIpConfig?.allowRealIpFallback === true,
|
||||||
|
) ?? req.socket?.remoteAddress;
|
||||||
|
return normalizeRateLimitClientIp(clientIp);
|
||||||
};
|
};
|
||||||
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ export function createGatewayHooksRequestHandler(params: {
|
|||||||
bindHost,
|
bindHost,
|
||||||
port,
|
port,
|
||||||
logHooks,
|
logHooks,
|
||||||
|
getClientIpConfig: () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
return {
|
||||||
|
trustedProxies: cfg.gateway?.trustedProxies,
|
||||||
|
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
|
||||||
|
};
|
||||||
|
},
|
||||||
dispatchAgentHook,
|
dispatchAgentHook,
|
||||||
dispatchWakeHook,
|
dispatchWakeHook,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user