mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-24 07:01:49 +00:00
refactor(gateway): harden proxy client ip resolution
This commit is contained in:
@@ -310,10 +310,15 @@ export type GatewayConfig = {
|
||||
nodes?: GatewayNodesConfig;
|
||||
/**
|
||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or
|
||||
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
|
||||
* arrives from one of these IPs, the Gateway trusts `x-forwarded-for`
|
||||
* to determine the client IP for local pairing and HTTP checks.
|
||||
*/
|
||||
trustedProxies?: string[];
|
||||
/**
|
||||
* Allow `x-real-ip` as a fallback only when `x-forwarded-for` is missing.
|
||||
* Default: false (safer fail-closed behavior).
|
||||
*/
|
||||
allowRealIpFallback?: boolean;
|
||||
/** Tool access restrictions for HTTP /tools/invoke endpoint. */
|
||||
tools?: GatewayToolsConfig;
|
||||
/**
|
||||
|
||||
@@ -450,6 +450,7 @@ export const OpenClawSchema = z
|
||||
.strict()
|
||||
.optional(),
|
||||
trustedProxies: z.array(z.string()).optional(),
|
||||
allowRealIpFallback: z.boolean().optional(),
|
||||
tools: z
|
||||
.object({
|
||||
deny: z.array(z.string()).optional(),
|
||||
|
||||
@@ -301,6 +301,45 @@ describe("gateway auth", () => {
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.10", "shared-secret");
|
||||
});
|
||||
|
||||
it("ignores X-Real-IP fallback by default for rate-limit checks", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-real-ip": "203.0.113.77" },
|
||||
} as never,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
rateLimiter: limiter,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
|
||||
});
|
||||
|
||||
it("uses X-Real-IP when fallback is explicitly enabled", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: false },
|
||||
connectAuth: { token: "wrong" },
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { "x-real-ip": "203.0.113.77" },
|
||||
} as never,
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRealIpFallback: true,
|
||||
rateLimiter: limiter,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_mismatch");
|
||||
expect(limiter.check).toHaveBeenCalledWith("203.0.113.77", "shared-secret");
|
||||
expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.77", "shared-secret");
|
||||
});
|
||||
|
||||
it("passes custom rate-limit scope to limiter operations", async () => {
|
||||
const limiter = createLimiterSpy();
|
||||
const res = await authorizeGatewayConnect({
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
isLoopbackAddress,
|
||||
isTrustedProxyAddress,
|
||||
resolveHostName,
|
||||
parseForwardedForClientIp,
|
||||
resolveGatewayClientIp,
|
||||
resolveClientIp,
|
||||
} from "./net.js";
|
||||
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
|
||||
@@ -71,6 +70,8 @@ export type AuthorizeGatewayConnectParams = {
|
||||
clientIp?: string;
|
||||
/** Optional limiter scope; defaults to shared-secret auth scope. */
|
||||
rateLimitScope?: string;
|
||||
/** Trust X-Real-IP only when explicitly enabled. */
|
||||
allowRealIpFallback?: boolean;
|
||||
};
|
||||
|
||||
type TailscaleUser = {
|
||||
@@ -89,34 +90,45 @@ function headerValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
const TAILSCALE_TRUSTED_PROXIES = ["127.0.0.1", "::1"] as const;
|
||||
|
||||
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
|
||||
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
|
||||
return resolveClientIp({
|
||||
remoteAddr: req.socket?.remoteAddress ?? "",
|
||||
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
|
||||
trustedProxies: [...TAILSCALE_TRUSTED_PROXIES],
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRequestClientIp(
|
||||
req?: IncomingMessage,
|
||||
trustedProxies?: string[],
|
||||
allowRealIpFallback = false,
|
||||
): string | undefined {
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveGatewayClientIp({
|
||||
return resolveClientIp({
|
||||
remoteAddr: req.socket?.remoteAddress ?? "",
|
||||
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
|
||||
realIp: headerValue(req.headers?.["x-real-ip"]),
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
});
|
||||
}
|
||||
|
||||
export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
|
||||
export function isLocalDirectRequest(
|
||||
req?: IncomingMessage,
|
||||
trustedProxies?: string[],
|
||||
allowRealIpFallback = false,
|
||||
): boolean {
|
||||
if (!req) {
|
||||
return false;
|
||||
}
|
||||
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
||||
const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) {
|
||||
return false;
|
||||
}
|
||||
@@ -351,7 +363,11 @@ export async function authorizeGatewayConnect(
|
||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||
const authSurface = params.authSurface ?? "http";
|
||||
const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface);
|
||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||
const localDirect = isLocalDirectRequest(
|
||||
req,
|
||||
trustedProxies,
|
||||
params.allowRealIpFallback === true,
|
||||
);
|
||||
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
if (!auth.trustedProxy) {
|
||||
@@ -379,7 +395,9 @@ export async function authorizeGatewayConnect(
|
||||
|
||||
const limiter = params.rateLimiter;
|
||||
const ip =
|
||||
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
|
||||
res: ServerResponse;
|
||||
auth: ResolvedGatewayAuth;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
}): Promise<boolean> {
|
||||
const token = getBearerToken(params.req);
|
||||
@@ -17,6 +18,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req: params.req,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
auth: ResolvedGatewayAuth;
|
||||
maxBodyBytes: number;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
},
|
||||
): Promise<false | { body: unknown } | undefined> {
|
||||
@@ -30,6 +31,7 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
res,
|
||||
auth: opts.auth,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
allowRealIpFallback: opts.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
});
|
||||
if (!authorized) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isSecureWebSocketUrl,
|
||||
isTrustedProxyAddress,
|
||||
pickPrimaryLanIPv4,
|
||||
resolveGatewayClientIp,
|
||||
resolveClientIp,
|
||||
resolveGatewayListenHosts,
|
||||
resolveHostName,
|
||||
} from "./net.js";
|
||||
@@ -132,49 +132,74 @@ describe("isTrustedProxyAddress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGatewayClientIp", () => {
|
||||
it("returns remote IP when the remote is not a trusted proxy", () => {
|
||||
const ip = resolveGatewayClientIp({
|
||||
describe("resolveClientIp", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "returns remote IP when remote is not trusted proxy",
|
||||
remoteAddr: "203.0.113.10",
|
||||
forwardedFor: "10.0.0.2",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
});
|
||||
expect(ip).toBe("203.0.113.10");
|
||||
});
|
||||
|
||||
it("returns forwarded client IP when the remote is a trusted proxy", () => {
|
||||
const ip = resolveGatewayClientIp({
|
||||
remoteAddr: "127.0.0.1",
|
||||
forwardedFor: "127.0.0.1, 10.0.0.2",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
});
|
||||
expect(ip).toBe("10.0.0.2");
|
||||
});
|
||||
|
||||
it("does not trust the left-most X-Forwarded-For value when behind a trusted proxy", () => {
|
||||
const ip = resolveGatewayClientIp({
|
||||
expected: "203.0.113.10",
|
||||
},
|
||||
{
|
||||
name: "uses right-most untrusted X-Forwarded-For hop",
|
||||
remoteAddr: "127.0.0.1",
|
||||
forwardedFor: "198.51.100.99, 10.0.0.9, 127.0.0.1",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
});
|
||||
expect(ip).toBe("10.0.0.9");
|
||||
});
|
||||
|
||||
it("fails closed when trusted proxy headers are missing", () => {
|
||||
const ip = resolveGatewayClientIp({
|
||||
expected: "10.0.0.9",
|
||||
},
|
||||
{
|
||||
name: "fails closed when all X-Forwarded-For hops are trusted proxies",
|
||||
remoteAddr: "127.0.0.1",
|
||||
forwardedFor: "127.0.0.1, ::1",
|
||||
trustedProxies: ["127.0.0.1", "::1"],
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "fails closed when trusted proxy omits forwarding headers",
|
||||
remoteAddr: "127.0.0.1",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
});
|
||||
expect(ip).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports IPv6 client IP forwarded by a trusted proxy", () => {
|
||||
const ip = resolveGatewayClientIp({
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "ignores invalid X-Forwarded-For entries",
|
||||
remoteAddr: "127.0.0.1",
|
||||
forwardedFor: "garbage, 10.0.0.999",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "does not trust X-Real-IP by default",
|
||||
remoteAddr: "127.0.0.1",
|
||||
realIp: "[2001:db8::5]",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "uses X-Real-IP only when explicitly enabled",
|
||||
remoteAddr: "127.0.0.1",
|
||||
realIp: "[2001:db8::5]",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRealIpFallback: true,
|
||||
expected: "2001:db8::5",
|
||||
},
|
||||
{
|
||||
name: "ignores invalid X-Real-IP even when fallback enabled",
|
||||
remoteAddr: "127.0.0.1",
|
||||
realIp: "not-an-ip",
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
allowRealIpFallback: true,
|
||||
expected: undefined,
|
||||
},
|
||||
])("$name", (testCase) => {
|
||||
const ip = resolveClientIp({
|
||||
remoteAddr: testCase.remoteAddr,
|
||||
forwardedFor: testCase.forwardedFor,
|
||||
realIp: testCase.realIp,
|
||||
trustedProxies: testCase.trustedProxies,
|
||||
allowRealIpFallback: testCase.allowRealIpFallback,
|
||||
});
|
||||
expect(ip).toBe("2001:db8::5");
|
||||
expect(ip).toBe(testCase.expected);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -146,45 +146,51 @@ function stripOptionalPort(ip: string): string {
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function parseForwardedForClientIp(
|
||||
forwardedFor?: string,
|
||||
trustedProxies?: string[],
|
||||
): string | undefined {
|
||||
const entries = forwardedFor
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
if (!entries?.length) {
|
||||
function parseIpLiteral(raw: string | undefined): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!trustedProxies?.length) {
|
||||
const raw = entries.at(-1);
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
const stripped = stripOptionalPort(trimmed);
|
||||
const normalized = normalizeIp(stripped);
|
||||
if (!normalized || net.isIP(normalized) === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const normalized = normalizeIp(stripOptionalPort(entries[index]));
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (!isTrustedProxyAddress(normalized, trustedProxies)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseRealIp(realIp?: string): string | undefined {
|
||||
const raw = realIp?.trim();
|
||||
if (!raw) {
|
||||
return parseIpLiteral(realIp);
|
||||
}
|
||||
|
||||
function resolveForwardedClientIp(params: {
|
||||
forwardedFor?: string;
|
||||
trustedProxies?: string[];
|
||||
}): string | undefined {
|
||||
const { forwardedFor, trustedProxies } = params;
|
||||
if (!trustedProxies?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
|
||||
const forwardedChain: string[] = [];
|
||||
for (const entry of forwardedFor?.split(",") ?? []) {
|
||||
const normalized = parseIpLiteral(entry);
|
||||
if (normalized) {
|
||||
forwardedChain.push(normalized);
|
||||
}
|
||||
}
|
||||
if (forwardedChain.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Walk right-to-left and return the first untrusted hop.
|
||||
for (let index = forwardedChain.length - 1; index >= 0; index -= 1) {
|
||||
const hop = forwardedChain[index];
|
||||
if (!isTrustedProxyAddress(hop, trustedProxies)) {
|
||||
return hop;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,11 +258,13 @@ export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: s
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGatewayClientIp(params: {
|
||||
export function resolveClientIp(params: {
|
||||
remoteAddr?: string;
|
||||
forwardedFor?: string;
|
||||
realIp?: string;
|
||||
trustedProxies?: string[];
|
||||
/** Default false: only trust X-Real-IP when explicitly enabled. */
|
||||
allowRealIpFallback?: boolean;
|
||||
}): string | undefined {
|
||||
const remote = normalizeIp(params.remoteAddr);
|
||||
if (!remote) {
|
||||
@@ -268,10 +276,17 @@ export function resolveGatewayClientIp(params: {
|
||||
// Fail closed when traffic comes from a trusted proxy but client-origin headers
|
||||
// are missing or invalid. Falling back to the proxy's own IP can accidentally
|
||||
// treat unrelated requests as local/trusted.
|
||||
return (
|
||||
parseForwardedForClientIp(params.forwardedFor, params.trustedProxies) ??
|
||||
parseRealIp(params.realIp)
|
||||
);
|
||||
const forwardedIp = resolveForwardedClientIp({
|
||||
forwardedFor: params.forwardedFor,
|
||||
trustedProxies: params.trustedProxies,
|
||||
});
|
||||
if (forwardedIp) {
|
||||
return forwardedIp;
|
||||
}
|
||||
if (params.allowRealIpFallback) {
|
||||
return parseRealIp(params.realIp);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||
|
||||
@@ -20,6 +20,7 @@ type OpenAiHttpOptions = {
|
||||
auth: ResolvedGatewayAuth;
|
||||
maxBodyBytes?: number;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
};
|
||||
|
||||
@@ -162,6 +163,7 @@ export async function handleOpenAiHttpRequest(
|
||||
pathname: "/v1/chat/completions",
|
||||
auth: opts.auth,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
allowRealIpFallback: opts.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
maxBodyBytes: opts.maxBodyBytes ?? 1024 * 1024,
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ type OpenResponsesHttpOptions = {
|
||||
maxBodyBytes?: number;
|
||||
config?: GatewayHttpResponsesConfig;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
};
|
||||
|
||||
@@ -343,6 +344,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
pathname: "/v1/responses",
|
||||
auth: opts.auth,
|
||||
trustedProxies: opts.trustedProxies,
|
||||
allowRealIpFallback: opts.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
maxBodyBytes,
|
||||
});
|
||||
|
||||
@@ -133,17 +133,26 @@ async function authorizeCanvasRequest(params: {
|
||||
req: IncomingMessage;
|
||||
auth: ResolvedGatewayAuth;
|
||||
trustedProxies: string[];
|
||||
allowRealIpFallback: boolean;
|
||||
clients: Set<GatewayWsClient>;
|
||||
canvasCapability?: string;
|
||||
malformedScopedPath?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
}): Promise<GatewayAuthResult> {
|
||||
const { req, auth, trustedProxies, clients, canvasCapability, malformedScopedPath, rateLimiter } =
|
||||
params;
|
||||
const {
|
||||
req,
|
||||
auth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
clients,
|
||||
canvasCapability,
|
||||
malformedScopedPath,
|
||||
rateLimiter,
|
||||
} = params;
|
||||
if (malformedScopedPath) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
if (isLocalDirectRequest(req, trustedProxies)) {
|
||||
if (isLocalDirectRequest(req, trustedProxies, allowRealIpFallback)) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -155,6 +164,7 @@ async function authorizeCanvasRequest(params: {
|
||||
connectAuth: { token, password: token },
|
||||
req,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
});
|
||||
if (authResult.ok) {
|
||||
@@ -497,6 +507,7 @@ export function createGatewayHttpServer(opts: {
|
||||
try {
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
|
||||
const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/");
|
||||
if (scopedCanvas.malformedScopedPath) {
|
||||
sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" });
|
||||
@@ -513,6 +524,7 @@ export function createGatewayHttpServer(opts: {
|
||||
await handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
) {
|
||||
@@ -532,6 +544,7 @@ export function createGatewayHttpServer(opts: {
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
@@ -549,6 +562,7 @@ export function createGatewayHttpServer(opts: {
|
||||
auth: resolvedAuth,
|
||||
config: openResponsesConfig,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
) {
|
||||
@@ -560,6 +574,7 @@ export function createGatewayHttpServer(opts: {
|
||||
await handleOpenAiHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter,
|
||||
})
|
||||
) {
|
||||
@@ -572,6 +587,7 @@ export function createGatewayHttpServer(opts: {
|
||||
req,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
clients,
|
||||
canvasCapability: scopedCanvas.capability,
|
||||
malformedScopedPath: scopedCanvas.malformedScopedPath,
|
||||
@@ -648,10 +664,12 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
if (url.pathname === CANVAS_WS_PATH) {
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
|
||||
const ok = await authorizeCanvasRequest({
|
||||
req,
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
clients,
|
||||
canvasCapability: scopedCanvas.capability,
|
||||
malformedScopedPath: scopedCanvas.malformedScopedPath,
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
mintCanvasCapabilityToken,
|
||||
} from "../../canvas-capability.js";
|
||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
|
||||
import { isLoopbackAddress, isTrustedProxyAddress, resolveClientIp } from "../../net.js";
|
||||
import { resolveHostName } from "../../net.js";
|
||||
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
|
||||
import { checkBrowserOrigin } from "../../origin-check.js";
|
||||
@@ -176,7 +176,14 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies });
|
||||
const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true;
|
||||
const clientIp = resolveClientIp({
|
||||
remoteAddr,
|
||||
forwardedFor,
|
||||
realIp,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
});
|
||||
|
||||
// If proxy headers are present but the remote address isn't trusted, don't treat
|
||||
// the connection as local. This prevents auth bypass when running behind a reverse
|
||||
@@ -189,7 +196,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
|
||||
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
|
||||
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
|
||||
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
|
||||
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback);
|
||||
const reportedClientIp =
|
||||
isLocalClient || hasUntrustedProxyHeaders
|
||||
? undefined
|
||||
@@ -389,6 +396,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter,
|
||||
clientIp,
|
||||
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
@@ -424,6 +432,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
// Shared-auth probe only; rate-limit side effects are handled in
|
||||
// the primary auth flow (or deferred for device-token candidates).
|
||||
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
|
||||
@@ -131,6 +131,7 @@ export async function handleToolsInvokeHttpRequest(
|
||||
auth: ResolvedGatewayAuth;
|
||||
maxBodyBytes?: number;
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
@@ -151,6 +152,7 @@ export async function handleToolsInvokeHttpRequest(
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req,
|
||||
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
||||
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
|
||||
Reference in New Issue
Block a user