mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(comfy): allow private service hostnames (#99065)
* fix(comfy): allow private service hostnames * fix(comfy): scope private hostname trust to origin * test(comfy): use fetch-compatible response bodies --------- Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
co-authored by
joshavant
parent
0307deacfa
commit
9c86529e44
@@ -206,7 +206,11 @@ Comfy supports shared top-level connection settings plus per-capability workflow
|
||||
| `mode` | `"local"` or `"cloud"` | Connection mode. Defaults to `"local"`. |
|
||||
| `baseUrl` | string | Defaults to `http://127.0.0.1:8188` for local or `https://cloud.comfy.org` for cloud. |
|
||||
| `apiKey` | string | Optional inline key, alternative to `COMFY_API_KEY` / `COMFY_CLOUD_API_KEY` env vars. |
|
||||
| `allowPrivateNetwork` | boolean | Allow a private/LAN `baseUrl` in cloud mode. |
|
||||
| `allowPrivateNetwork` | boolean | Allow a private/LAN `baseUrl` in cloud mode or a local private-DNS FQDN. |
|
||||
|
||||
<Note>
|
||||
In `local` mode, loopback/private IP literals and single-label service names such as `http://comfyui:8188` work without `allowPrivateNetwork`. Public-looking private-DNS FQDNs such as `https://comfy.local.example.com` require `allowPrivateNetwork: true`. Private-origin trust stays scoped to the configured scheme, hostname, and port; local redirects cannot leave the configured hostname, while cloud redirects to public CDNs are checked with the default SSRF policy.
|
||||
</Note>
|
||||
|
||||
### Per-capability keys
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Comfy tests cover image generation provider plugin behavior.
|
||||
import type { LookupAddress } from "node:dns";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
setComfyFetchGuardForTesting,
|
||||
@@ -21,12 +23,28 @@ type FetchGuardRequest = {
|
||||
url?: unknown;
|
||||
auditContext?: unknown;
|
||||
timeoutMs?: unknown;
|
||||
policy?: unknown;
|
||||
init?: {
|
||||
method?: unknown;
|
||||
headers?: HeadersInit;
|
||||
body?: BodyInit | null;
|
||||
};
|
||||
};
|
||||
type RealGuardParams = Parameters<typeof fetchWithSsrFGuard>[0];
|
||||
type RealGuardFetchImpl = NonNullable<RealGuardParams["fetchImpl"]>;
|
||||
type RealGuardLookupFn = NonNullable<RealGuardParams["lookupFn"]>;
|
||||
type RealGuardHarness = {
|
||||
fetchUrls: string[];
|
||||
guardCalls: RealGuardParams[];
|
||||
};
|
||||
|
||||
type RealComfyFetchOptions = {
|
||||
dns: Record<string, string>;
|
||||
promptId?: string;
|
||||
redirectLocation?: string;
|
||||
body?: Buffer;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
function fetchRequest(call: number): FetchGuardRequest {
|
||||
const request = fetchWithSsrFGuardMock.mock.calls[call - 1]?.[0] as FetchGuardRequest | undefined;
|
||||
@@ -40,6 +58,167 @@ function parseJsonBody(call: number): Record<string, unknown> {
|
||||
return parseComfyJsonBody(fetchWithSsrFGuardMock, call);
|
||||
}
|
||||
|
||||
function mockLocalImageResponses(promptId = "local-prompt-1") {
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ prompt_id: promptId }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
[promptId]: {
|
||||
outputs: {
|
||||
"9": {
|
||||
images: [{ filename: "generated.png", subfolder: "", type: "output" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(Buffer.from("png-data"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
}
|
||||
|
||||
const COMFY_SERVICE_HOST_LOCAL_POLICY = {
|
||||
allowedOrigins: ["http://comfyui:8188"],
|
||||
hostnameAllowlist: ["comfyui"],
|
||||
};
|
||||
|
||||
const COMFY_SERVICE_HOST_EXPLICIT_PRIVATE_NETWORK_POLICY = {
|
||||
allowedOrigins: ["http://comfyui:8188"],
|
||||
};
|
||||
|
||||
const COMFY_PUBLIC_LOCAL_HOST_POLICY = {
|
||||
hostnameAllowlist: ["images.example.com"],
|
||||
};
|
||||
|
||||
function testWorkflowConfig(config: Record<string, unknown> = {}) {
|
||||
return {
|
||||
workflow: {
|
||||
"6": { inputs: { text: "" } },
|
||||
"9": { inputs: {} },
|
||||
},
|
||||
promptNodeId: "6",
|
||||
outputNodeId: "9",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
function toFetchUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
return input.url;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function generatedHistory(promptId: string) {
|
||||
return {
|
||||
[promptId]: {
|
||||
outputs: {
|
||||
"9": {
|
||||
images: [{ filename: "generated.png", subfolder: "", type: "output" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createLookupFn(dns: Record<string, string>): RealGuardLookupFn {
|
||||
return (async (hostname: string, options?: unknown) => {
|
||||
const normalized = hostname.toLowerCase().replace(/\.+$/u, "");
|
||||
const address = dns[normalized] ?? "93.184.216.34";
|
||||
const record: LookupAddress = {
|
||||
address,
|
||||
family: address.includes(":") ? 6 : 4,
|
||||
};
|
||||
if (
|
||||
typeof options === "object" &&
|
||||
options !== null &&
|
||||
(options as { all?: unknown }).all === true
|
||||
) {
|
||||
return [record];
|
||||
}
|
||||
return record;
|
||||
}) as RealGuardLookupFn;
|
||||
}
|
||||
|
||||
function installRealComfyFetchGuard(options: RealComfyFetchOptions): RealGuardHarness {
|
||||
const promptId = options.promptId ?? "real-guard-prompt-1";
|
||||
const body = options.body ?? Buffer.from("png-data");
|
||||
const contentType = options.contentType ?? "image/png";
|
||||
const fetchUrls: string[] = [];
|
||||
const guardCalls: RealGuardParams[] = [];
|
||||
const lookupFn = createLookupFn(options.dns);
|
||||
const fetchImpl: RealGuardFetchImpl = async (input) => {
|
||||
const url = toFetchUrl(input);
|
||||
fetchUrls.push(url);
|
||||
const parsed = new URL(url);
|
||||
if (parsed.pathname.endsWith("/prompt")) {
|
||||
return jsonResponse({ prompt_id: promptId });
|
||||
}
|
||||
if (parsed.pathname === `/api/job/${promptId}/status`) {
|
||||
return jsonResponse({ status: "completed" });
|
||||
}
|
||||
if (
|
||||
parsed.pathname === `/history/${promptId}` ||
|
||||
parsed.pathname === `/api/history_v2/${promptId}`
|
||||
) {
|
||||
return jsonResponse(generatedHistory(promptId));
|
||||
}
|
||||
if (parsed.pathname === "/view" || parsed.pathname === "/api/view") {
|
||||
if (options.redirectLocation) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: options.redirectLocation },
|
||||
});
|
||||
}
|
||||
return new Response(new Uint8Array(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
}
|
||||
return new Response(new Uint8Array(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
};
|
||||
|
||||
setComfyFetchGuardForTesting(async (params) => {
|
||||
guardCalls.push(params);
|
||||
return await fetchWithSsrFGuard({
|
||||
...params,
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
});
|
||||
});
|
||||
return { fetchUrls, guardCalls };
|
||||
}
|
||||
|
||||
describe("comfy image-generation provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -204,6 +383,352 @@ describe("comfy image-generation provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("honors local private-network access for service-discovery hostnames", async () => {
|
||||
setComfyFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
mockLocalImageResponses("compose-prompt-1");
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig({
|
||||
baseUrl: "http://comfyui:8188",
|
||||
workflow: {
|
||||
"6": { inputs: { text: "" } },
|
||||
"9": { inputs: {} },
|
||||
},
|
||||
promptNodeId: "6",
|
||||
outputNodeId: "9",
|
||||
}),
|
||||
});
|
||||
|
||||
const submitRequest = fetchRequest(1);
|
||||
expect(submitRequest.url).toBe("http://comfyui:8188/prompt");
|
||||
expect(submitRequest.policy).toEqual(COMFY_SERVICE_HOST_LOCAL_POLICY);
|
||||
expect(fetchRequest(2).policy).toEqual(COMFY_SERVICE_HOST_LOCAL_POLICY);
|
||||
expect(fetchRequest(3).policy).toEqual(COMFY_SERVICE_HOST_LOCAL_POLICY);
|
||||
});
|
||||
|
||||
it("keeps local public-looking hostnames strict without explicit private-network access", async () => {
|
||||
setComfyFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
mockLocalImageResponses("public-host-prompt-1");
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig({
|
||||
baseUrl: "http://images.example.com:8188",
|
||||
workflow: {
|
||||
"6": { inputs: { text: "" } },
|
||||
"9": { inputs: {} },
|
||||
},
|
||||
promptNodeId: "6",
|
||||
outputNodeId: "9",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fetchRequest(1).url).toBe("http://images.example.com:8188/prompt");
|
||||
expect(fetchRequest(1).policy).toEqual(COMFY_PUBLIC_LOCAL_HOST_POLICY);
|
||||
});
|
||||
|
||||
it("keeps cloud service-discovery hostnames strict without explicit private-network access", async () => {
|
||||
setComfyFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
mockComfyCloudJobResponses(fetchWithSsrFGuardMock, {
|
||||
body: Buffer.from("cloud-data"),
|
||||
contentType: "image/png",
|
||||
filename: "cloud.png",
|
||||
outputKind: "images",
|
||||
promptId: "strict-cloud-job-1",
|
||||
redirectLocation: "https://cdn.example.com/cloud.png",
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "cloud workflow prompt",
|
||||
cfg: buildComfyConfig({
|
||||
mode: "cloud",
|
||||
apiKey: "comfy-test-key",
|
||||
baseUrl: "http://comfyui:8188",
|
||||
workflow: {
|
||||
"6": { inputs: { text: "" } },
|
||||
"9": { inputs: {} },
|
||||
},
|
||||
promptNodeId: "6",
|
||||
outputNodeId: "9",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fetchRequest(1).url).toBe("http://comfyui:8188/api/prompt");
|
||||
expect(fetchRequest(1).policy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors explicit cloud private-network access for service-discovery hostnames", async () => {
|
||||
setComfyFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
mockComfyCloudJobResponses(fetchWithSsrFGuardMock, {
|
||||
body: Buffer.from("cloud-data"),
|
||||
contentType: "image/png",
|
||||
filename: "cloud.png",
|
||||
outputKind: "images",
|
||||
promptId: "private-cloud-job-1",
|
||||
redirectLocation: "https://cdn.example.com/cloud.png",
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "cloud workflow prompt",
|
||||
cfg: buildComfyConfig({
|
||||
mode: "cloud",
|
||||
apiKey: "comfy-test-key",
|
||||
baseUrl: "http://comfyui:8188",
|
||||
allowPrivateNetwork: true,
|
||||
workflow: {
|
||||
"6": { inputs: { text: "" } },
|
||||
"9": { inputs: {} },
|
||||
},
|
||||
promptNodeId: "6",
|
||||
outputNodeId: "9",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fetchRequest(1).url).toBe("http://comfyui:8188/api/prompt");
|
||||
expect(fetchRequest(1).policy).toEqual(COMFY_SERVICE_HOST_EXPLICIT_PRIVATE_NETWORK_POLICY);
|
||||
expect(fetchRequest(2).policy).toEqual(COMFY_SERVICE_HOST_EXPLICIT_PRIVATE_NETWORK_POLICY);
|
||||
expect(fetchRequest(3).policy).toEqual(COMFY_SERVICE_HOST_EXPLICIT_PRIVATE_NETWORK_POLICY);
|
||||
expect(fetchRequest(4).policy).toEqual(COMFY_SERVICE_HOST_EXPLICIT_PRIVATE_NETWORK_POLICY);
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("allows local single-label hostnames that resolve to RFC1918 addresses", async () => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns: { comfyui: "10.0.0.25" },
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
baseUrl: "http://comfyui:8188",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(harness.fetchUrls).toContain("http://comfyui:8188/prompt");
|
||||
expect(result.images[0]?.buffer).toEqual(Buffer.from("png-data"));
|
||||
});
|
||||
|
||||
it("blocks local public-looking FQDNs resolving private without explicit opt-in", async () => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns: { "images.example.com": "10.0.0.25" },
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
baseUrl: "http://images.example.com:8188",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow("Blocked: resolves to private/internal/special-use IP address");
|
||||
expect(harness.fetchUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows local private-DNS FQDNs with explicit opt-in", async () => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns: { "comfy.private.example.com": "10.0.0.25" },
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
baseUrl: "http://comfy.private.example.com:8188",
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(harness.fetchUrls).toContain("http://comfy.private.example.com:8188/prompt");
|
||||
expect(result.images[0]?.buffer).toEqual(Buffer.from("png-data"));
|
||||
});
|
||||
|
||||
it("blocks explicit private-DNS FQDNs resolving to metadata addresses", async () => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns: { "comfy.private.example.com": "169.254.169.254" },
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
baseUrl: "http://comfy.private.example.com:8188",
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow("Blocked: resolves to private/internal/special-use IP address");
|
||||
expect(harness.fetchUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"subdomain",
|
||||
"http://assets.comfyui:8188/generated.png",
|
||||
{ comfyui: "10.0.0.25", "assets.comfyui": "10.0.0.26" },
|
||||
],
|
||||
[
|
||||
"different hostname",
|
||||
"http://other-comfy:8188/generated.png",
|
||||
{ comfyui: "10.0.0.25", "other-comfy": "10.0.0.26" },
|
||||
],
|
||||
[
|
||||
"same hostname private alternate port",
|
||||
"http://comfyui:8288/generated.png",
|
||||
{ comfyui: "10.0.0.25" },
|
||||
],
|
||||
[
|
||||
"public CDN hostname",
|
||||
"https://cdn.example.com/generated.png",
|
||||
{ comfyui: "10.0.0.25", "cdn.example.com": "93.184.216.34" },
|
||||
],
|
||||
])("blocks local output redirects to %s", async (_label, redirectLocation, dns) => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns,
|
||||
redirectLocation,
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
baseUrl: "http://comfyui:8188",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow("Blocked");
|
||||
expect(harness.fetchUrls).not.toContain(redirectLocation);
|
||||
});
|
||||
|
||||
it("blocks local public FQDN redirects to other public hosts", async () => {
|
||||
const redirectLocation = "https://cdn.example.com/generated.png";
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns: {
|
||||
"comfy.example.com": "93.184.216.34",
|
||||
"cdn.example.com": "93.184.216.35",
|
||||
},
|
||||
redirectLocation,
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "draw a lobster",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
baseUrl: "https://comfy.example.com",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow("Blocked hostname (not in allowlist)");
|
||||
expect(harness.fetchUrls).not.toContain(redirectLocation);
|
||||
});
|
||||
|
||||
it("allows explicit private cloud origins redirecting to public CDNs", async () => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns: {
|
||||
"private-comfy.example.com": "10.0.0.25",
|
||||
"cdn.example.com": "93.184.216.34",
|
||||
},
|
||||
redirectLocation: "https://cdn.example.com/generated.png",
|
||||
body: Buffer.from("cdn-data"),
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "cloud workflow prompt",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
mode: "cloud",
|
||||
apiKey: "comfy-test-key",
|
||||
baseUrl: "https://private-comfy.example.com",
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(harness.fetchUrls).toContain("https://cdn.example.com/generated.png");
|
||||
expect(harness.guardCalls).toHaveLength(4);
|
||||
expect(result.images[0]?.buffer).toEqual(Buffer.from("cdn-data"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"private DNS destination",
|
||||
"http://other-private.example.com/generated.png",
|
||||
{
|
||||
"private-comfy.example.com": "10.0.0.25",
|
||||
"other-private.example.com": "10.0.0.26",
|
||||
},
|
||||
],
|
||||
[
|
||||
"metadata destination",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
{ "private-comfy.example.com": "10.0.0.25" },
|
||||
],
|
||||
])("blocks explicit private cloud redirects to %s", async (_label, redirectLocation, dns) => {
|
||||
const harness = installRealComfyFetchGuard({
|
||||
dns,
|
||||
redirectLocation,
|
||||
});
|
||||
|
||||
const provider = buildComfyImageGenerationProvider();
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "comfy",
|
||||
model: "workflow",
|
||||
prompt: "cloud workflow prompt",
|
||||
cfg: buildComfyConfig(
|
||||
testWorkflowConfig({
|
||||
mode: "cloud",
|
||||
apiKey: "comfy-test-key",
|
||||
baseUrl: "https://private-comfy.example.com",
|
||||
allowPrivateNetwork: true,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
).rejects.toThrow("Blocked");
|
||||
expect(harness.fetchUrls).not.toContain(redirectLocation);
|
||||
});
|
||||
|
||||
it("caps oversized local workflow timeouts", async () => {
|
||||
setComfyFetchGuardForTesting(fetchWithSsrFGuardMock);
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
@@ -481,9 +1006,7 @@ describe("comfy image-generation provider", () => {
|
||||
"https://cloud.comfy.org/api/view?filename=cloud.png&subfolder=&type=output",
|
||||
);
|
||||
expect(viewRequest.auditContext).toBe("comfy-image-download");
|
||||
const cdnRequest = fetchRequest(5);
|
||||
expect(cdnRequest.url).toBe("https://cdn.example.com/cloud.png");
|
||||
expect(cdnRequest.auditContext).toBe("comfy-image-download");
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(4);
|
||||
expect(result.metadata).toEqual({
|
||||
promptId: "cloud-job-1",
|
||||
outputNodeIds: ["9"],
|
||||
|
||||
@@ -17,7 +17,7 @@ type ComfyCloudJobResponseOptions = {
|
||||
filename: string;
|
||||
outputKind: "gifs" | "images";
|
||||
promptId: string;
|
||||
redirectLocation: string;
|
||||
redirectLocation?: string;
|
||||
};
|
||||
|
||||
export function buildComfyConfig(config: Record<string, unknown>): OpenClawConfig {
|
||||
@@ -79,14 +79,6 @@ export function mockComfyCloudJobResponses(
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
fetchGuardResponse(
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: options.redirectLocation },
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
fetchGuardResponse(
|
||||
new Response(options.body, {
|
||||
|
||||
@@ -21,11 +21,10 @@ import {
|
||||
resolveSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input-runtime";
|
||||
import {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
fetchWithSsrFGuard,
|
||||
isPrivateOrLoopbackHost,
|
||||
mergeSsrFPolicies,
|
||||
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
@@ -280,6 +279,8 @@ function setWorkflowInput(params: {
|
||||
function resolveComfyNetworkPolicy(params: {
|
||||
baseUrl: string;
|
||||
allowPrivateNetwork: boolean;
|
||||
explicitAllowPrivateNetwork: boolean;
|
||||
mode: ComfyMode;
|
||||
}): ComfyNetworkPolicy {
|
||||
let parsed: URL;
|
||||
try {
|
||||
@@ -289,17 +290,45 @@ function resolveComfyNetworkPolicy(params: {
|
||||
}
|
||||
|
||||
const hostname = normalizeOptionalLowercaseString(parsed.hostname) ?? "";
|
||||
if (!hostname || !params.allowPrivateNetwork || !isPrivateOrLoopbackHost(hostname)) {
|
||||
if (!hostname) {
|
||||
return {};
|
||||
}
|
||||
const localHostnamePolicy: SsrFPolicy | undefined =
|
||||
params.mode === "local" ? { hostnameAllowlist: [hostname] } : undefined;
|
||||
const hostnameOnlyPolicy = localHostnamePolicy ? { apiPolicy: localHostnamePolicy } : {};
|
||||
if (!params.allowPrivateNetwork) {
|
||||
return hostnameOnlyPolicy;
|
||||
}
|
||||
// Local mode auto-trusts loopback/IP targets and Compose-style single-label
|
||||
// service names; public-looking FQDNs require the operator's explicit
|
||||
// allowPrivateNetwork opt-in.
|
||||
if (!params.explicitAllowPrivateNetwork && params.mode !== "local") {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
!params.explicitAllowPrivateNetwork &&
|
||||
params.mode === "local" &&
|
||||
!isPrivateOrLoopbackHost(hostname) &&
|
||||
!isSingleLabelServiceHostname(hostname)
|
||||
) {
|
||||
return hostnameOnlyPolicy;
|
||||
}
|
||||
|
||||
const originPolicy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(params.baseUrl);
|
||||
if (!originPolicy) {
|
||||
return hostnameOnlyPolicy;
|
||||
}
|
||||
|
||||
const hostnamePolicy = buildHostnameAllowlistPolicyFromSuffixAllowlist([hostname]);
|
||||
const privateNetworkPolicy = ssrfPolicyFromDangerouslyAllowPrivateNetwork(true);
|
||||
return {
|
||||
apiPolicy: mergeSsrFPolicies(hostnamePolicy, privateNetworkPolicy),
|
||||
apiPolicy:
|
||||
params.mode === "local" ? mergeSsrFPolicies(originPolicy, localHostnamePolicy) : originPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function isSingleLabelServiceHostname(hostname: string): boolean {
|
||||
return /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(hostname);
|
||||
}
|
||||
|
||||
async function readJsonResponse<T>(params: {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
@@ -561,7 +590,6 @@ async function downloadOutputFile(params: {
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: params.headers,
|
||||
...(params.mode === "cloud" ? { redirect: "manual" } : {}),
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
policy: params.policy,
|
||||
@@ -570,45 +598,6 @@ async function downloadOutputFile(params: {
|
||||
});
|
||||
|
||||
try {
|
||||
if (
|
||||
params.mode === "cloud" &&
|
||||
[301, 302, 303, 307, 308].includes(firstResponse.response.status)
|
||||
) {
|
||||
const redirectUrl = normalizeOptionalString(firstResponse.response.headers.get("location"));
|
||||
if (!redirectUrl) {
|
||||
throw new Error("Comfy cloud output redirect missing location header");
|
||||
}
|
||||
const redirected = await comfyFetchGuard({
|
||||
url: redirectUrl,
|
||||
init: {
|
||||
method: "GET",
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
dispatcherPolicy: params.dispatcherPolicy,
|
||||
auditContext,
|
||||
});
|
||||
try {
|
||||
await assertOkOrThrowHttpError(redirected.response, "Comfy output download failed");
|
||||
const mimeType =
|
||||
normalizeOptionalString(redirected.response.headers.get("content-type")) ||
|
||||
"application/octet-stream";
|
||||
return {
|
||||
buffer: await readResponseWithLimit(redirected.response, params.maxBytes, {
|
||||
chunkTimeoutMs: params.timeoutMs,
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`Comfy ${params.capability} output download exceeds ${maxBytes} bytes`),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(
|
||||
`Comfy ${params.capability} output download stalled after ${chunkTimeoutMs}ms`,
|
||||
),
|
||||
}),
|
||||
mimeType,
|
||||
};
|
||||
} finally {
|
||||
await redirected.release();
|
||||
}
|
||||
}
|
||||
|
||||
await assertOkOrThrowHttpError(firstResponse.response, "Comfy output download failed");
|
||||
const mimeType =
|
||||
normalizeOptionalString(firstResponse.response.headers.get("content-type")) ||
|
||||
@@ -720,13 +709,14 @@ export async function runComfyWorkflow(params: {
|
||||
throw new Error("Comfy Cloud API key missing");
|
||||
}
|
||||
|
||||
const explicitAllowPrivateNetwork =
|
||||
readConfigBoolean(capabilityConfig, "allowPrivateNetwork") === true;
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: normalizeOptionalString(capabilityConfig.baseUrl),
|
||||
defaultBaseUrl:
|
||||
mode === "cloud" ? DEFAULT_COMFY_CLOUD_BASE_URL : DEFAULT_COMFY_LOCAL_BASE_URL,
|
||||
allowPrivateNetwork:
|
||||
mode === "local" || readConfigBoolean(capabilityConfig, "allowPrivateNetwork") === true,
|
||||
allowPrivateNetwork: mode === "local" || explicitAllowPrivateNetwork,
|
||||
defaultHeaders:
|
||||
mode === "cloud"
|
||||
? {
|
||||
@@ -746,6 +736,8 @@ export async function runComfyWorkflow(params: {
|
||||
const networkPolicy = resolveComfyNetworkPolicy({
|
||||
baseUrl: normalizedBaseUrl,
|
||||
allowPrivateNetwork,
|
||||
explicitAllowPrivateNetwork,
|
||||
mode,
|
||||
});
|
||||
|
||||
if (params.inputImage) {
|
||||
|
||||
Reference in New Issue
Block a user