mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 18:14:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user