mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 06:00:23 +00:00
fix: harden OpenResponses URL input fetching
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
||||
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||
@@ -37,6 +39,15 @@ async function startServer(port: number, opts?: { openResponsesEnabled?: boolean
|
||||
});
|
||||
}
|
||||
|
||||
async function writeGatewayConfig(config: Record<string, unknown>) {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) {
|
||||
throw new Error("OPENCLAW_CONFIG_PATH is required for gateway config tests");
|
||||
}
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async function postResponses(port: number, body: unknown, headers?: Record<string, string>) {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||
method: "POST",
|
||||
@@ -504,4 +515,187 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
// shared server
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks unsafe URL-based file/image inputs", async () => {
|
||||
const port = enabledPort;
|
||||
agentCommand.mockReset();
|
||||
|
||||
const blockedPrivate = await postResponses(port, {
|
||||
model: "openclaw",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "read this" },
|
||||
{
|
||||
type: "input_file",
|
||||
source: { type: "url", url: "http://127.0.0.1:6379/info" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(blockedPrivate.status).toBe(400);
|
||||
const blockedPrivateJson = (await blockedPrivate.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(blockedPrivateJson.error?.type).toBe("invalid_request_error");
|
||||
expect(blockedPrivateJson.error?.message ?? "").toMatch(/private|internal|blocked/i);
|
||||
|
||||
const blockedMetadata = await postResponses(port, {
|
||||
model: "openclaw",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "read this" },
|
||||
{
|
||||
type: "input_image",
|
||||
source: { type: "url", url: "http://metadata.google.internal/computeMetadata/v1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(blockedMetadata.status).toBe(400);
|
||||
const blockedMetadataJson = (await blockedMetadata.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(blockedMetadataJson.error?.type).toBe("invalid_request_error");
|
||||
expect(blockedMetadataJson.error?.message ?? "").toMatch(/blocked|metadata|internal/i);
|
||||
|
||||
const blockedScheme = await postResponses(port, {
|
||||
model: "openclaw",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "read this" },
|
||||
{
|
||||
type: "input_file",
|
||||
source: { type: "url", url: "file:///etc/passwd" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(blockedScheme.status).toBe(400);
|
||||
const blockedSchemeJson = (await blockedScheme.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(blockedSchemeJson.error?.type).toBe("invalid_request_error");
|
||||
expect(blockedSchemeJson.error?.message ?? "").toMatch(/http or https/i);
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces URL allowlist and URL part cap for responses inputs", async () => {
|
||||
const allowlistConfig = {
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: {
|
||||
enabled: true,
|
||||
maxUrlParts: 1,
|
||||
files: {
|
||||
allowUrl: true,
|
||||
urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
|
||||
},
|
||||
images: {
|
||||
allowUrl: true,
|
||||
urlAllowlist: ["images.example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeGatewayConfig(allowlistConfig);
|
||||
|
||||
const allowlistPort = await getFreePort();
|
||||
const allowlistServer = await startServer(allowlistPort, { openResponsesEnabled: true });
|
||||
try {
|
||||
agentCommand.mockReset();
|
||||
|
||||
const allowlistBlocked = await postResponses(allowlistPort, {
|
||||
model: "openclaw",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "fetch this" },
|
||||
{
|
||||
type: "input_file",
|
||||
source: { type: "url", url: "https://evil.example.org/secret.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(allowlistBlocked.status).toBe(400);
|
||||
const allowlistBlockedJson = (await allowlistBlocked.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(allowlistBlockedJson.error?.type).toBe("invalid_request_error");
|
||||
expect(allowlistBlockedJson.error?.message ?? "").toMatch(/allowlist|blocked/i);
|
||||
} finally {
|
||||
await allowlistServer.close({ reason: "responses allowlist hardening test done" });
|
||||
}
|
||||
|
||||
const capConfig = {
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: {
|
||||
enabled: true,
|
||||
maxUrlParts: 0,
|
||||
files: {
|
||||
allowUrl: true,
|
||||
urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
|
||||
},
|
||||
images: {
|
||||
allowUrl: true,
|
||||
urlAllowlist: ["images.example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await writeGatewayConfig(capConfig);
|
||||
|
||||
const capPort = await getFreePort();
|
||||
const capServer = await startServer(capPort, { openResponsesEnabled: true });
|
||||
try {
|
||||
agentCommand.mockReset();
|
||||
const maxUrlBlocked = await postResponses(capPort, {
|
||||
model: "openclaw",
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "fetch this" },
|
||||
{
|
||||
type: "input_file",
|
||||
source: { type: "url", url: "https://cdn.example.com/file-1.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(maxUrlBlocked.status).toBe(400);
|
||||
const maxUrlBlockedJson = (await maxUrlBlocked.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
};
|
||||
expect(maxUrlBlockedJson.error?.type).toBe("invalid_request_error");
|
||||
expect(maxUrlBlockedJson.error?.message ?? "").toMatch(/Too many URL-based input sources/i);
|
||||
expect(agentCommand).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await capServer.close({ reason: "responses url cap hardening test done" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ type OpenResponsesHttpOptions = {
|
||||
};
|
||||
|
||||
const DEFAULT_BODY_BYTES = 20 * 1024 * 1024;
|
||||
const DEFAULT_MAX_URL_PARTS = 8;
|
||||
|
||||
function writeSseEvent(res: ServerResponse, event: StreamingEvent) {
|
||||
res.write(`event: ${event.type}\n`);
|
||||
@@ -89,10 +90,19 @@ function extractTextContent(content: string | ContentPart[]): string {
|
||||
|
||||
type ResolvedResponsesLimits = {
|
||||
maxBodyBytes: number;
|
||||
maxUrlParts: number;
|
||||
files: InputFileLimits;
|
||||
images: InputImageLimits;
|
||||
};
|
||||
|
||||
function normalizeHostnameAllowlist(values: string[] | undefined): string[] | undefined {
|
||||
if (!values || values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveResponsesLimits(
|
||||
config: GatewayHttpResponsesConfig | undefined,
|
||||
): ResolvedResponsesLimits {
|
||||
@@ -100,8 +110,13 @@ function resolveResponsesLimits(
|
||||
const images = config?.images;
|
||||
return {
|
||||
maxBodyBytes: config?.maxBodyBytes ?? DEFAULT_BODY_BYTES,
|
||||
maxUrlParts:
|
||||
typeof config?.maxUrlParts === "number"
|
||||
? Math.max(0, Math.floor(config.maxUrlParts))
|
||||
: DEFAULT_MAX_URL_PARTS,
|
||||
files: {
|
||||
allowUrl: files?.allowUrl ?? true,
|
||||
urlAllowlist: normalizeHostnameAllowlist(files?.urlAllowlist),
|
||||
allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_INPUT_FILE_MIMES),
|
||||
maxBytes: files?.maxBytes ?? DEFAULT_INPUT_FILE_MAX_BYTES,
|
||||
maxChars: files?.maxChars ?? DEFAULT_INPUT_FILE_MAX_CHARS,
|
||||
@@ -115,6 +130,7 @@ function resolveResponsesLimits(
|
||||
},
|
||||
images: {
|
||||
allowUrl: images?.allowUrl ?? true,
|
||||
urlAllowlist: normalizeHostnameAllowlist(images?.urlAllowlist),
|
||||
allowedMimes: normalizeMimeList(images?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES),
|
||||
maxBytes: images?.maxBytes ?? DEFAULT_INPUT_IMAGE_MAX_BYTES,
|
||||
maxRedirects: images?.maxRedirects ?? DEFAULT_INPUT_MAX_REDIRECTS,
|
||||
@@ -384,6 +400,15 @@ export async function handleOpenResponsesHttpRequest(
|
||||
// Extract images + files from input (Phase 2)
|
||||
let images: ImageContent[] = [];
|
||||
let fileContexts: string[] = [];
|
||||
let urlParts = 0;
|
||||
const markUrlPart = () => {
|
||||
urlParts += 1;
|
||||
if (urlParts > limits.maxUrlParts) {
|
||||
throw new Error(
|
||||
`Too many URL-based input sources: ${urlParts} (limit: ${limits.maxUrlParts})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (Array.isArray(payload.input)) {
|
||||
for (const item of payload.input) {
|
||||
@@ -401,6 +426,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
if (!sourceType) {
|
||||
throw new Error("input_image must have 'source.url' or 'source.data'");
|
||||
}
|
||||
if (sourceType === "url") {
|
||||
markUrlPart();
|
||||
}
|
||||
const imageSource: InputImageSource = {
|
||||
type: sourceType,
|
||||
url: source.url,
|
||||
@@ -425,6 +453,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
if (!sourceType) {
|
||||
throw new Error("input_file must have 'source.url' or 'source.data'");
|
||||
}
|
||||
if (sourceType === "url") {
|
||||
markUrlPart();
|
||||
}
|
||||
const file = await extractFileContentFromSource({
|
||||
source: {
|
||||
type: sourceType,
|
||||
|
||||
Reference in New Issue
Block a user