diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8250e3ea1..72503d14e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/plugin-node-capability.test.ts b/src/gateway/plugin-node-capability.test.ts index 76ddbc25698..4f40ffc7c9f 100644 --- a/src/gateway/plugin-node-capability.test.ts +++ b/src/gateway/plugin-node-capability.test.ts @@ -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({ diff --git a/src/gateway/plugin-node-capability.ts b/src/gateway/plugin-node-capability.ts index 2029f8413c8..80d56844464 100644 --- a/src/gateway/plugin-node-capability.ts +++ b/src/gateway/plugin-node-capability.ts @@ -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; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 5e4025a2d42..a84112c983c 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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; @@ -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({ diff --git a/src/gateway/server.plugin-node-capability-auth.test.ts b/src/gateway/server.plugin-node-capability-auth.test.ts index baee0feea83..7c00b715eab 100644 --- a/src/gateway/server.plugin-node-capability-auth.test.ts +++ b/src/gateway/server.plugin-node-capability-auth.test.ts @@ -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): }); } +async function sendRawHttpRequest(params: { + host: string; + port: number; + requestTarget: string; + headers?: readonly string[]; +}): Promise { + return new Promise((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({