Compare commits

...

8 Commits

Author SHA1 Message Date
diegosouzapw
9384663a4b fix(review): resolve LEDGER-47/48/49/50/52/54/56/57/58/59/60/63/64/66/67 (omni-code-review round 3)
LEDGER-47: Antigravity call-log test fixture returns a real inlineData candidate and
asserts result.data.data.length === 1, instead of an empty candidates array.
LEDGER-48: normalizeImageGenerationSize now returns { value, clamped } so an
unrecognised image_size logs a log.warn line naming the raw value and the clamp.
LEDGER-49: stringifyImageErrorForLog's object branch is pinned against a
credential-bearing null-prototype payload, asserting redaction to [REDACTED].
LEDGER-50: added fetchUntrustedRemoteImage() to src/shared/network/remoteImageFetch.ts
and switched imageGeneration.ts and imageUpscale/shared.ts to call it, removing the
per-file duplicated public-only/pinDns option literal and rationale comment.
LEDGER-52: imageErrorLog.ts's relative import of redactSensitiveErrorText now carries
the explicit .ts extension used by every other relative import in open-sse/handlers/.
LEDGER-54: captureAntigravityImageRequest now reads the persisted call-log row's
requestBody (image_size / image_size_applied) via getCallLogById, not just the
upstream envelope.
LEDGER-56: stringifyImageErrorForLog's Error branch renders name/message
defensively inside the same safety net as the other branches, so a non-string
name/message or a throwing message getter cannot escape as an unhandled throw.
LEDGER-57: normalizeImageGenerationSize's docblock hedges the 1K/2K/4K enum and the
clamp rationale as observed behaviour (#11952), not a confirmed upstream contract.
LEDGER-58: logRequestBody records both image_size (raw caller value, null when
absent) and image_size_applied (what went upstream, "default" when omitted), so the
persisted call-log body never silently drops the caller's original value.
LEDGER-59: same call-log fix as LEDGER-58 (two ledger findings, one change).
LEDGER-60: same call-log fix as LEDGER-58 (two ledger findings, one change).
LEDGER-63: imageErrorLog.ts's relative imports use explicit .ts extensions, matching
every other relative import in open-sse/handlers/.
LEDGER-64: imageUpscale/shared.ts imports imageErrorLog.ts and utils/error.ts with
explicit .ts extensions, matching the sibling import in imageGeneration.ts.
LEDGER-66: the non-object-item 502 test case in image-generation-handler.test.ts now
also asserts the "without an image payload" message, pinning isJsonObject(item)
rather than any 502 from the outer catch.
LEDGER-67: the new LEDGER-12/LEDGER-6 test comments are qualified with the
omni-code-review run id, avoiding a collision with other runs' LEDGER numbering.
2026-09-22 01:35:21 -03:00
diegosouzapw
6aa703ca8f fix(review): resolve LEDGER-51, LEDGER-61 (omni-code-review round 3)
The LEDGER-3/9 source-text guard in
tests/unit/batch-delete-completed-ownership-wvxc.test.ts matched prose as
well as code: its `\bvalidateApiKey\s*\(` alternative was one comment
reflow (or a `@see validateApiKey(key)`) away from failing the suite with
a message that falsely claims the route re-runs the lifecycle check.

- Extract the guard into `routeReRunsValidateApiKey(src)` and strip
  block and line comments (`/\/\*[\s\S]*?\*\/|\/\/.*$/gm`) before
  matching, so the assertion is about code only.
- Add a fixture case that feeds the guard a comment containing
  `validateApiKey (` and `@see validateApiKey(key)` — RED against the old
  regex, GREEN after the strip — plus a mutation check proving a real
  import or call in code still trips it.

The route file is untouched.
2026-09-21 22:27:27 -03:00
diegosouzapw
7107cb7599 test(images): move the image_size and hasUsableImage cases into their own file (file-size gate)
tests/unit/image-generation-handler.test.ts is frozen at 2235 lines by
check:file-size; commit 8a587a7 grew it to 2389. The nine new cases
(LEDGER-6/24/28/29 image_size outcomes, LEDGER-12 hasUsableImage) now live in
tests/unit/image-generation-size-and-payload-guard.test.ts; the original file
is back to 2232 lines with its other content unchanged.
2026-09-21 20:49:29 -03:00
diegosouzapw
ec7f9cc0b9 docs(changelog): fragment for #14365 2026-09-21 17:05:52 -03:00
diegosouzapw
2dcb4ce07d fix(review): share stringifyImageErrorForLog between the image sinks (omni-code-review LEDGER-22 follow-up) 2026-09-21 16:57:55 -03:00
diegosouzapw
8a587a7321 fix(review): resolve LEDGER-4/40/6/24/28/29/12/32/42 (omni-code-review) 2026-09-21 14:19:27 -03:00
diegosouzapw
11d71e959c fix(review): resolve LEDGER-3/9/39/41 (omni-code-review) 2026-09-21 14:15:56 -03:00
diegosouzapw
25951c113a fix(review): resolve LEDGER-22, LEDGER-37 (omni-code-review)
The /v1/images/upscale call-log sink rendered error: unknown with a bare
String(opts.error), which throws on the null-prototype objects
sanitizeUpstreamDetails() produces and forwarded credential-bearing Error
messages verbatim. Lift the generation sink's total stringifier into
open-sse/handlers/imageErrorLog.ts, add credential redaction, and use it
from imageUpscale/shared.ts.
2026-09-21 13:59:34 -03:00
12 changed files with 898 additions and 97 deletions

View 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))

View 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;
}
}

View File

@@ -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 }];
}

View File

@@ -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(() => {});

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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");
});
});

View File

@@ -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)

View 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",
});
});

View 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}`);
});

View File

@@ -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;
}
});