mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* fix(embeddings): support secure multimodal inputs Closes #7956 * fix(embeddings): translate multimodal inputs and harden URL/base64 bounds Reject oversize base64 before format validation to avoid Zod stack overflows, translate canonical items to Jina modality-keyed and Gemini embedContent contracts, and fetch HTTPS media server-side with DNS pinning before provider submission. Closes #7956 * fix(embeddings): pin DNS only for embedding media fetches Default remote-image fetch keeps the previous globalThis.fetch path so image-generation tests and callers stay mockable. Multimodal embeddings still opt into undici DNS pinning for URL media. * fix(embeddings): close 2 SSRF/DoS gaps in secure multimodal input (#7978) Closes two gaps in the multimodal embedding input hardening from #7956: 1. `createPinnedFetch()` (the connection-pinning mechanism that closes the DNS-rebinding TOCTOU window, GHSA-cmhj-wh2f-9cgx) had zero test coverage anywhere in the repo. Writing that test surfaced a real regression: its custom `connect.lookup` only implemented the single-address callback form `(err, address, family)`. Node's autoSelectFamily/Happy Eyeballs (on by default since Node 18) calls `lookup` with `{ all: true }` and requires the array form `(err, addresses[])` — the mismatch threw `ERR_INVALID_IP_ADDRESS` on every real pinned fetch, silently breaking all URL-sourced multimodal embedding requests in production. Fixed by branching on `options.all`. 2. The documented "16 MiB decoded per request" cap was enforced by the Zod schema only for base64-sourced items; URL-sourced items were excluded, and all up-to-32 items were fetched concurrently via `Promise.all` — allowing ~256 MiB in memory at once (16x the documented bound). Fixed by resolving items sequentially with a running byte budget shared across base64 and fetched-URL sources, rejecting once the aggregate is exhausted instead of after over-fetching. Adds tests/unit/remote-image-fetch-pin-dns-connection.test.ts (real loopback-server pinning tests) and a new aggregate-cap test in tests/unit/embeddings-multimodal-7956.test.ts; both were verified to fail against the pre-fix code before the corresponding fix was applied. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-embeddings-multimodal-"));
|
|
|
|
const { v1EmbeddingsSchema } = await import("../../src/shared/validation/schemas/apiV1.ts");
|
|
const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts");
|
|
|
|
const vectorResponse = () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
|
|
usage: { prompt_tokens: 3, total_tokens: 3 },
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } }
|
|
);
|
|
|
|
test("embedding schema preserves legacy inputs and accepts bounded canonical multimodal items", () => {
|
|
for (const input of ["hello", ["hello", "world"], [101, 102], [[101], [102]]]) {
|
|
assert.equal(
|
|
v1EmbeddingsSchema.safeParse({ model: "openai/text-embedding-3-small", input }).success,
|
|
true
|
|
);
|
|
}
|
|
|
|
const input = [
|
|
{ type: "text", text: "caption" },
|
|
{ type: "image", source: { type: "url", url: "https://example.com/image.png" } },
|
|
{
|
|
type: "audio",
|
|
source: { type: "base64", data: "YXVkaW8=", media_type: "audio/wav" },
|
|
},
|
|
{ type: "video", source: { type: "url", url: "https://example.com/video.mp4" } },
|
|
{
|
|
type: "document",
|
|
source: { type: "base64", data: "cGRm", media_type: "application/pdf" },
|
|
},
|
|
];
|
|
const parsed = v1EmbeddingsSchema.safeParse({
|
|
model: "jina-ai/jina-embeddings-v5-omni-small",
|
|
input,
|
|
});
|
|
assert.equal(parsed.success, true);
|
|
if (parsed.success) assert.deepEqual(parsed.data.input, input);
|
|
});
|
|
|
|
test("embedding schema bounds item count and decoded inline payload sizes", () => {
|
|
const textItems = Array.from({ length: 33 }, (_, index) => ({ type: "text", text: `${index}` }));
|
|
assert.equal(
|
|
v1EmbeddingsSchema.safeParse({
|
|
model: "jina-ai/jina-embeddings-v5-omni-small",
|
|
input: textItems,
|
|
}).success,
|
|
false
|
|
);
|
|
|
|
const overEightMiB = Buffer.alloc(8 * 1024 * 1024 + 1).toString("base64");
|
|
assert.equal(
|
|
v1EmbeddingsSchema.safeParse({
|
|
model: "jina-ai/jina-embeddings-v5-omni-small",
|
|
input: [
|
|
{
|
|
type: "image",
|
|
source: { type: "base64", data: overEightMiB, media_type: "image/png" },
|
|
},
|
|
],
|
|
}).success,
|
|
false
|
|
);
|
|
|
|
const sixMiB = Buffer.alloc(6 * 1024 * 1024).toString("base64");
|
|
assert.equal(
|
|
v1EmbeddingsSchema.safeParse({
|
|
model: "jina-ai/jina-embeddings-v5-omni-small",
|
|
input: ["image", "audio", "document"].map((type) => ({
|
|
type,
|
|
source: { type: "base64", data: sixMiB, media_type: "application/octet-stream" },
|
|
})),
|
|
}).success,
|
|
false
|
|
);
|
|
});
|
|
|
|
test("embedding schema rejects unsafe remote media URLs", () => {
|
|
for (const url of [
|
|
"http://example.com/image.png",
|
|
"file:///etc/passwd",
|
|
"https://127.0.0.1/image.png",
|
|
"https://169.254.169.254/latest/meta-data/",
|
|
"https://metadata.google.internal/computeMetadata/v1/",
|
|
]) {
|
|
const parsed = v1EmbeddingsSchema.safeParse({
|
|
model: "jina-ai/jina-embeddings-v5-omni-small",
|
|
input: [{ type: "image", source: { type: "url", url } }],
|
|
});
|
|
assert.equal(parsed.success, false, `expected URL to be rejected: ${url}`);
|
|
}
|
|
});
|
|
|
|
test("translates canonical items to Jina's modality-keyed request contract", async () => {
|
|
const { prepareStructuredEmbeddingRequest } =
|
|
await import("../../open-sse/handlers/embeddingStructuredInput.ts");
|
|
const provider = {
|
|
id: "jina-ai",
|
|
baseUrl: "https://api.jina.ai/v1/embeddings",
|
|
authType: "apikey",
|
|
authHeader: "bearer",
|
|
structuredInputProtocol: "jina-v1" as const,
|
|
models: [],
|
|
};
|
|
const input = [
|
|
{ type: "text" as const, text: "caption" },
|
|
{ type: "image" as const, source: { type: "url" as const, url: "https://example.com/i.png" } },
|
|
{
|
|
type: "audio" as const,
|
|
source: { type: "base64" as const, data: "YQ==", media_type: "audio/wav" },
|
|
},
|
|
{
|
|
type: "video" as const,
|
|
source: { type: "base64" as const, data: "dg==", media_type: "video/mp4" },
|
|
},
|
|
{
|
|
type: "document" as const,
|
|
source: { type: "base64" as const, data: "cA==", media_type: "application/pdf" },
|
|
},
|
|
];
|
|
const prepared = await prepareStructuredEmbeddingRequest(
|
|
provider,
|
|
"jina-embeddings-v5-omni-small",
|
|
{ input, dimensions: 512, task: "retrieval.query" },
|
|
"token",
|
|
{ fetchMedia: async () => ({ buffer: Buffer.from("img"), contentType: "image/png" }) }
|
|
);
|
|
assert.equal(prepared.url, "https://api.jina.ai/v1/embeddings");
|
|
assert.deepEqual(prepared.body, {
|
|
input: [
|
|
{ text: "caption" },
|
|
{ image: "data:image/png;base64,aW1n" },
|
|
{ audio: "data:audio/wav;base64,YQ==" },
|
|
{ video: "data:video/mp4;base64,dg==" },
|
|
{ pdf: "data:application/pdf;base64,cA==" },
|
|
],
|
|
dimensions: 512,
|
|
task: "retrieval.query",
|
|
model: "jina-embeddings-v5-omni-small",
|
|
});
|
|
});
|
|
|
|
test("translates one canonical array to Gemini native embedContent parts", async () => {
|
|
const { prepareStructuredEmbeddingRequest } =
|
|
await import("../../open-sse/handlers/embeddingStructuredInput.ts");
|
|
const provider = {
|
|
id: "gemini",
|
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/embeddings",
|
|
authType: "apikey",
|
|
authHeader: "bearer",
|
|
structuredInputProtocol: "gemini-embed-content" as const,
|
|
models: [],
|
|
};
|
|
const prepared = await prepareStructuredEmbeddingRequest(
|
|
provider,
|
|
"gemini-embedding-2",
|
|
{
|
|
input: [
|
|
{ type: "text", text: "caption" },
|
|
{ type: "image", source: { type: "base64", data: "aQ==", media_type: "image/png" } },
|
|
{ type: "audio", source: { type: "base64", data: "YQ==", media_type: "audio/mpeg" } },
|
|
{ type: "video", source: { type: "base64", data: "dg==", media_type: "video/mp4" } },
|
|
{
|
|
type: "document",
|
|
source: { type: "base64", data: "cA==", media_type: "application/pdf" },
|
|
},
|
|
],
|
|
dimensions: 1536,
|
|
task: "retrieval.query",
|
|
},
|
|
"gemini-key",
|
|
{
|
|
fetchMedia: async () => {
|
|
throw new Error("unexpected URL");
|
|
},
|
|
}
|
|
);
|
|
assert.equal(
|
|
prepared.url,
|
|
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:embedContent"
|
|
);
|
|
assert.deepEqual(prepared.authHeader, { name: "x-goog-api-key", value: "gemini-key" });
|
|
assert.deepEqual(prepared.body, {
|
|
content: {
|
|
parts: [
|
|
{ text: "caption" },
|
|
{ inline_data: { mime_type: "image/png", data: "aQ==" } },
|
|
{ inline_data: { mime_type: "audio/mpeg", data: "YQ==" } },
|
|
{ inline_data: { mime_type: "video/mp4", data: "dg==" } },
|
|
{ inline_data: { mime_type: "application/pdf", data: "cA==" } },
|
|
],
|
|
},
|
|
output_dimensionality: 1536,
|
|
task_type: "RETRIEVAL_QUERY",
|
|
});
|
|
assert.deepEqual(prepared.normalizeResponse?.({ embedding: { values: [0.1, 0.2] } }), {
|
|
object: "list",
|
|
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
|
|
usage: { prompt_tokens: 0, total_tokens: 0 },
|
|
});
|
|
});
|
|
|
|
test("prepareStructuredEmbeddingRequest bounds aggregate fetched bytes across URL-sourced items and fetches them sequentially", async () => {
|
|
// #7978 fix-in-place item 2: the documented "16 MiB decoded per request"
|
|
// cap is enforced by the Zod schema only for base64-sourced items (see the
|
|
// "bounds item count and decoded inline payload sizes" test above) — URL
|
|
// items are excluded there. Each URL item is individually capped at 8 MiB
|
|
// via fetchRemoteImage's maxBytes, but nothing previously stopped up to 32
|
|
// of them from being fetched concurrently, pulling up to ~256 MiB into
|
|
// memory at once. This proves the handler-level aggregate cap: URL items
|
|
// are fetched one at a time, and no further fetch is started once the
|
|
// running total already exhausts the 16 MiB budget.
|
|
const { prepareStructuredEmbeddingRequest } =
|
|
await import("../../open-sse/handlers/embeddingStructuredInput.ts");
|
|
const provider = {
|
|
id: "jina-ai",
|
|
baseUrl: "https://api.jina.ai/v1/embeddings",
|
|
authType: "apikey",
|
|
authHeader: "bearer",
|
|
structuredInputProtocol: "jina-v1" as const,
|
|
models: [],
|
|
};
|
|
|
|
const EIGHT_MIB = 8 * 1024 * 1024; // exactly the per-item cap
|
|
const items = Array.from({ length: 5 }, (_, index) => ({
|
|
type: "image" as const,
|
|
source: { type: "url" as const, url: `https://example.com/${index}.png` },
|
|
}));
|
|
|
|
let concurrentFetches = 0;
|
|
let maxConcurrentFetches = 0;
|
|
let fetchCount = 0;
|
|
const fetchMedia = async () => {
|
|
fetchCount += 1;
|
|
concurrentFetches += 1;
|
|
maxConcurrentFetches = Math.max(maxConcurrentFetches, concurrentFetches);
|
|
// Yield so a broken (Promise.all-style concurrent) implementation would
|
|
// visibly overlap fetches instead of running them one at a time.
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
concurrentFetches -= 1;
|
|
return { buffer: Buffer.alloc(EIGHT_MIB, 1), contentType: "image/png" };
|
|
};
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
prepareStructuredEmbeddingRequest(
|
|
provider,
|
|
"jina-embeddings-v5-omni-small",
|
|
{ input: items },
|
|
"token",
|
|
{ fetchMedia }
|
|
),
|
|
/16 MiB per request/
|
|
);
|
|
|
|
assert.equal(maxConcurrentFetches, 1, "URL media fetches must be sequential, not concurrent");
|
|
// Two 8 MiB items exactly exhaust the 16 MiB budget; a third must never be
|
|
// fetched at all (fails fast on the running total, not after over-fetching).
|
|
assert.equal(
|
|
fetchCount,
|
|
2,
|
|
"no further URL fetch should start once the aggregate 16 MiB budget is already exhausted"
|
|
);
|
|
});
|
|
|
|
test("handleEmbedding clearly rejects modalities not advertised by the resolved model", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
let fetched = false;
|
|
globalThis.fetch = async () => {
|
|
fetched = true;
|
|
return vectorResponse();
|
|
};
|
|
|
|
try {
|
|
const unsupported = await handleEmbedding({
|
|
body: {
|
|
model: "jina-ai/jina-clip-v2",
|
|
input: [{ type: "audio", source: { type: "url", url: "https://example.com/audio.wav" } }],
|
|
},
|
|
credentials: { apiKey: "test-key" },
|
|
log: null,
|
|
});
|
|
assert.equal(unsupported.success, false);
|
|
assert.equal(unsupported.status, 400);
|
|
assert.match(unsupported.error, /does not support.*audio/i);
|
|
|
|
const unknown = await handleEmbedding({
|
|
body: {
|
|
model: "jina-ai/not-in-registry",
|
|
input: [{ type: "text", text: "hello" }],
|
|
},
|
|
credentials: { apiKey: "test-key" },
|
|
log: null,
|
|
});
|
|
assert.equal(unknown.success, false);
|
|
assert.equal(unknown.status, 400);
|
|
assert.match(unknown.error, /does not advertise structured embedding input/i);
|
|
assert.equal(fetched, false);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|