mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 14:52:22 +03:00
Compare commits
8 Commits
fix/relay-
...
fix/omni-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9384663a4b | ||
|
|
6aa703ca8f | ||
|
|
7107cb7599 | ||
|
|
ec7f9cc0b9 | ||
|
|
2dcb4ce07d | ||
|
|
8a587a7321 | ||
|
|
11d71e959c | ||
|
|
25951c113a |
1
changelog.d/fixes/14365-omni-code-review-e2e-areas.md
Normal file
1
changelog.d/fixes/14365-omni-code-review-e2e-areas.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api,images):** `DELETE /v1/batches/delete-completed` stops re-validating the API key the scope helper already gated and logs an honest audit reason; Antigravity image requests no longer force `image_size: 1K` when the caller omitted it; the image-upscale call log no longer crashes on sanitized error objects — findings of the omni-code-review battery ([#14365](https://github.com/diegosouzapw/OmniRoute/pull/14365))
|
||||
71
open-sse/handlers/imageErrorLog.ts
Normal file
71
open-sse/handlers/imageErrorLog.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Shared call-log error stringifier for the image handlers
|
||||
* (`/v1/images/generations`, `/v1/images/upscale`).
|
||||
*
|
||||
* Both sinks take `error: unknown`, and the Codex fan-out forwards whatever
|
||||
* `sanitizeImageProviderError()` produced — i.e. the output of
|
||||
* `sanitizeUpstreamDetails()`, which builds every object with
|
||||
* `Object.create(null)` on purpose (#12506) so a hostile upstream key such as
|
||||
* `__proto__` or `constructor` can never reach a real prototype. That object
|
||||
* therefore has NO `toString`/`Symbol.toPrimitive`, so a bare `String(value)`
|
||||
* throws `TypeError: Cannot convert object to primitive value` and turns a
|
||||
* handled provider failure into an unhandled crash. The null prototype is the
|
||||
* correct behavior at the source, so the sink is what has to be total:
|
||||
* serialize objects structurally and keep `String()` semantics for everything
|
||||
* else.
|
||||
*
|
||||
* The rendered text is also passed through `redactSensitiveErrorText()` so an
|
||||
* `Error` whose message quotes an upstream `Authorization: Bearer …` header (or
|
||||
* any other recognised credential shape) never reaches the call log verbatim.
|
||||
*
|
||||
* Kept in its own module so `imageGeneration.ts` and `imageUpscale/shared.ts`
|
||||
* share ONE implementation instead of rediscovering the null-prototype contract
|
||||
* per sink (omni-code-review LEDGER-22 / LEDGER-37).
|
||||
*/
|
||||
|
||||
import { redactSensitiveErrorText } from "../utils/error.ts";
|
||||
|
||||
export function stringifyImageErrorForLog(value: unknown): string {
|
||||
return redactSensitiveErrorText(renderImageErrorForLog(value));
|
||||
}
|
||||
|
||||
function renderImageErrorForLog(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) return renderErrorInstanceForLog(value);
|
||||
if (value !== null && typeof value === "object") {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (typeof serialized === "string") return serialized;
|
||||
} catch {
|
||||
// Circular graph or a throwing toJSON — fall through to String().
|
||||
}
|
||||
}
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return "[unserializable error]";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Error` branch has to be as total as the others: `name`/`message` are ordinary
|
||||
* (re)assignable properties, so an Error can carry a null-prototype object or a throwing
|
||||
* getter in either slot, and a bare `${value.name}: ${value.message}` would throw the very
|
||||
* `TypeError` this module exists to prevent (omni-code-review LEDGER-56).
|
||||
*/
|
||||
function renderErrorInstanceForLog(error: Error): string {
|
||||
return `${readErrorPart(error, "name", "Error")}: ${readErrorPart(
|
||||
error,
|
||||
"message",
|
||||
"[unserializable message]"
|
||||
)}`;
|
||||
}
|
||||
|
||||
function readErrorPart(error: Error, key: "name" | "message", fallback: string): string {
|
||||
try {
|
||||
const part: unknown = error[key];
|
||||
return typeof part === "string" ? part : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
CHATGPT_WEB_RETIRED_MESSAGE,
|
||||
isCommonChatGptWebRetiredProviderId,
|
||||
} from "@/shared/constants/chatgptWebRetirement";
|
||||
import {
|
||||
isMicrosoftDesignerWebRetiredProviderId,
|
||||
MICROSOFT_DESIGNER_WEB_RETIRED_MESSAGE,
|
||||
} from "@/shared/constants/designerWebRetirement";
|
||||
|
||||
import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
@@ -31,17 +35,16 @@ import {
|
||||
extractComfyOutputFiles,
|
||||
resolveComfyUiBaseUrl,
|
||||
} from "../utils/comfyuiClient.ts";
|
||||
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import { fetchUntrustedRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import {
|
||||
FetchTimeoutError,
|
||||
fetchWithTimeout,
|
||||
getConfiguredTimeout,
|
||||
} from "@/shared/utils/fetchTimeout";
|
||||
import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts";
|
||||
import {
|
||||
isMicrosoftDesignerWebRetiredProviderId,
|
||||
MICROSOFT_DESIGNER_WEB_RETIRED_MESSAGE,
|
||||
} from "@/shared/constants/designerWebRetirement";
|
||||
// Shared with imageUpscale/shared.ts — see imageErrorLog.ts for why a bare
|
||||
// String(value) is unsafe here (null-prototype sanitizeUpstreamDetails() payloads, #12506).
|
||||
import { stringifyImageErrorForLog } from "./imageErrorLog.ts";
|
||||
|
||||
import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts";
|
||||
import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts";
|
||||
@@ -239,11 +242,26 @@ function normalizeImageAspectRatio(value: unknown, fallbackSize: unknown): strin
|
||||
return mapImageSize(typeof fallbackSize === "string" ? fallbackSize : null);
|
||||
}
|
||||
|
||||
function normalizeImageGenerationSize(snakeCaseValue: unknown, camelCaseValue: unknown): string {
|
||||
const value = snakeCaseValue ?? camelCaseValue;
|
||||
if (typeof value !== "string") return "1K";
|
||||
/**
|
||||
* Normalize the caller's `image_size` for Antigravity's `imageConfig.imageSize`.
|
||||
*
|
||||
* This is the output-resolution axis (`1K` | `2K` | `4K` — the values #11952 observed
|
||||
* Antigravity accepting; not a documented upstream enum), distinct from the `size`/`aspect_ratio`
|
||||
* axis handled by `normalizeImageAspectRatio`. Returns `value: undefined` when the caller sent
|
||||
* nothing usable (absent or non-string), so the key is left out and the upstream default
|
||||
* applies. A string outside that set is clamped to `1K` rather than forwarded because we have
|
||||
* not confirmed what upstream does with an unrecognised value; the clamp is reported through
|
||||
* `clamped: true` so the caller can warn and the call log can record the raw request next to
|
||||
* what was actually sent (omni-code-review LEDGER-6 / LEDGER-48 / LEDGER-57).
|
||||
*/
|
||||
function normalizeImageGenerationSize(value: unknown): {
|
||||
value: string | undefined;
|
||||
clamped: boolean;
|
||||
} {
|
||||
if (typeof value !== "string") return { value: undefined, clamped: false };
|
||||
const normalized = value.trim().toUpperCase();
|
||||
return IMAGE_SIZE_PATTERN.test(normalized) ? normalized : "1K";
|
||||
if (IMAGE_SIZE_PATTERN.test(normalized)) return { value: normalized, clamped: false };
|
||||
return { value: "1K", clamped: true };
|
||||
}
|
||||
|
||||
function parseJsonOrNull(value: string): unknown | null {
|
||||
@@ -394,6 +412,9 @@ export async function handleImageGeneration({
|
||||
clientHeaders = null,
|
||||
peerLocality = null,
|
||||
}) {
|
||||
// Retirement guards: the retired-provider sets hold bare provider ids only, so testing
|
||||
// the `<provider>/` prefix (or the whole model when it carries no slash) covers both the
|
||||
// `provider/model` and bare-id request shapes.
|
||||
const requestedModel = typeof body?.model === "string" ? body.model : "";
|
||||
const slash = requestedModel.indexOf("/");
|
||||
const requestedPrefix = slash > 0 ? requestedModel.slice(0, slash) : requestedModel;
|
||||
@@ -408,15 +429,13 @@ export async function handleImageGeneration({
|
||||
};
|
||||
}
|
||||
|
||||
const requestedProvider = slash > 0 ? requestedModel.slice(0, slash) : null;
|
||||
if (
|
||||
isCommonChatGptWebRetiredProviderId(resolvedProvider) ||
|
||||
isCommonChatGptWebRetiredProviderId(requestedProvider) ||
|
||||
isCommonChatGptWebRetiredProviderId(requestedModel)
|
||||
isCommonChatGptWebRetiredProviderId(requestedPrefix)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
status: 410,
|
||||
status: HTTP_STATUS.GONE,
|
||||
error: CHATGPT_WEB_RETIRED_MESSAGE,
|
||||
code: CHATGPT_WEB_RETIRED_ERROR_CODE,
|
||||
};
|
||||
@@ -1030,15 +1049,26 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
|
||||
typeof body.n === "number" && Number.isFinite(body.n) && body.n > 0 ? Math.floor(body.n) : 1;
|
||||
const promptText = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? "");
|
||||
const aspectRatio = normalizeImageAspectRatio(body.aspect_ratio, body.size);
|
||||
const imageSize = normalizeImageGenerationSize(body.image_size, body.imageSize);
|
||||
const { value: imageSize, clamped: imageSizeClamped } = normalizeImageGenerationSize(
|
||||
body.image_size
|
||||
);
|
||||
if (imageSizeClamped && log && typeof log.warn === "function") {
|
||||
log.warn(
|
||||
"IMAGE",
|
||||
`antigravity/${model}: unsupported image_size ${JSON.stringify(body.image_size)} — clamped to 1K (accepted: 1K|2K|4K)`
|
||||
);
|
||||
}
|
||||
|
||||
// Summarized request for call log
|
||||
// Summarized request for call log. Both axes are recorded so the log never hides what the
|
||||
// client asked for: `image_size` is the raw caller value (null when absent) and
|
||||
// `image_size_applied` is what went upstream ("default" when the key was omitted).
|
||||
const logRequestBody = {
|
||||
model: body.model,
|
||||
prompt: promptText.slice(0, 200),
|
||||
size: body.size || "default",
|
||||
aspect_ratio: aspectRatio,
|
||||
image_size: imageSize,
|
||||
image_size: body.image_size ?? null,
|
||||
image_size_applied: imageSize ?? "default",
|
||||
n: candidateCount,
|
||||
};
|
||||
|
||||
@@ -1068,7 +1098,7 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
|
||||
candidateCount,
|
||||
imageConfig: {
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
...(imageSize ? { imageSize } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1088,7 +1118,7 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden
|
||||
const promptPreview = promptText.slice(0, 60);
|
||||
log.info(
|
||||
"IMAGE",
|
||||
`antigravity/${model} (gemini) | prompt: "${promptPreview}..." | ${aspectRatio} ${imageSize}`
|
||||
`antigravity/${model} (gemini) | prompt: "${promptPreview}..." | ${aspectRatio} ${imageSize ?? "default"}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2243,11 +2273,8 @@ export async function resolveImageSource(source) {
|
||||
}
|
||||
|
||||
if (isHttpUrl(trimmed)) {
|
||||
// GHSA-34rg-3pqj-35g9 / #13883: caller-input URL — pin `public-only` (never the operator
|
||||
// outbound policy, which would let a request body reach loopback/LAN) and `pinDns: true`
|
||||
// to close the DNS-rebinding TOCTOU where a second, un-pinned resolution at connect time
|
||||
// could answer differently than the validated lookup and bypass the guard.
|
||||
const remoteImage = await fetchRemoteImage(trimmed, { guard: "public-only", pinDns: true });
|
||||
// Caller-input URL — public-only + DNS-pinned policy lives in fetchUntrustedRemoteImage.
|
||||
const remoteImage = await fetchUntrustedRemoteImage(trimmed);
|
||||
return {
|
||||
buffer: remoteImage.buffer,
|
||||
base64: remoteImage.buffer.toString("base64"),
|
||||
@@ -2782,40 +2809,6 @@ export function saveImageSuccessResult({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an arbitrary `error` value as a call-log string.
|
||||
*
|
||||
* `saveImageErrorResult` takes `error: unknown`, and the Codex fan-out forwards
|
||||
* whatever `sanitizeImageProviderError()` produced — i.e. the output of
|
||||
* `sanitizeUpstreamDetails()`, which builds every object with
|
||||
* `Object.create(null)` on purpose (#12506) so a hostile upstream key such as
|
||||
* `__proto__` or `constructor` can never reach a real prototype. That object
|
||||
* therefore has NO `toString`/`Symbol.toPrimitive`, so a bare `String(value)`
|
||||
* throws `TypeError: Cannot convert object to primitive value` and turned every
|
||||
* Codex image failure into an unhandled crash instead of the sanitized error.
|
||||
* The null prototype is the correct behavior at the source, so the sink is what
|
||||
* has to be total: serialize objects structurally (the same way the Antigravity
|
||||
* branch already logs its sanitized payload) and keep `String()` semantics for
|
||||
* everything else.
|
||||
*/
|
||||
function stringifyImageErrorForLog(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
||||
if (value !== null && typeof value === "object") {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (typeof serialized === "string") return serialized;
|
||||
} catch {
|
||||
// Circular graph or a throwing toJSON — fall through to String().
|
||||
}
|
||||
}
|
||||
try {
|
||||
return String(value);
|
||||
} catch {
|
||||
return "[unserializable error]";
|
||||
}
|
||||
}
|
||||
|
||||
export function saveImageErrorResult({
|
||||
provider,
|
||||
model,
|
||||
@@ -3276,10 +3269,9 @@ export async function normalizeNanoBananaTaskResult(taskData, body, log) {
|
||||
|
||||
if (urlCandidates.length > 0) {
|
||||
const firstUrl = urlCandidates[0];
|
||||
// GHSA-34rg-3pqj-35g9 / #13883: upstream-supplied result URL, not an OmniRoute-
|
||||
// controlled host — pin `public-only`, never the operator outbound policy, and
|
||||
// `pinDns: true` to close the DNS-rebinding TOCTOU (see `resolveImageSource`).
|
||||
const remoteImage = await fetchRemoteImage(firstUrl, { guard: "public-only", pinDns: true });
|
||||
// Upstream-supplied result URL, not an OmniRoute-controlled host — public-only +
|
||||
// DNS-pinned policy lives in fetchUntrustedRemoteImage.
|
||||
const remoteImage = await fetchUntrustedRemoteImage(firstUrl);
|
||||
const base64 = remoteImage.buffer.toString("base64");
|
||||
return [{ b64_json: base64, revised_prompt: body.prompt }];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import { fetchUntrustedRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import { stringifyImageErrorForLog } from "../imageErrorLog.ts";
|
||||
|
||||
export const UPSCALE_CALL_LOG_PATH = "/v1/images/upscale";
|
||||
|
||||
@@ -163,15 +164,9 @@ export async function resolveUpscaleImageSource(source: string): Promise<Upscale
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
// GHSA-34rg-3pqj-35g9 / #13883: `source` is caller input (14 body aliases,
|
||||
// `provider_options.*`, message parts) — pin `public-only` explicitly (string check +
|
||||
// DNS validation of every resolved answer). Never let it fall back to the operator
|
||||
// outbound policy (`block-metadata` on a local-first default install), which would let
|
||||
// a request body make the server fetch loopback/LAN URLs and upload the bytes to the
|
||||
// upscale provider. `pinDns: true` closes the DNS-rebinding TOCTOU: without it, a
|
||||
// second, un-pinned resolution at connect time could answer differently than the
|
||||
// validated lookup and bypass the public-only guard.
|
||||
const remote = await fetchRemoteImage(trimmed, { guard: "public-only", pinDns: true });
|
||||
// `source` is caller input (14 body aliases, `provider_options.*`, message parts) — the
|
||||
// public-only + DNS-pinned policy lives in `fetchUntrustedRemoteImage` (GHSA-34rg-3pqj-35g9).
|
||||
const remote = await fetchUntrustedRemoteImage(trimmed);
|
||||
assertSourceBytes(remote.buffer);
|
||||
// fetchRemoteImage falls back to application/octet-stream; sniff whenever the
|
||||
// server did not send a usable image/* type so multipart uploads stay correct.
|
||||
@@ -367,8 +362,7 @@ export function saveUpscaleErrorResult(opts: {
|
||||
model: `${opts.provider}/${opts.model}`,
|
||||
provider: opts.provider,
|
||||
duration: Date.now() - opts.startTime,
|
||||
error:
|
||||
typeof opts.error === "string" ? opts.error.slice(0, 500) : String(opts.error).slice(0, 500),
|
||||
error: stringifyImageErrorForLog(opts.error).slice(0, 500),
|
||||
requestBody: opts.requestBody ?? null,
|
||||
}).catch(() => {});
|
||||
|
||||
|
||||
@@ -5,10 +5,27 @@ import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
/**
|
||||
* Why `apiKeyId` is null — the lifecycle outcome `getApiKeyRequestScope` already
|
||||
* computed, surfaced so a route can name it in an audit line WITHOUT re-running
|
||||
* the gate (omni-code-review LEDGER-3/9):
|
||||
*
|
||||
* - `none` — no key presented (anonymous or session-only caller);
|
||||
* - `unresolved` — a key was presented but no row matches (deleted, rotated, mistyped);
|
||||
* - `invalid` — the row exists but failed `validateApiKey`
|
||||
* (is_active / revoked_at / is_banned / expires_at);
|
||||
* - `valid` — passed the gate; `apiKeyId` and `apiKeyMetadata` are set.
|
||||
*
|
||||
* `apiKeyId !== null` ⟺ `keyState === "valid"`. Additive field: every consumer
|
||||
* that only reads `apiKeyId` keeps working unchanged.
|
||||
*/
|
||||
export type ApiKeyState = "none" | "unresolved" | "invalid" | "valid";
|
||||
|
||||
export interface ApiKeyRequestScope {
|
||||
apiKey: string | null;
|
||||
apiKeyId: string | null;
|
||||
apiKeyMetadata: Awaited<ReturnType<typeof getApiKeyMetadata>>;
|
||||
keyState: ApiKeyState;
|
||||
rejection: Response | null;
|
||||
isSessionAuth: boolean;
|
||||
}
|
||||
@@ -17,7 +34,14 @@ export async function getApiKeyRequestScope(request: Request): Promise<ApiKeyReq
|
||||
const isSessionAuth = await isDashboardSessionAuthenticated(request);
|
||||
const apiKey = extractApiKey(request);
|
||||
if (!apiKey) {
|
||||
return { apiKey: null, apiKeyId: null, apiKeyMetadata: null, rejection: null, isSessionAuth };
|
||||
return {
|
||||
apiKey: null,
|
||||
apiKeyId: null,
|
||||
apiKeyMetadata: null,
|
||||
keyState: "none",
|
||||
rejection: null,
|
||||
isSessionAuth,
|
||||
};
|
||||
}
|
||||
|
||||
const apiKeyMetadata = await getApiKeyMetadata(apiKey);
|
||||
@@ -27,13 +51,26 @@ export async function getApiKeyRequestScope(request: Request): Promise<ApiKeyReq
|
||||
// checks is_active/revoked_at/is_banned/expires_at (CWE-613). A key that
|
||||
// fails that gate is folded into the same `{ apiKeyId: null }` shape as an
|
||||
// unresolved/anonymous caller, so every consumer of this scope (list reads,
|
||||
// per-record ownership checks) treats a revoked/expired/banned key as
|
||||
// invalid without each route re-implementing the check.
|
||||
const isValid = apiKeyMetadata ? await validateApiKey(apiKey) : false;
|
||||
// per-record ownership checks, the delete-completed sweep) treats a
|
||||
// revoked/expired/banned key as invalid without each route re-implementing
|
||||
// the check — this is the single lifecycle gate; routes must not re-run it.
|
||||
let keyState: ApiKeyState = "unresolved";
|
||||
if (apiKeyMetadata) keyState = (await validateApiKey(apiKey)) ? "valid" : "invalid";
|
||||
if (keyState !== "valid") {
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyId: null,
|
||||
apiKeyMetadata: null,
|
||||
keyState,
|
||||
rejection: null,
|
||||
isSessionAuth,
|
||||
};
|
||||
}
|
||||
return {
|
||||
apiKey,
|
||||
apiKeyId: isValid ? apiKeyMetadata?.id || null : null,
|
||||
apiKeyMetadata: isValid ? apiKeyMetadata : null,
|
||||
apiKeyId: apiKeyMetadata.id,
|
||||
apiKeyMetadata,
|
||||
keyState,
|
||||
rejection: null,
|
||||
isSessionAuth,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
|
||||
import { deleteCompletedBatches, type DeleteCompletedBatchesScope } from "@/lib/db/batches";
|
||||
import { validateApiKey } from "@/lib/db/apiKeys";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyRequestScope } from "@/app/api/v1/_helpers/apiKeyScope";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
@@ -18,20 +17,21 @@ export async function DELETE(request: Request) {
|
||||
if (scope.rejection) return scope.rejection;
|
||||
|
||||
// Fail closed on an unresolvable OR invalid credential. `getApiKeyRequestScope`
|
||||
// resolves the key by row EXISTENCE (so the list/count siblings can still
|
||||
// attribute reads); existence is not authorization for a destructive sweep:
|
||||
// a revoked, deactivated, banned or expired key still has a row and would
|
||||
// otherwise run the sweep (CWE-613). `validateApiKey` is the one lifecycle
|
||||
// gate (is_active, revoked_at, is_banned, expires_at) — and neither case may
|
||||
// fall through to the session branch and widen the sweep to the whole instance.
|
||||
if (scope.apiKey && (!scope.apiKeyId || !(await validateApiKey(scope.apiKey)))) {
|
||||
// is the single lifecycle gate: it runs `validateApiKey` (is_active,
|
||||
// revoked_at, is_banned, expires_at — CWE-613) itself and folds a key that
|
||||
// fails it into `apiKeyId: null`, so a presented key with no id is either
|
||||
// unknown (`keyState: "unresolved"`) or revoked/deactivated/banned/expired
|
||||
// (`keyState: "invalid"`). Nothing is re-validated here — `apiKeyId !== null`
|
||||
// already means the key passed that gate (#13881) — and neither case may fall
|
||||
// through to the session branch and widen the sweep to the whole instance.
|
||||
if (scope.apiKey && !scope.apiKeyId) {
|
||||
// `info`, not `warn`: any caller can reach this branch by presenting any
|
||||
// string as a key, so a warn-level line per attempt is a log-flooding lever
|
||||
// (LEDGER-12). The 401 itself is the audit signal; the real sweeps below
|
||||
// keep their warn-level audit lines.
|
||||
// (omni-code-sec 2026-09-14 proof run, LEDGER-12). The 401 itself is the
|
||||
// audit signal; the real sweeps below keep their warn-level audit lines.
|
||||
log.info("BATCHES", "delete-completed: presented API key rejected", {
|
||||
route: LOG_ROUTE,
|
||||
reason: scope.apiKeyId ? "invalid" : "unresolved",
|
||||
reason: scope.keyState,
|
||||
apiKeyId: scope.apiKeyId,
|
||||
isSessionAuth: scope.isSessionAuth,
|
||||
});
|
||||
@@ -42,11 +42,12 @@ export async function DELETE(request: Request) {
|
||||
}
|
||||
|
||||
// The per-key operator policy every other `/v1` route applies (endpoint
|
||||
// allowlist, access schedule, usage cap, rate limit — LEDGER-9/13/16). Runs
|
||||
// after the lifecycle gate above (the enforcer's own status check does not
|
||||
// look at `revoked_at`) and before the sweep scope is chosen, so a restricted
|
||||
// key is refused with the enforcer's own rejection and nothing is swept. A
|
||||
// session-only caller carries no key and passes through untouched.
|
||||
// allowlist, access schedule, usage cap, rate limit — omni-code-sec
|
||||
// 2026-09-14 proof run, LEDGER-9/13/16). Runs after the lifecycle gate above
|
||||
// (the enforcer's own status check does not look at `revoked_at`) and before
|
||||
// the sweep scope is chosen, so a restricted key is refused with the
|
||||
// enforcer's own rejection and nothing is swept. A session-only caller
|
||||
// carries no key and passes through untouched.
|
||||
const policy = await enforceApiKeyPolicy(request, null);
|
||||
if (policy.rejection) return policy.rejection;
|
||||
|
||||
|
||||
@@ -227,3 +227,29 @@ export async function fetchRemoteImage(
|
||||
): Promise<RemoteImageFetchResult> {
|
||||
return fetchRemoteMedia(input, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an image from a URL that did NOT originate from an OmniRoute-controlled host: a
|
||||
* caller-supplied `image_url` / `image` body field (any of the request-body aliases,
|
||||
* `provider_options.*`, message parts) or a result URL echoed back by an upstream provider.
|
||||
*
|
||||
* GHSA-34rg-3pqj-35g9 / #13883: the SSRF-hardened policy is hard-coded here so it is no longer
|
||||
* an opt-in every call site has to remember (omni-code-review LEDGER-32 / LEDGER-50):
|
||||
*
|
||||
* - `guard: "public-only"` — never the operator outbound policy (`block-metadata` on a
|
||||
* local-first default install), which would let a request body make the server fetch
|
||||
* loopback/LAN URLs and forward the bytes to an image provider. Every resolved DNS answer
|
||||
* is validated, not just the hostname string.
|
||||
* - `pinDns: true` — closes the DNS-rebinding TOCTOU: without it, a second, un-pinned
|
||||
* resolution at connect time could answer differently than the validated lookup and bypass
|
||||
* the public-only guard.
|
||||
*
|
||||
* Any `guard` / `pinDns` the caller passes is overridden; the remaining options (`maxBytes`,
|
||||
* `timeoutMs`, `signal`, test seams such as `lookup` / `fetchImpl`) pass through unchanged.
|
||||
*/
|
||||
export async function fetchUntrustedRemoteImage(
|
||||
input: string | URL,
|
||||
opts: RemoteImageFetchOptions = {}
|
||||
): Promise<RemoteImageFetchResult> {
|
||||
return fetchRemoteImage(input, { ...opts, guard: "public-only", pinDns: true });
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ import { createBatch, getBatch, deleteCompletedBatches } from "@/lib/db/batches"
|
||||
const KEY_A = "key-wvxc-aaaa";
|
||||
const KEY_B = "key-wvxc-bbbb";
|
||||
|
||||
// Source-text guard for the LEDGER-3/9 invariant. Comments are stripped BEFORE matching
|
||||
// (LEDGER-51/61): the route's own comment says "it runs `validateApiKey` (is_active, …" and
|
||||
// a reflow that drops the backticks — or a `@see validateApiKey(key)` — is prose, not a
|
||||
// second lookup. The assertion is about code.
|
||||
const STRIP_COMMENTS_RE = /\/\*[\s\S]*?\*\/|\/\/.*$/gm;
|
||||
const VALIDATE_API_KEY_CALL_RE = /import\s*\{[^}]*\bvalidateApiKey\b|\bvalidateApiKey\s*\(/;
|
||||
|
||||
function routeReRunsValidateApiKey(src: string): boolean {
|
||||
return VALIDATE_API_KEY_CALL_RE.test(src.replace(STRIP_COMMENTS_RE, ""));
|
||||
}
|
||||
|
||||
function seedCompletedBatch(apiKeyId: string | null, tag: string) {
|
||||
// The file carries the batch's owner, as an upload through that key does in production.
|
||||
// #13374 (SEC-C) scopes the file half of a key sweep to files the caller owns, so an
|
||||
@@ -119,4 +130,51 @@ describe("the route passes the caller's key through", () => {
|
||||
"the route must pass the caller's scope into the helper"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not re-run validateApiKey: getApiKeyRequestScope is the single lifecycle gate and the audit reason is its keyState (omni-code-review LEDGER-3/9)", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const src = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL("../../src/app/api/v1/batches/delete-completed/route.ts", import.meta.url)
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
assert.ok(
|
||||
!routeReRunsValidateApiKey(src),
|
||||
"the route re-checks a lifecycle the helper already folded into apiKeyId: null — a redundant second lookup on every keyed request"
|
||||
);
|
||||
assert.ok(
|
||||
/reason:\s*scope\.keyState/.test(src),
|
||||
"the audit reason must come from the helper's keyState, not be re-derived from apiKeyId"
|
||||
);
|
||||
});
|
||||
|
||||
it("the validateApiKey guard ignores prose: a comment mentioning `validateApiKey (` must not trip it (LEDGER-51/61)", () => {
|
||||
const proseOnly = [
|
||||
"// getApiKeyRequestScope is the single gate: it runs validateApiKey (is_active,",
|
||||
"// revoked_at, is_banned, expires_at) itself and folds failures into apiKeyId: null.",
|
||||
"/** @see validateApiKey(key) for the lifecycle checks this route relies on. */",
|
||||
"const scope = await getApiKeyRequestScope(request);",
|
||||
"logger.info({ reason: scope.keyState });",
|
||||
].join("\n");
|
||||
assert.equal(
|
||||
routeReRunsValidateApiKey(proseOnly),
|
||||
false,
|
||||
"a comment reflow must not be reported as a redundant second lookup"
|
||||
);
|
||||
});
|
||||
|
||||
it("the validateApiKey guard still catches a real re-run in code (mutation check)", () => {
|
||||
const importAgain = [
|
||||
'import { validateApiKey } from "@/lib/db/apiKeys";',
|
||||
"const scope = await getApiKeyRequestScope(request);",
|
||||
].join("\n");
|
||||
const callAgain = [
|
||||
"const scope = await getApiKeyRequestScope(request);",
|
||||
"const record = validateApiKey(scope.apiKey);",
|
||||
].join("\n");
|
||||
assert.equal(routeReRunsValidateApiKey(importAgain), true, "an import must still trip it");
|
||||
assert.equal(routeReRunsValidateApiKey(callAgain), true, "a call must still trip it");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
* App Router's own `/api/v1/…`) (omni-code-sec LEDGER-9/13/16);
|
||||
* - no credentials at all → 401;
|
||||
* - a sweep that throws → sanitized 500 (no stack trace, no raw SQLite message)
|
||||
* and nothing deleted (the sweep is atomic).
|
||||
* and nothing deleted (the sweep is atomic);
|
||||
* - the rejection audit line carries an HONEST `reason`: `getApiKeyRequestScope`
|
||||
* is the single lifecycle gate and surfaces `keyState`, so a revoked key logs
|
||||
* `invalid` and an unknown key logs `unresolved` — the route never re-runs
|
||||
* `validateApiKey` to re-derive it (omni-code-review LEDGER-3/9).
|
||||
*
|
||||
* Self-isolating: DATA_DIR points at a fresh temp dir BEFORE any `@/lib/db/*`
|
||||
* module loads (dynamic imports below), so this file never touches ~/.omniroute.
|
||||
@@ -38,11 +42,15 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { SignJWT } from "jose";
|
||||
import pino from "pino";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "wvxc-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "wvxc-route-api-secret";
|
||||
process.env.JWT_SECRET = "wvxc-route-jwt-secret";
|
||||
// The audit-reason case below reads the route's info-level line off the shared pino
|
||||
// stream; pin the level so a CI-wide APP_LOG_LEVEL=warn cannot silently drop it.
|
||||
process.env.APP_LOG_LEVEL = "info";
|
||||
|
||||
const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
const { createApiKey, revokeApiKey, updateApiKeyPermissions, setApiKeyExpiry } =
|
||||
@@ -50,8 +58,11 @@ const { createApiKey, revokeApiKey, updateApiKeyPermissions, setApiKeyExpiry } =
|
||||
const { createFile, getFile, getFileContent } = await import("../../src/lib/db/files.ts");
|
||||
const { createBatch, getBatch } = await import("../../src/lib/db/batches.ts");
|
||||
const { DELETE } = await import("../../src/app/api/v1/batches/delete-completed/route.ts");
|
||||
const { getApiKeyRequestScope } = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts");
|
||||
const { logger: rootLogger } = await import("../../src/shared/utils/logger.ts");
|
||||
|
||||
const ROUTE_URL = "http://localhost/api/v1/batches/delete-completed";
|
||||
const LOG_ROUTE = "batches/delete-completed";
|
||||
|
||||
async function sessionCookie(): Promise<string> {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
@@ -99,6 +110,47 @@ async function callDelete(headers: Record<string, string>, url: string = ROUTE_U
|
||||
return { res, body };
|
||||
}
|
||||
|
||||
const REJECTION_AUDIT_MSG = "delete-completed: presented API key rejected";
|
||||
|
||||
/**
|
||||
* Run `fn` while tapping the ROOT pino stream and return the parsed
|
||||
* `presented API key rejected` audit line it emitted. The route logs through the
|
||||
* `sse` child (`createLogger("sse")`, a module-private `Object.create(root)`), and a
|
||||
* pino child resolves `streamSym` through its prototype chain, so shadowing `write`
|
||||
* on the root's stream object sees every child line — no ESM namespace mocking.
|
||||
* pino serializes to a JSON line BEFORE handing it to the stream, so the tap sees
|
||||
* the structured fields, not pino-pretty output.
|
||||
*/
|
||||
async function captureRejectionAudit<T>(
|
||||
fn: () => Promise<T>
|
||||
): Promise<{ result: T; audit: Record<string, unknown> | null }> {
|
||||
const stream = (rootLogger as unknown as Record<symbol, { write: (line: string) => boolean }>)[
|
||||
pino.symbols.streamSym
|
||||
];
|
||||
const originalWrite = stream.write;
|
||||
const lines: string[] = [];
|
||||
stream.write = (line: string) => {
|
||||
lines.push(line);
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
const result = await fn();
|
||||
const audit =
|
||||
lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.find((entry) => entry?.msg === REJECTION_AUDIT_MSG) ?? null;
|
||||
return { result, audit };
|
||||
} finally {
|
||||
stream.write = originalWrite;
|
||||
}
|
||||
}
|
||||
|
||||
describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp3v-5mg5)", () => {
|
||||
after(() => {
|
||||
resetDbInstance();
|
||||
@@ -288,6 +340,65 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp
|
||||
}
|
||||
});
|
||||
|
||||
it("getApiKeyRequestScope surfaces `keyState` (valid / invalid / unresolved / none) so the route derives its audit reason instead of re-validating (omni-code-review LEDGER-3/9)", async () => {
|
||||
const live = await createApiKey("wvxc-route-state-live", "machine-wvxc-sl", []);
|
||||
const revoked = await createApiKey("wvxc-route-state-revoked", "machine-wvxc-sr", []);
|
||||
assert.strictEqual(await revokeApiKey(revoked.id), true);
|
||||
const scopeOf = (headers: Record<string, string>) =>
|
||||
getApiKeyRequestScope(new Request(ROUTE_URL, { method: "DELETE", headers }));
|
||||
|
||||
const liveScope = await scopeOf({ Authorization: `Bearer ${live.key}` });
|
||||
assert.strictEqual(liveScope.keyState, "valid");
|
||||
assert.strictEqual(liveScope.apiKeyId, live.id, "a valid key still resolves its id");
|
||||
|
||||
const revokedScope = await scopeOf({ Authorization: `Bearer ${revoked.key}` });
|
||||
assert.strictEqual(
|
||||
revokedScope.keyState,
|
||||
"invalid",
|
||||
"row exists but failed the lifecycle gate"
|
||||
);
|
||||
assert.strictEqual(revokedScope.apiKeyId, null, "the #13881 fold-to-null contract is kept");
|
||||
assert.strictEqual(revokedScope.apiKeyMetadata, null);
|
||||
|
||||
const unknownScope = await scopeOf({ Authorization: "Bearer sk-omni-never-issued-wvxc-state" });
|
||||
assert.strictEqual(unknownScope.keyState, "unresolved", "no row at all");
|
||||
assert.strictEqual(unknownScope.apiKeyId, null);
|
||||
|
||||
const anonymousScope = await scopeOf({});
|
||||
assert.strictEqual(anonymousScope.keyState, "none", "no key presented");
|
||||
assert.strictEqual(anonymousScope.apiKey, null);
|
||||
assert.strictEqual(anonymousScope.rejection, null, "the helper still never sets rejection");
|
||||
});
|
||||
|
||||
it("the rejection audit line is honest: a REVOKED key logs reason 'invalid', an unknown key logs 'unresolved' (omni-code-review LEDGER-3/9)", async () => {
|
||||
const keyA = await createApiKey("wvxc-route-reason-a", "machine-wvxc-rsn", []);
|
||||
assert.strictEqual(await revokeApiKey(keyA.id), true);
|
||||
|
||||
const revoked = await captureRejectionAudit(() =>
|
||||
callDelete({ Authorization: `Bearer ${keyA.key}` })
|
||||
);
|
||||
assert.strictEqual(revoked.result.res.status, 401, "the revoked key is still refused");
|
||||
assert.ok(revoked.audit, "a rejected key must emit the audit line");
|
||||
assert.strictEqual(
|
||||
revoked.audit?.reason,
|
||||
"invalid",
|
||||
"a key whose row exists but failed the lifecycle gate must not be logged as 'unresolved'"
|
||||
);
|
||||
assert.strictEqual(revoked.audit?.route, LOG_ROUTE);
|
||||
assert.strictEqual(
|
||||
revoked.audit?.apiKeyId,
|
||||
null,
|
||||
"the fold-to-null contract shows in the audit"
|
||||
);
|
||||
|
||||
const unknown = await captureRejectionAudit(() =>
|
||||
callDelete({ Authorization: "Bearer sk-omni-never-issued-wvxc-reason" })
|
||||
);
|
||||
assert.strictEqual(unknown.result.res.status, 401, "the unknown key is still refused");
|
||||
assert.ok(unknown.audit, "an unresolvable key must emit the audit line");
|
||||
assert.strictEqual(unknown.audit?.reason, "unresolved", "no row at all → 'unresolved'");
|
||||
});
|
||||
|
||||
it("rejects an unauthenticated request with 401 and deletes nothing", async () => {
|
||||
const keyB = await createApiKey("wvxc-route-401-b", "machine-wvxc-401", []);
|
||||
// short label: see the seedCompletedBatch docblock (#13729)
|
||||
|
||||
242
tests/unit/image-generation-size-and-payload-guard.test.ts
Normal file
242
tests/unit/image-generation-size-and-payload-guard.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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-images-"));
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { handleImageGeneration, handleOpenAIImageEdit } =
|
||||
await import("../../open-sse/handlers/imageGeneration.ts");
|
||||
const { getCallLogs, getCallLogById, waitForCallLogSaves } =
|
||||
await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
});
|
||||
|
||||
// omni-code-review 2026-09-21_release-v3.8.51_vs_main_e2e-areas LEDGER-12 — `hasUsableImage`
|
||||
// (fetchImageEndpoint): a 2xx whose items carry no usable `b64_json`/`url` must surface as a
|
||||
// retryable 502 so image combos fall back, on both the generation and the edit path.
|
||||
function mockOpenAICompatibleUpstream(payload: unknown) {
|
||||
return async () =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const usableImageRequest = {
|
||||
body: { model: "custom-provider/super-image", prompt: "retro poster" },
|
||||
credentials: {
|
||||
apiKey: "custom-key",
|
||||
baseUrl: "https://custom.example.com/v1/images/generations",
|
||||
},
|
||||
resolvedProvider: "custom-provider",
|
||||
log: null,
|
||||
};
|
||||
|
||||
test("handleImageGeneration treats a 200 whose only item has a blank url as a retryable 502", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockOpenAICompatibleUpstream({ data: [{ url: "" }] });
|
||||
try {
|
||||
const result = await handleImageGeneration(usableImageRequest);
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502);
|
||||
assert.match(String(result.error), /without an image payload/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleImageGeneration treats a 200 whose only item is not an object as a retryable 502", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockOpenAICompatibleUpstream({ data: ["https://cdn.example.com/x.png"] });
|
||||
try {
|
||||
const result = await handleImageGeneration(usableImageRequest);
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502);
|
||||
// LEDGER-66 (same run): pin the message so this case proves `isJsonObject(item)` rejected
|
||||
// the item rather than any 502 the outer catch would also produce.
|
||||
assert.match(String(result.error), /without an image payload/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleImageGeneration keeps a well-formed 200 image payload as success", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = mockOpenAICompatibleUpstream({
|
||||
created: 123,
|
||||
data: [{ url: "" }, { b64_json: "ZmFrZQ==" }],
|
||||
});
|
||||
try {
|
||||
const result = await handleImageGeneration(usableImageRequest);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.status, undefined);
|
||||
assert.deepEqual(result.data, { created: 123, data: [{ url: "" }, { b64_json: "ZmFrZQ==" }] });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleOpenAIImageEdit applies the same usable-image gate to the edit path", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const editRequest = {
|
||||
model: "super-image",
|
||||
provider: "custom-provider",
|
||||
credentials: {
|
||||
apiKey: "custom-key",
|
||||
providerSpecificData: { baseUrl: "https://custom.example.com/v1" },
|
||||
},
|
||||
prompt: "make it blue",
|
||||
imageBytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
imageMime: "image/png",
|
||||
log: null,
|
||||
};
|
||||
try {
|
||||
globalThis.fetch = mockOpenAICompatibleUpstream({ data: [{ b64_json: "" }] });
|
||||
const blank = await handleOpenAIImageEdit(editRequest);
|
||||
assert.equal(blank.success, false);
|
||||
assert.equal(blank.status, 502);
|
||||
assert.match(String(blank.error), /without an image payload/);
|
||||
|
||||
globalThis.fetch = mockOpenAICompatibleUpstream({ data: [{ b64_json: "ZmFrZQ==" }] });
|
||||
const ok = await handleOpenAIImageEdit(editRequest);
|
||||
assert.equal(ok.success, true);
|
||||
assert.deepEqual(ok.data.data, [{ b64_json: "ZmFrZQ==" }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// omni-code-review 2026-09-21_release-v3.8.51_vs_main_e2e-areas LEDGER-6 (+ round-2
|
||||
// LEDGER-47/48/54/58/59/60) — `image_size` is forwarded to Antigravity's
|
||||
// `imageConfig.imageSize` only when the caller supplied it; an unrecognised string is clamped
|
||||
// to "1K" WITH a warn line, everything else leaves the key out so the upstream default
|
||||
// applies. The persisted call-log request summary must record BOTH what the caller sent
|
||||
// (`image_size`, raw or null) and what went upstream (`image_size_applied`, or "default").
|
||||
const ANTIGRAVITY_IMAGE_MODEL = "antigravity/gemini-3.1-flash-image-preview";
|
||||
let antigravityPromptSeq = 0;
|
||||
|
||||
async function captureAntigravityImageRequest(extraBody: Record<string, unknown>) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const prompt = `painted beach #${++antigravityPromptSeq}`;
|
||||
const warnings: string[] = [];
|
||||
const log = {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: (_scope: string, message: string) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
};
|
||||
let captured;
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
captured = JSON.parse(String(options.body || "{}"));
|
||||
// LEDGER-47: a REAL success payload, so these cases fail when the request/response wiring
|
||||
// breaks rather than only when the envelope changes.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
response: {
|
||||
candidates: [{ content: { parts: [{ inlineData: { data: "ZmFrZQ==" } }] } }],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
try {
|
||||
const result = await handleImageGeneration({
|
||||
body: { model: ANTIGRAVITY_IMAGE_MODEL, prompt, aspect_ratio: "3:4", ...extraBody },
|
||||
credentials: { accessToken: "ag-token", projectId: "project-123" },
|
||||
log,
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.data.length, 1);
|
||||
assert.equal(result.data.data[0].b64_json, "ZmFrZQ==");
|
||||
|
||||
assert.ok(await waitForCallLogSaves(60_000), "call-log save did not settle");
|
||||
// The persisted `model` carries the resolved alias (`…-flash-image`), so match on provider
|
||||
// + the unique prompt rather than on the requested model string. `getCallLogs` only ever
|
||||
// returns the list-level SUMMARY row — `requestSummary`, populated exclusively for
|
||||
// `requestType: "search"` rows (see `buildRequestSummary`) — never the full `requestBody`
|
||||
// this handler persists via `saveCallLog`. That body lives solely in the per-row artifact
|
||||
// file, reachable only through `getCallLogById`, so each candidate row must be re-read
|
||||
// through it before its `requestBody.prompt` can be compared.
|
||||
const rows = await getCallLogs({ provider: "antigravity", limit: 50 });
|
||||
let row: Awaited<ReturnType<typeof getCallLogById>> | null = null;
|
||||
for (const candidate of rows) {
|
||||
const detail = await getCallLogById(candidate.id);
|
||||
const candidateBody = detail?.requestBody as Record<string, unknown> | null | undefined;
|
||||
if (candidateBody && candidateBody.prompt === prompt) {
|
||||
row = detail;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(row, `no call log persisted for prompt ${prompt}`);
|
||||
return {
|
||||
imageConfig: captured.request.generationConfig.imageConfig,
|
||||
requestBody: row.requestBody as Record<string, unknown>,
|
||||
warnings,
|
||||
};
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
async function captureAntigravityImageConfig(extraBody: Record<string, unknown>) {
|
||||
return (await captureAntigravityImageRequest(extraBody)).imageConfig;
|
||||
}
|
||||
|
||||
test("handleImageGeneration omits Antigravity imageSize when image_size is not supplied", async () => {
|
||||
const { imageConfig, requestBody, warnings } = await captureAntigravityImageRequest({});
|
||||
assert.deepEqual(imageConfig, { aspectRatio: "3:4" });
|
||||
// LEDGER-54/58: the persisted summary says explicitly that nothing was sent and the
|
||||
// upstream default applied — the key must not silently vanish from the stored JSON.
|
||||
assert.equal(requestBody.image_size, null);
|
||||
assert.equal(requestBody.image_size_applied, "default");
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("handleImageGeneration forwards a valid Antigravity image_size normalized to upper case", async () => {
|
||||
const twoK = await captureAntigravityImageRequest({ image_size: "2K" });
|
||||
assert.deepEqual(twoK.imageConfig, { aspectRatio: "3:4", imageSize: "2K" });
|
||||
assert.equal(twoK.requestBody.image_size, "2K");
|
||||
assert.equal(twoK.requestBody.image_size_applied, "2K");
|
||||
assert.deepEqual(twoK.warnings, []);
|
||||
|
||||
assert.deepEqual(await captureAntigravityImageConfig({ image_size: " 4k " }), {
|
||||
aspectRatio: "3:4",
|
||||
imageSize: "4K",
|
||||
});
|
||||
});
|
||||
|
||||
test("handleImageGeneration clamps an unrecognised Antigravity image_size string to 1K and says so", async () => {
|
||||
const { imageConfig, requestBody, warnings } = await captureAntigravityImageRequest({
|
||||
image_size: "1024x1024",
|
||||
});
|
||||
assert.deepEqual(imageConfig, { aspectRatio: "3:4", imageSize: "1K" });
|
||||
// LEDGER-48: the downgrade is greppable — exactly one warn line naming the raw value.
|
||||
assert.equal(warnings.length, 1, `expected one warn line, got ${JSON.stringify(warnings)}`);
|
||||
assert.match(warnings[0], /unsupported image_size "1024x1024"/);
|
||||
assert.match(warnings[0], /clamped to 1K/);
|
||||
// LEDGER-54/59/60: the call log keeps the caller's raw value next to what went upstream.
|
||||
assert.equal(requestBody.image_size, "1024x1024");
|
||||
assert.equal(requestBody.image_size_applied, "1K");
|
||||
});
|
||||
|
||||
test("handleImageGeneration omits Antigravity imageSize for a non-string image_size", async () => {
|
||||
const { imageConfig, requestBody, warnings } = await captureAntigravityImageRequest({
|
||||
image_size: 2,
|
||||
});
|
||||
assert.deepEqual(imageConfig, { aspectRatio: "3:4" });
|
||||
assert.equal(requestBody.image_size, 2);
|
||||
assert.equal(requestBody.image_size_applied, "default");
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("handleImageGeneration ignores the camelCase imageSize alias on Antigravity requests", async () => {
|
||||
assert.deepEqual(await captureAntigravityImageConfig({ imageSize: "4K" }), {
|
||||
aspectRatio: "3:4",
|
||||
});
|
||||
});
|
||||
200
tests/unit/image-upscale-error-log.test.ts
Normal file
200
tests/unit/image-upscale-error-log.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* LEDGER-22 / LEDGER-37 (omni-code-review, release/v3.8.51 vs main):
|
||||
* the `/v1/images/upscale` call-log sink must render `error: unknown` through the
|
||||
* same total, credential-redacting stringifier the generation sink uses — never a
|
||||
* bare `String(opts.error)`.
|
||||
*
|
||||
* - `String(Object.create(null))` throws `TypeError: Cannot convert object to
|
||||
* primitive value`, so a provider forwarding a `sanitizeUpstreamDetails()`
|
||||
* payload (null-prototype on purpose, #12506) turned a handled failure into an
|
||||
* unhandled crash.
|
||||
* - An `Error` whose message carries an Authorization header value must reach the
|
||||
* log with the credential masked.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-upscale-errlog-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { stringifyImageErrorForLog } = await import("../../open-sse/handlers/imageErrorLog.ts");
|
||||
const { saveUpscaleErrorResult } = await import("../../open-sse/handlers/imageUpscale/shared.ts");
|
||||
const { getCallLogs, waitForCallLogSaves } = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
const SECRET = "sk-live-ZmFrZXNlY3JldDEyMzQ1Njc4OTBhYmNkZWY";
|
||||
const AUTH_MESSAGE = `upstream rejected header Authorization: Bearer ${SECRET}`;
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
// ── imageGeneration.ts must not carry its own copy (LEDGER-22 follow-up) ──
|
||||
|
||||
test("imageGeneration.ts imports the shared stringifyImageErrorForLog instead of redefining it", () => {
|
||||
const handlerPath = fileURLToPath(
|
||||
new URL("../../open-sse/handlers/imageGeneration.ts", import.meta.url)
|
||||
);
|
||||
const source = fs.readFileSync(handlerPath, "utf8");
|
||||
assert.ok(
|
||||
!/^function stringifyImageErrorForLog\(/m.test(source),
|
||||
"imageGeneration.ts must not redefine stringifyImageErrorForLog — import it from ./imageErrorLog"
|
||||
);
|
||||
assert.ok(
|
||||
/import\s*\{[^}]*\bstringifyImageErrorForLog\b[^}]*\}\s*from\s*["']\.\/imageErrorLog(?:\.ts)?["']/.test(
|
||||
source
|
||||
),
|
||||
"imageGeneration.ts must import stringifyImageErrorForLog from ./imageErrorLog"
|
||||
);
|
||||
});
|
||||
|
||||
// ── stringifyImageErrorForLog ──────────────────────────────────────────────
|
||||
|
||||
test("stringifyImageErrorForLog masks an Authorization header value inside an Error", () => {
|
||||
const rendered = stringifyImageErrorForLog(new Error(AUTH_MESSAGE));
|
||||
assert.equal(typeof rendered, "string");
|
||||
assert.ok(rendered.startsWith("Error:"), `expected "Error: …" prefix, got ${rendered}`);
|
||||
assert.ok(!rendered.includes(SECRET), `credential leaked into log string: ${rendered}`);
|
||||
assert.ok(rendered.includes("[REDACTED]"), `expected a redaction marker, got ${rendered}`);
|
||||
});
|
||||
|
||||
test("stringifyImageErrorForLog masks an Authorization header value inside a plain string", () => {
|
||||
const rendered = stringifyImageErrorForLog(AUTH_MESSAGE);
|
||||
assert.ok(!rendered.includes(SECRET), `credential leaked into log string: ${rendered}`);
|
||||
assert.ok(rendered.includes("[REDACTED]"));
|
||||
});
|
||||
|
||||
test("stringifyImageErrorForLog serializes a null-prototype object instead of throwing", () => {
|
||||
const payload = Object.create(null) as Record<string, unknown>;
|
||||
payload.code = "upstream_error";
|
||||
payload.message = "provider rejected the upscale";
|
||||
assert.throws(() => String(payload), TypeError, "precondition: String() must throw here");
|
||||
|
||||
const rendered = stringifyImageErrorForLog(payload);
|
||||
assert.equal(
|
||||
rendered,
|
||||
JSON.stringify({ code: "upstream_error", message: "provider rejected the upscale" })
|
||||
);
|
||||
});
|
||||
|
||||
// omni-code-review 2026-09-21_release-v3.8.51_vs_main_e2e-areas LEDGER-49: the object branch is
|
||||
// the one `sanitizeUpstreamDetails()` payloads and raw upstream JSON take, so its redaction
|
||||
// must be pinned on a credential-bearing key — not only on the string/Error branches.
|
||||
test("stringifyImageErrorForLog redacts a credential inside a null-prototype object payload", () => {
|
||||
const payload = Object.create(null) as Record<string, unknown>;
|
||||
payload.code = "upstream_error";
|
||||
payload.message = "provider rejected the upscale";
|
||||
payload.authorization = `Bearer ${SECRET}`;
|
||||
|
||||
const rendered = stringifyImageErrorForLog(payload);
|
||||
assert.ok(!rendered.includes(SECRET), `credential leaked into log string: ${rendered}`);
|
||||
assert.ok(rendered.includes("[REDACTED]"), `expected a redaction marker, got ${rendered}`);
|
||||
assert.ok(rendered.includes('"code":"upstream_error"'), `structure lost: ${rendered}`);
|
||||
assert.ok(
|
||||
rendered.includes('"message":"provider rejected the upscale"'),
|
||||
`structure lost: ${rendered}`
|
||||
);
|
||||
});
|
||||
|
||||
// omni-code-review 2026-09-21_release-v3.8.51_vs_main_e2e-areas LEDGER-56: the Error branch
|
||||
// must be as total as the other three — a throwing or non-string `message`/`name` must never
|
||||
// turn a handled provider failure back into an unhandled throw.
|
||||
test("stringifyImageErrorForLog does not throw on an Error whose message getter throws", () => {
|
||||
const hostile = new Error("placeholder");
|
||||
Object.defineProperty(hostile, "message", {
|
||||
get() {
|
||||
throw new TypeError("message getter exploded");
|
||||
},
|
||||
});
|
||||
assert.throws(() => `${hostile.message}`, TypeError, "precondition: reading message throws");
|
||||
|
||||
let rendered: string | undefined;
|
||||
assert.doesNotThrow(() => {
|
||||
rendered = stringifyImageErrorForLog(hostile);
|
||||
});
|
||||
assert.equal(typeof rendered, "string");
|
||||
assert.ok(rendered!.startsWith("Error:"), `expected "Error: …" prefix, got ${rendered}`);
|
||||
assert.ok(rendered!.length > "Error:".length, "message part must not be empty");
|
||||
});
|
||||
|
||||
test("stringifyImageErrorForLog renders an Error carrying non-string name/message without throwing", () => {
|
||||
const hostile = new Error("placeholder");
|
||||
const nullProto = Object.create(null) as Record<string, unknown>;
|
||||
(hostile as { name: unknown }).name = nullProto;
|
||||
(hostile as { message: unknown }).message = nullProto;
|
||||
assert.throws(() => `${hostile.name}: ${hostile.message}`, TypeError, "precondition");
|
||||
|
||||
const rendered = stringifyImageErrorForLog(hostile);
|
||||
assert.equal(typeof rendered, "string");
|
||||
assert.ok(rendered.startsWith("Error:"), `expected the "Error" fallback name, got ${rendered}`);
|
||||
});
|
||||
|
||||
test("stringifyImageErrorForLog keeps String() semantics for primitives and falls back on cycles", () => {
|
||||
assert.equal(stringifyImageErrorForLog(42), "42");
|
||||
assert.equal(stringifyImageErrorForLog(null), "null");
|
||||
assert.equal(stringifyImageErrorForLog(undefined), "undefined");
|
||||
|
||||
const cyclic: Record<string, unknown> = { reason: "loop" };
|
||||
cyclic.self = cyclic;
|
||||
assert.equal(stringifyImageErrorForLog(cyclic), "[object Object]");
|
||||
|
||||
const hostile = Object.create(null) as Record<string, unknown>;
|
||||
hostile.toJSON = () => {
|
||||
throw new Error("nope");
|
||||
};
|
||||
assert.equal(stringifyImageErrorForLog(hostile), "[unserializable error]");
|
||||
});
|
||||
|
||||
// ── saveUpscaleErrorResult (the call-log sink) ─────────────────────────────
|
||||
|
||||
test("saveUpscaleErrorResult does not crash on a null-prototype error payload", async () => {
|
||||
const payload = Object.create(null) as Record<string, unknown>;
|
||||
payload.code = "upstream_error";
|
||||
payload.message = "null-proto upscale failure";
|
||||
|
||||
const provider = "upscale-nullproto-ledger22";
|
||||
let result: ReturnType<typeof saveUpscaleErrorResult> | undefined;
|
||||
assert.doesNotThrow(() => {
|
||||
result = saveUpscaleErrorResult({
|
||||
provider,
|
||||
model: "fast",
|
||||
status: 502,
|
||||
startTime: Date.now(),
|
||||
error: payload,
|
||||
});
|
||||
});
|
||||
assert.ok(result);
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 502);
|
||||
assert.equal(result.error, payload, "the caller-facing result keeps the original error");
|
||||
|
||||
assert.ok(await waitForCallLogSaves(60_000), "call-log save did not settle");
|
||||
const logs = await getCallLogs({ provider, limit: 5 });
|
||||
assert.equal(logs.length, 1, "the sink must still persist the call log");
|
||||
assert.equal(typeof logs[0].error, "string");
|
||||
assert.ok(logs[0].error.includes("null-proto upscale failure"), `got ${logs[0].error}`);
|
||||
});
|
||||
|
||||
test("saveUpscaleErrorResult persists an Error carrying an Authorization value masked", async () => {
|
||||
const provider = "upscale-auth-ledger37";
|
||||
saveUpscaleErrorResult({
|
||||
provider,
|
||||
model: "conservative",
|
||||
status: 401,
|
||||
startTime: Date.now(),
|
||||
error: new Error(AUTH_MESSAGE),
|
||||
});
|
||||
|
||||
assert.ok(await waitForCallLogSaves(60_000), "call-log save did not settle");
|
||||
const logs = await getCallLogs({ provider, limit: 5 });
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(typeof logs[0].error, "string");
|
||||
assert.ok(!logs[0].error.includes(SECRET), `credential persisted in call log: ${logs[0].error}`);
|
||||
assert.ok(logs[0].error.startsWith("Error:"), `expected "Error: …" prefix, got ${logs[0].error}`);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
import { fetchRemoteImage, fetchUntrustedRemoteImage } from "@/shared/network/remoteImageFetch";
|
||||
|
||||
// Stub DNS resolver: every (unused) hostname resolves to a public IP. The
|
||||
// rebinding guard (GHSA-cmhj-wh2f-9cgx) needs a non-empty resolution; without
|
||||
@@ -105,3 +105,71 @@ test("fetchRemoteImage blocks redirects to cloud-metadata hosts under the defaul
|
||||
/Blocked cloud-metadata endpoint/
|
||||
);
|
||||
});
|
||||
|
||||
// omni-code-review 2026-09-21_release-v3.8.51_vs_main_e2e-areas LEDGER-50 (round-1 LEDGER-32):
|
||||
// `fetchUntrustedRemoteImage` is the ONE entry point for caller-supplied / upstream-returned
|
||||
// URLs. It must hard-code `guard: "public-only"` and `pinDns: true` (GHSA-34rg-3pqj-35g9 /
|
||||
// #13883) — a caller cannot loosen either, even by passing the opposite value explicitly.
|
||||
|
||||
test("fetchUntrustedRemoteImage forces guard public-only even when the caller asks for none", async () => {
|
||||
let called = false;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
fetchUntrustedRemoteImage("http://127.0.0.1:20128/private.png", {
|
||||
guard: "none",
|
||||
fetchImpl: async () => {
|
||||
called = true;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
}),
|
||||
/Blocked private or local provider URL/
|
||||
);
|
||||
assert.equal(called, false, "the private URL must be rejected before any fetch");
|
||||
});
|
||||
|
||||
test("fetchUntrustedRemoteImage validates the resolved answers (public-only DNS check) even when the caller asks for none", async () => {
|
||||
let lookups = 0;
|
||||
let called = false;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
fetchUntrustedRemoteImage("https://rebind.example.com/x.png", {
|
||||
guard: "none",
|
||||
lookup: async () => {
|
||||
lookups += 1;
|
||||
return [{ address: "10.0.0.8", family: 4 }];
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
called = true;
|
||||
return new Response("unexpected");
|
||||
},
|
||||
}),
|
||||
/DNS rebinding/
|
||||
);
|
||||
assert.equal(lookups, 1, "public-only must resolve the host (guard none would skip the lookup)");
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("fetchUntrustedRemoteImage forces pinDns even when the caller passes pinDns: false", async () => {
|
||||
// Same mechanism as tests/unit/pindns-toctou-13883.test.ts: with pinning active the request
|
||||
// goes out through the real pinned undici socket towards the (validated, unroutable
|
||||
// RFC 5737 TEST-NET-3) answer and fails closed, instead of reaching an un-pinned
|
||||
// `globalThis.fetch`. If the helper honoured `pinDns: false`, the mock below would be hit.
|
||||
let mockCalled = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
mockCalled = true;
|
||||
throw new Error("globalThis.fetch must not be reached when pinDns is active");
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(() =>
|
||||
fetchUntrustedRemoteImage("https://rebind.example.com/x.png", {
|
||||
pinDns: false,
|
||||
lookup: async () => [{ address: "203.0.113.7", family: 4 }],
|
||||
timeoutMs: 1500,
|
||||
})
|
||||
);
|
||||
assert.equal(mockCalled, false, "pinDns must bypass globalThis.fetch, not call it");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user