Files
OmniRoute/open-sse/handlers/embeddingStructuredInput.ts
Ravi Tharuma 79ec594c1f fix(embeddings): support secure multimodal inputs (#7978)
* 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>
2026-07-22 07:10:24 -03:00

165 lines
6.5 KiB
TypeScript

import { MAX_EMBEDDING_INLINE_TOTAL_BYTES } from "@/shared/validation/schemas/apiV1";
import type { EmbeddingMultimodalItem } from "@/shared/validation/schemas/apiV1";
import type { EmbeddingProvider } from "../config/embeddingRegistry.ts";
const AGGREGATE_SIZE_ERROR = "decoded inline media must not exceed 16 MiB per request";
export interface StructuredEmbeddingFetchOptions {
/**
* Fetch one HTTPS media source and return a bounded, already validated body.
* The production implementation owns DNS/redirect/timeout/size enforcement.
*/
fetchMedia: (url: string) => Promise<{ buffer: Buffer; contentType: string | null }>;
}
interface PreparedEmbeddingRequest {
url: string;
body: Record<string, unknown>;
authHeader?: { name: string; value: string };
normalizeResponse?: (data: Record<string, unknown>) => Record<string, unknown>;
}
function isStructuredItem(value: unknown): value is EmbeddingMultimodalItem {
return typeof value === "object" && value !== null && "type" in value;
}
export function hasStructuredEmbeddingInput(input: unknown): input is EmbeddingMultimodalItem[] {
return Array.isArray(input) && input.some(isStructuredItem);
}
async function sourceToInlineData(
item: Exclude<EmbeddingMultimodalItem, { type: "text" }>,
fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"]
): Promise<{ data: string; mediaType: string }> {
if (item.source.type === "base64") {
return { data: item.source.data, mediaType: item.source.media_type };
}
const fetched = await fetchMedia(item.source.url);
if (!fetched.contentType) {
throw new Error("Remote embedding media must include a Content-Type header");
}
return { data: fetched.buffer.toString("base64"), mediaType: fetched.contentType };
}
interface ResolvedInlineItem {
item: EmbeddingMultimodalItem;
inline: { data: string; mediaType: string } | null;
}
/**
* Resolve every non-text item's inline data SEQUENTIALLY (not `Promise.all`),
* enforcing the documented "16 MiB decoded per request" cap across ALL
* sources — base64 AND fetched URLs.
*
* The Zod schema (`embeddingMultimodalInputSchema.superRefine` in
* `src/shared/validation/schemas/apiV1.ts`) only sums base64-sourced items
* before this handler ever runs — URL-sourced items are excluded from that
* aggregate there. Each URL item is individually capped at 8 MiB via
* `fetchRemoteImage(url, { maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES })`, but
* fetching all up to 32 items concurrently could otherwise pull ~256 MiB into
* memory at once — 16x past the documented per-request bound. Processing
* items one at a time and checking a running byte budget after every fetch
* closes that gap: at most one URL fetch is ever in flight, and no further
* URL fetch is started once the aggregate budget is already exhausted.
*/
async function resolveInlineItems(
items: EmbeddingMultimodalItem[],
fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"]
): Promise<ResolvedInlineItem[]> {
const results: ResolvedInlineItem[] = [];
let remainingBytes = MAX_EMBEDDING_INLINE_TOTAL_BYTES;
for (const item of items) {
if (item.type === "text") {
results.push({ item, inline: null });
continue;
}
if (item.source.type === "url" && remainingBytes <= 0) {
throw new Error(AGGREGATE_SIZE_ERROR);
}
const { data, mediaType } = await sourceToInlineData(item, fetchMedia);
const decodedBytes = Buffer.byteLength(data, "base64");
if (decodedBytes > remainingBytes) {
throw new Error(AGGREGATE_SIZE_ERROR);
}
remainingBytes -= decodedBytes;
results.push({ item, inline: { data, mediaType } });
}
return results;
}
async function prepareJinaInput(
items: EmbeddingMultimodalItem[],
fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"]
): Promise<Array<Record<string, string>>> {
const resolved = await resolveInlineItems(items, fetchMedia);
return resolved.map(({ item, inline }) => {
if (item.type === "text") return { text: item.text };
const key = item.type === "document" ? "pdf" : item.type;
return { [key]: `data:${inline!.mediaType};base64,${inline!.data}` };
});
}
function mapGeminiTaskType(value: unknown): unknown {
if (value === "retrieval.query") return "RETRIEVAL_QUERY";
if (value === "retrieval.passage") return "RETRIEVAL_DOCUMENT";
return value;
}
async function prepareGeminiParts(
items: EmbeddingMultimodalItem[],
fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"]
): Promise<Array<Record<string, unknown>>> {
const resolved = await resolveInlineItems(items, fetchMedia);
return resolved.map(({ item, inline }) => {
if (item.type === "text") return { text: item.text };
return { inline_data: { mime_type: inline!.mediaType, data: inline!.data } };
});
}
function normalizeGeminiResponse(data: Record<string, unknown>): Record<string, unknown> {
const embedding = data.embedding as { values?: unknown } | undefined;
return {
object: "list",
data: [{ object: "embedding", embedding: embedding?.values ?? [], index: 0 }],
usage: { prompt_tokens: 0, total_tokens: 0 },
};
}
/**
* Translate OmniRoute's provider-neutral structured input into a documented
* provider-native transport. Each top-level canonical array is one logical
* multimodal item for Gemini and one vector-per-item batch for Jina.
*/
export async function prepareStructuredEmbeddingRequest(
provider: EmbeddingProvider,
model: string,
body: Record<string, unknown>,
token: string,
options: StructuredEmbeddingFetchOptions
): Promise<PreparedEmbeddingRequest> {
const items = body.input as EmbeddingMultimodalItem[];
if (provider.structuredInputProtocol === "jina-v1") {
return {
url: provider.baseUrl,
body: { ...body, model, input: await prepareJinaInput(items, options.fetchMedia) },
};
}
if (provider.structuredInputProtocol === "gemini-embed-content") {
const parts = await prepareGeminiParts(items, options.fetchMedia);
const request: Record<string, unknown> = {
content: { parts },
};
if (body.dimensions !== undefined) request.output_dimensionality = body.dimensions;
if (body.task !== undefined) request.task_type = mapGeminiTaskType(body.task);
return {
url: `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:embedContent`,
body: request,
authHeader: { name: "x-goog-api-key", value: token },
normalizeResponse: normalizeGeminiResponse,
};
}
throw new Error(`Provider ${provider.id} has no structured embedding input translator`);
}