fix(gateway): reject malformed request targets (#82686)

* fix(gateway): reject malformed request targets

* fix(gateway): document malformed request target rejection
This commit is contained in:
Agustin Rivera
2026-05-16 13:25:49 -07:00
committed by GitHub
parent 55edadf86f
commit f7977fb102
5 changed files with 133 additions and 7 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Providers/xAI: keep retired Grok 3, Grok 4 Fast, Grok 4.1 Fast, and Grok Code slugs out of model pickers while preserving compatibility resolution for existing configs.
- Providers/xAI: replace the retired `grok-imagine-image-pro` image model with `grok-imagine-image-quality` in the bundled image-generation provider and docs. (#81399) Thanks @KateWilkins.
- Providers/OAuth: let browser-hosted identity provider pages read successful localhost callback responses, preventing xAI Grok OAuth from showing a false connection failure after OpenClaw completes login.
- Gateway/security: reject malformed HTTP and WebSocket request targets with the existing auth failure response instead of letting invalid URL parsing crash the Gateway. Fixes GHSA-6hc3-f4rg-377m.
- Gateway/diagnostics: redact credential-bearing gateway target URLs and client diagnostics while preserving raw connection URLs for programmatic use, so connect-failure logs no longer surface embedded tokens.
- Gateway/auth: honor `OPENCLAW_GATEWAY_TOKEN` as the remote interactive fallback when no remote token is configured, keeping remote TUI setup aligned with documented auth precedence.
- Providers/xAI: continue polling video generations while xAI reports in-flight jobs as `pending`, so Grok video requests no longer fail before the final `done` response. (#82610) Thanks @Manzojunior.

View File

@@ -71,6 +71,19 @@ describe("plugin node capability helpers", () => {
expect(normalized.rewrittenUrl).toBeUndefined();
});
test("marks malformed request targets without throwing", () => {
for (const rawUrl of ["//", "///", "//${jndi:ldap://example}.action"]) {
const normalized = normalizePluginNodeCapabilityScopedUrl(rawUrl);
expect(normalized).toMatchObject({
pathname: "/",
scopedPath: false,
malformedScopedPath: true,
});
expect(normalized.capability).toBeUndefined();
expect(normalized.rewrittenUrl).toBeUndefined();
}
});
test("stores capabilities per plugin surface", () => {
const client = makeClient();
setClientPluginNodeCapability({

View File

@@ -130,7 +130,16 @@ export function replacePluginNodeCapabilityInScopedHostUrl(
export function normalizePluginNodeCapabilityScopedUrl(
rawUrl: string,
): NormalizedPluginNodeCapabilityUrl {
const url = new URL(rawUrl, "http://localhost");
let url: URL;
try {
url = new URL(rawUrl, "http://localhost");
} catch {
return {
pathname: "/",
scopedPath: false,
malformedScopedPath: true,
};
}
const prefix = `${PLUGIN_NODE_CAPABILITY_PATH_PREFIX}/`;
let scopedPath = false;
let malformedScopedPath = false;

View File

@@ -359,6 +359,14 @@ function writeUpgradeServiceUnavailable(socket: { write: (chunk: string) => void
);
}
function parseGatewayRequestPath(rawUrl: string | undefined): string | undefined {
try {
return new URL(rawUrl ?? "/", "http://localhost").pathname;
} catch {
return undefined;
}
}
type GatewayHttpRequestStage = {
name: string;
run: () => Promise<boolean> | boolean;
@@ -531,7 +539,11 @@ export function createGatewayHttpServer(opts: {
}
try {
const requestPath = new URL(req.url ?? "/", "http://localhost").pathname;
const requestPath = parseGatewayRequestPath(req.url);
if (requestPath === undefined) {
sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" });
return;
}
if (GATEWAY_PROBE_STATUS_BY_PATH.get(requestPath) === "live") {
await handleGatewayProbeRequest(
req,
@@ -556,7 +568,7 @@ export function createGatewayHttpServer(opts: {
if (scopedNodeCapability.rewrittenUrl) {
req.url = scopedNodeCapability.rewrittenUrl;
}
const scopedRequestPath = new URL(req.url ?? "/", "http://localhost").pathname;
const scopedRequestPath = scopedNodeCapability.pathname;
const pluginPathContext = handlePluginRequest
? resolvePluginRoutePathContext(scopedRequestPath)
: null;
@@ -843,8 +855,8 @@ export function attachGatewayUpgradeHandler(opts: {
req.url = scopedNodeCapability.rewrittenUrl;
}
const resolvedAuth = getResolvedAuth();
const url = new URL(req.url ?? "/", "http://localhost");
const pathContext = resolvePluginRoutePathContext(url.pathname);
const requestPath = scopedNodeCapability.pathname;
const pathContext = resolvePluginRoutePathContext(requestPath);
const nodeCapability = resolvePluginNodeCapabilityRoute?.(pathContext);
if (nodeCapability) {
const { authorizePluginNodeCapabilityRequest } = await getPluginNodeCapabilityAuthModule();
@@ -874,7 +886,7 @@ export function attachGatewayUpgradeHandler(opts: {
)(pathContext);
if (
enforcePluginGatewayAuth &&
!(await getCachedPluginGatewayAuthBypassPaths(configSnapshot)).has(url.pathname)
!(await getCachedPluginGatewayAuthBypassPaths(configSnapshot)).has(requestPath)
) {
const { checkGatewayHttpRequestAuth } = await getHttpAuthUtilsModule();
const authCheck = await checkGatewayHttpRequestAuth({

View File

@@ -1,5 +1,5 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Socket } from "node:net";
import { connect, type Socket } from "node:net";
import type { Duplex } from "node:stream";
import { describe, expect, test } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
@@ -172,6 +172,50 @@ async function expectWsConnected(url: string, headers?: Record<string, string>):
});
}
async function sendRawHttpRequest(params: {
host: string;
port: number;
requestTarget: string;
headers?: readonly string[];
}): Promise<string> {
return new Promise<string>((resolve, reject) => {
const socket = connect({ host: params.host, port: params.port }, () => {
const headers = params.headers ?? ["Host: localhost", "Connection: close"];
socket.write([`GET ${params.requestTarget} HTTP/1.1`, ...headers, "", ""].join("\r\n"));
});
let response = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) {
return;
}
settled = true;
socket.setTimeout(0);
fn();
};
socket.setEncoding("utf8");
socket.setTimeout(WS_REJECT_TIMEOUT_MS, () => {
const error = new Error("timeout");
finish(() => {
socket.destroy(error);
reject(error);
});
});
socket.on("data", (chunk) => {
response += chunk;
});
socket.once("end", () => {
finish(() => resolve(response));
});
socket.once("close", () => {
finish(() => resolve(response));
});
socket.once("error", (err) => {
finish(() => reject(err));
});
});
}
function makeWsClient(params: {
connId: string;
clientIp: string;
@@ -408,6 +452,53 @@ describe("gateway plugin node capability auth", () => {
}, "openclaw-canvas-auth-test-");
}, 60_000);
test("rejects malformed raw HTTP request targets without disrupting gateway", async () => {
await withCanvasGatewayHarness({
resolvedAuth: tokenResolvedAuth,
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener }) => {
for (const requestTarget of ["//", "///", "//${jndi:ldap://example}.action"]) {
const response = await sendRawHttpRequest({
host: "127.0.0.1",
port: listener.port,
requestTarget,
});
expect(response).toMatch(/^HTTP\/1\.1 401 /);
}
const res = await fetchCanvas(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
expect(res.status).toBe(401);
},
});
}, 60_000);
test("rejects malformed raw WebSocket upgrade targets without disrupting gateway", async () => {
await withCanvasGatewayHarness({
resolvedAuth: tokenResolvedAuth,
handleHttpRequest: allowCanvasHostHttp,
run: async ({ listener }) => {
for (const requestTarget of ["//", "///", "//${jndi:ldap://example}.action"]) {
const response = await sendRawHttpRequest({
host: "127.0.0.1",
port: listener.port,
requestTarget,
headers: [
"Host: localhost",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
],
});
expect(response).toMatch(/^HTTP\/1\.1 401 /);
}
const res = await fetchCanvas(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
expect(res.status).toBe(401);
},
});
}, 60_000);
test("denies canvas auth when trusted proxy omits forwarded client headers", async () => {
await withLoopbackTrustedProxy(async () => {
await withCanvasGatewayHarness({