Browse Source
Release: security hardening, output node improvements, schema resolution, and bug fixeshandoff-20260429-1057
committed by
GitHub
26 changed files with 1219 additions and 75 deletions
@ -0,0 +1,177 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
|||
import { generateWithFalQueue, clearFalInputMappingCache } from "../fal"; |
|||
import type { GenerationInput } from "@/lib/providers/types"; |
|||
|
|||
/** |
|||
* Tests for prompt passthrough when dynamicInputs are present. |
|||
* |
|||
* Bug: When a generate node has both a prompt AND image input connected, |
|||
* the dynamicInputs code path processes images but never reads input.prompt, |
|||
* causing "prompt is required" errors from the fal.ai API. |
|||
*/ |
|||
|
|||
// Captured request bodies from fetch calls
|
|||
let capturedQueueBody: Record<string, unknown> | null = null; |
|||
|
|||
function makeInput(overrides: Partial<GenerationInput> = {}): GenerationInput { |
|||
return { |
|||
model: { |
|||
id: "fal-ai/test-model", |
|||
name: "Test Model", |
|||
description: null, |
|||
provider: "fal", |
|||
capabilities: ["text-to-image"], |
|||
}, |
|||
prompt: "a photo of a cat", |
|||
images: [], |
|||
parameters: {}, |
|||
...overrides, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Create a mock fetch that intercepts fal.ai API calls. |
|||
* - Schema request: returns OpenAPI spec with prompt + image_url properties |
|||
* - Queue submission: captures body, returns request_id |
|||
* - Status poll: returns COMPLETED |
|||
* - Result fetch: returns images array |
|||
* - Media fetch: returns fake image |
|||
*/ |
|||
function createMockFetch() { |
|||
return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { |
|||
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; |
|||
|
|||
// Schema request (fal.ai model search API with OpenAPI expansion)
|
|||
if (urlStr.includes("api.fal.ai/v1/models")) { |
|||
return new Response( |
|||
JSON.stringify({ |
|||
models: [ |
|||
{ |
|||
endpoint_id: "fal-ai/test-model", |
|||
openapi: { |
|||
paths: { |
|||
"/": { |
|||
post: { |
|||
requestBody: { |
|||
content: { |
|||
"application/json": { |
|||
schema: { |
|||
properties: { |
|||
prompt: { type: "string", description: "Text prompt" }, |
|||
image_url: { type: "string", description: "Input image URL" }, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
], |
|||
}), |
|||
{ status: 200 } |
|||
); |
|||
} |
|||
|
|||
// Queue submission
|
|||
if (urlStr.includes("queue.fal.run/fal-ai/test-model") && init?.method === "POST") { |
|||
capturedQueueBody = JSON.parse(init.body as string); |
|||
return new Response( |
|||
JSON.stringify({ |
|||
request_id: "test-123", |
|||
status_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123/status", |
|||
response_url: "https://queue.fal.run/fal-ai/test-model/requests/test-123", |
|||
}), |
|||
{ status: 200 } |
|||
); |
|||
} |
|||
|
|||
// Status poll
|
|||
if (urlStr.includes("/requests/test-123/status")) { |
|||
return new Response( |
|||
JSON.stringify({ status: "COMPLETED" }), |
|||
{ status: 200 } |
|||
); |
|||
} |
|||
|
|||
// Result fetch
|
|||
if (urlStr.includes("/requests/test-123") && !urlStr.includes("/status")) { |
|||
return new Response( |
|||
JSON.stringify({ |
|||
images: [{ url: "https://cdn.fal.ai/test/image.png" }], |
|||
}), |
|||
{ status: 200 } |
|||
); |
|||
} |
|||
|
|||
// Media fetch (output image)
|
|||
if (urlStr.includes("cdn.fal.ai")) { |
|||
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { |
|||
status: 200, |
|||
headers: { "content-type": "image/png" }, |
|||
}); |
|||
} |
|||
|
|||
return new Response("Not Found", { status: 404 }); |
|||
}); |
|||
} |
|||
|
|||
describe("fal.ai prompt passthrough with dynamicInputs", () => { |
|||
let mockFetch: ReturnType<typeof createMockFetch>; |
|||
|
|||
beforeEach(() => { |
|||
capturedQueueBody = null; |
|||
clearFalInputMappingCache(); |
|||
mockFetch = createMockFetch(); |
|||
vi.stubGlobal("fetch", mockFetch); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.unstubAllGlobals(); |
|||
}); |
|||
|
|||
it("includes prompt when dynamicInputs has image_url but no prompt", async () => { |
|||
const input = makeInput({ |
|||
prompt: "a photo of a cat", |
|||
dynamicInputs: { |
|||
image_url: "https://cdn.example.com/img.png", |
|||
}, |
|||
}); |
|||
|
|||
await generateWithFalQueue("test-req", "test-api-key", input); |
|||
|
|||
expect(capturedQueueBody).not.toBeNull(); |
|||
expect(capturedQueueBody!.prompt).toBe("a photo of a cat"); |
|||
expect(capturedQueueBody!.image_url).toBe("https://cdn.example.com/img.png"); |
|||
}); |
|||
|
|||
it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { |
|||
const input = makeInput({ |
|||
prompt: "a cat", |
|||
dynamicInputs: { |
|||
prompt: "a dog", |
|||
image_url: "https://cdn.example.com/img.png", |
|||
}, |
|||
}); |
|||
|
|||
await generateWithFalQueue("test-req", "test-api-key", input); |
|||
|
|||
expect(capturedQueueBody).not.toBeNull(); |
|||
// dynamicInputs value wins - not overwritten by input.prompt
|
|||
expect(capturedQueueBody!.prompt).toBe("a dog"); |
|||
}); |
|||
|
|||
it("works without dynamicInputs (existing behavior)", async () => { |
|||
const input = makeInput({ |
|||
prompt: "a cat", |
|||
dynamicInputs: undefined, |
|||
}); |
|||
|
|||
await generateWithFalQueue("test-req", "test-api-key", input); |
|||
|
|||
expect(capturedQueueBody).not.toBeNull(); |
|||
expect(capturedQueueBody!.prompt).toBe("a cat"); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,147 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
|||
import { generateWithReplicate } from "../replicate"; |
|||
import type { GenerationInput } from "@/lib/providers/types"; |
|||
|
|||
/** |
|||
* Tests for prompt passthrough when dynamicInputs are present. |
|||
* |
|||
* Bug: When a generate node has both a prompt AND image input connected, |
|||
* the dynamicInputs code path processes images but never reads input.prompt, |
|||
* causing "prompt is required" errors from the Replicate API. |
|||
*/ |
|||
|
|||
// Captured request bodies from fetch calls
|
|||
let capturedPredictionBody: Record<string, unknown> | null = null; |
|||
|
|||
function makeInput(overrides: Partial<GenerationInput> = {}): GenerationInput { |
|||
return { |
|||
model: { |
|||
id: "owner/test-model", |
|||
name: "Test Model", |
|||
description: null, |
|||
provider: "replicate", |
|||
capabilities: ["text-to-image"], |
|||
}, |
|||
prompt: "a photo of a cat", |
|||
images: [], |
|||
parameters: {}, |
|||
...overrides, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* Create a mock fetch that intercepts Replicate API calls. |
|||
* - Model info: returns schema with prompt + image_input properties |
|||
* - Prediction creation: captures body, returns succeeded |
|||
* - Media fetch: returns fake image |
|||
*/ |
|||
function createMockFetch() { |
|||
return vi.fn(async (url: string | URL | Request, init?: RequestInit) => { |
|||
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; |
|||
|
|||
// Model info request
|
|||
if (urlStr.includes("/models/owner/test-model") && !urlStr.includes("/predictions")) { |
|||
return new Response( |
|||
JSON.stringify({ |
|||
latest_version: { |
|||
id: "abc123", |
|||
openapi_schema: { |
|||
components: { |
|||
schemas: { |
|||
Input: { |
|||
properties: { |
|||
prompt: { type: "string", description: "Text prompt" }, |
|||
image_input: { type: "string", description: "Input image URL" }, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}), |
|||
{ status: 200 } |
|||
); |
|||
} |
|||
|
|||
// Prediction creation
|
|||
if (urlStr.includes("/predictions") && init?.method === "POST") { |
|||
const body = JSON.parse(init.body as string); |
|||
capturedPredictionBody = body.input; |
|||
return new Response( |
|||
JSON.stringify({ |
|||
id: "pred-123", |
|||
status: "succeeded", |
|||
output: ["https://replicate.delivery/test/image.png"], |
|||
}), |
|||
{ status: 201 } |
|||
); |
|||
} |
|||
|
|||
// Media fetch (output image)
|
|||
if (urlStr.includes("replicate.delivery")) { |
|||
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), { |
|||
status: 200, |
|||
headers: { "content-type": "image/png" }, |
|||
}); |
|||
} |
|||
|
|||
return new Response("Not Found", { status: 404 }); |
|||
}); |
|||
} |
|||
|
|||
describe("Replicate prompt passthrough with dynamicInputs", () => { |
|||
let mockFetch: ReturnType<typeof createMockFetch>; |
|||
|
|||
beforeEach(() => { |
|||
capturedPredictionBody = null; |
|||
mockFetch = createMockFetch(); |
|||
vi.stubGlobal("fetch", mockFetch); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.unstubAllGlobals(); |
|||
}); |
|||
|
|||
it("includes prompt when dynamicInputs has image_input but no prompt", async () => { |
|||
const input = makeInput({ |
|||
prompt: "a photo of a cat", |
|||
dynamicInputs: { |
|||
image_input: "https://cdn.example.com/img.png", |
|||
}, |
|||
}); |
|||
|
|||
await generateWithReplicate("test-req", "test-api-key", input); |
|||
|
|||
expect(capturedPredictionBody).not.toBeNull(); |
|||
expect(capturedPredictionBody!.prompt).toBe("a photo of a cat"); |
|||
expect(capturedPredictionBody!.image_input).toBe("https://cdn.example.com/img.png"); |
|||
}); |
|||
|
|||
it("does not duplicate prompt when dynamicInputs already contains prompt", async () => { |
|||
const input = makeInput({ |
|||
prompt: "a cat", |
|||
dynamicInputs: { |
|||
prompt: "a dog", |
|||
image_input: "https://cdn.example.com/img.png", |
|||
}, |
|||
}); |
|||
|
|||
await generateWithReplicate("test-req", "test-api-key", input); |
|||
|
|||
expect(capturedPredictionBody).not.toBeNull(); |
|||
// dynamicInputs value wins - not overwritten by input.prompt
|
|||
expect(capturedPredictionBody!.prompt).toBe("a dog"); |
|||
}); |
|||
|
|||
it("works without dynamicInputs (existing behavior)", async () => { |
|||
const input = makeInput({ |
|||
prompt: "a cat", |
|||
dynamicInputs: undefined, |
|||
}); |
|||
|
|||
await generateWithReplicate("test-req", "test-api-key", input); |
|||
|
|||
expect(capturedPredictionBody).not.toBeNull(); |
|||
expect(capturedPredictionBody!.prompt).toBe("a cat"); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,59 @@ |
|||
import * as path from "path"; |
|||
|
|||
/** |
|||
* Validates a workflow directory path to prevent path traversal attacks. |
|||
* Ensures the path is absolute, doesn't contain traversal sequences, |
|||
* and doesn't point to dangerous system directories. |
|||
*/ |
|||
export function validateWorkflowPath(inputPath: string): { |
|||
valid: boolean; |
|||
resolved: string; |
|||
error?: string; |
|||
} { |
|||
// Must be an absolute path
|
|||
if (!path.isAbsolute(inputPath)) { |
|||
return { |
|||
valid: false, |
|||
resolved: inputPath, |
|||
error: "Path must be absolute", |
|||
}; |
|||
} |
|||
|
|||
// Resolve the path and ensure it equals the input (catches .. traversal)
|
|||
const resolved = path.resolve(inputPath); |
|||
if (resolved !== inputPath) { |
|||
return { |
|||
valid: false, |
|||
resolved, |
|||
error: "Path contains traversal sequences", |
|||
}; |
|||
} |
|||
|
|||
// Block known dangerous system directories
|
|||
const dangerousPrefixes = [ |
|||
"/etc", |
|||
"/usr", |
|||
"/bin", |
|||
"/sbin", |
|||
"/sys", |
|||
"/proc", |
|||
"/var/run", |
|||
"/System", |
|||
"/Library", |
|||
]; |
|||
|
|||
for (const prefix of dangerousPrefixes) { |
|||
if (resolved.startsWith(prefix + "/") || resolved === prefix) { |
|||
return { |
|||
valid: false, |
|||
resolved, |
|||
error: `Access to ${prefix} is not allowed`, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
return { |
|||
valid: true, |
|||
resolved, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue