From f706cb19038de27fc953452b2bdeb6eecb0d8496 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:26:05 +0700 Subject: [PATCH 01/16] fix(memory): isolate optional rerank node listing --- src/app/api/memory/rerank-providers/route.ts | 2 +- tests/unit/rerank-providers-route.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/api/memory/rerank-providers/route.ts b/src/app/api/memory/rerank-providers/route.ts index 9b8b72d6..cc3dd904 100644 --- a/src/app/api/memory/rerank-providers/route.ts +++ b/src/app/api/memory/rerank-providers/route.ts @@ -40,7 +40,7 @@ export async function GET(request: NextRequest) { // Local provider nodes, listed with the rerank-typed models they expose // (synced + custom rows; see nodeModalityListings.ts). - const extra = await listProviderNodeModalityListings("rerank"); + const extra = await listProviderNodeModalityListings("rerank").catch(() => []); return NextResponse.json({ providers: mergeRerankProviderListings(curated, extra) }); } catch (err: unknown) { diff --git a/tests/unit/rerank-providers-route.test.ts b/tests/unit/rerank-providers-route.test.ts index a8dd6a7f..159f1f19 100644 --- a/tests/unit/rerank-providers-route.test.ts +++ b/tests/unit/rerank-providers-route.test.ts @@ -40,3 +40,12 @@ test("GET /api/memory/rerank-providers includes rerank-capable local provider no { provider: "local-reranker", hasKey: true, models: [] } ); }); + +test("local node listing failure is isolated from curated rerank providers", () => { + const source = fs.readFileSync("src/app/api/memory/rerank-providers/route.ts", "utf8"); + assert.match( + source, + /listProviderNodeModalityListings\("rerank"\)\.catch\(\(\) => \[\]\)/, + "optional local-node listing must fail open to [] instead of failing the whole route" + ); +}); From 96810ecb51fa10e6b23ab868855fa0e15b361797 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:29:28 +0700 Subject: [PATCH 02/16] fix(monitoring): harden deep health probes --- src/app/api/monitoring/health/route.ts | 9 ++-- src/lib/monitoring/observability.ts | 21 +++++++++- .../unit/monitoring-deep-health-14236.test.ts | 41 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/app/api/monitoring/health/route.ts b/src/app/api/monitoring/health/route.ts index 2a9e895a..176a86c0 100644 --- a/src/app/api/monitoring/health/route.ts +++ b/src/app/api/monitoring/health/route.ts @@ -8,6 +8,7 @@ import { APP_CONFIG } from "@/shared/constants/config"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard"; /** * GET /api/monitoring/health — System health overview @@ -83,9 +84,7 @@ function resolveDeepHealthTarget(): { url: string; token?: string } | null { const rawUrl = (process.env.DEEP_HEALTH_CHECK_URL ?? "").trim(); if (!rawUrl) return null; try { - const parsed = new URL(rawUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - if (parsed.username || parsed.password) return null; + const parsed = parseAndValidatePublicUrl(rawUrl); const token = (process.env.DEEP_HEALTH_CHECK_TOKEN ?? "").trim(); return { url: parsed.toString(), ...(token ? { token } : {}) }; } catch { @@ -94,7 +93,9 @@ function resolveDeepHealthTarget(): { url: string; token?: string } | null { } function withDeepHealth(payload: unknown, wantDeep: boolean): unknown { - if (!wantDeep || !deepHealthVerdictCache) return payload; + if (!wantDeep || !deepHealthVerdictCache || Date.now() > deepHealthVerdictCache.expiresAt) { + return payload; + } return { ...(payload as Record), deepHealth: deepHealthVerdictCache.verdict }; } diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index c2b3fdc2..273656c2 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -5,6 +5,8 @@ import { import type { AdaptiveAdmissionPublicSnapshot } from "@agentproxy/open-sse/services/admission/runtime.ts"; import type { PerConnectionAdmissionController } from "@/shared/middleware/chatBodyAdmission"; import type { WalMaintenanceState } from "@/lib/db/walMaintenance"; +import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard"; type JsonRecord = Record; @@ -643,7 +645,11 @@ export async function probeDeepHealth( }); try { - const request = fetcher(url, { + // Reject obvious private/link-local targets before any transport. Production + // also uses safeOutboundFetch below, which re-validates DNS answers immediately + // before egress so a public hostname cannot rebind to an internal address. + parseAndValidatePublicUrl(url); + const init: RequestInit = { method: "POST", headers: { "content-type": "application/json", @@ -651,7 +657,18 @@ export async function probeDeepHealth( }, body: JSON.stringify({ max_tokens: 1, stream: false }), signal: controller.signal, - }); + redirect: "manual", + }; + const request = opts.fetcher + ? fetcher(url, init) + : safeOutboundFetch(url, { + ...init, + timeoutMs, + guard: "public-only", + pinDns: true, + allowRedirect: false, + retry: false, + }); const timeout = new Promise((_, reject) => { timeoutHandle = setTimeout(() => { controller.abort(); diff --git a/tests/unit/monitoring-deep-health-14236.test.ts b/tests/unit/monitoring-deep-health-14236.test.ts index 3905ef1f..de725c5c 100644 --- a/tests/unit/monitoring-deep-health-14236.test.ts +++ b/tests/unit/monitoring-deep-health-14236.test.ts @@ -83,6 +83,31 @@ test("deep probe posts one-token non-streaming body and optional bearer token", assert.deepEqual(JSON.parse(String(seenInit?.body)), { max_tokens: 1, stream: false }); }); +test("deep probe blocks private/link-local targets before fetch and disables redirects", async () => { + let calls = 0; + const blocked = await probeDeepHealth("http://169.254.169.254/latest/meta-data", { + token: "must-not-leak", + fetcher: (async () => { + calls += 1; + return new Response(null, { status: 200 }); + }) as typeof fetch, + }); + assert.equal(calls, 0); + assert.equal(blocked.ok, false); + assert.equal(blocked.status, null); + + let seenRedirect: RequestRedirect | undefined; + const publicVerdict = await probeDeepHealth("https://health.example/v1/chat/completions", { + token: "deep-secret", + fetcher: (async (_url: string | URL | Request, init?: RequestInit) => { + seenRedirect = init?.redirect; + return new Response(null, { status: 302, headers: { location: "https://other.example/" } }); + }) as typeof fetch, + }); + assert.equal(seenRedirect, "manual"); + assert.equal(publicVerdict.status, 302); +}); + test("deep probe timeout and network failure return fail-open verdicts", async () => { const timeout = await probeDeepHealth("http://health.example/v1/chat/completions", { timeoutMs: 20, @@ -155,3 +180,19 @@ test("authenticated opted-in caller receives cached deep verdict without changin assert.deepEqual(secondBody.deepHealth, seeded); assert.equal(secondBody.status, firstBody.status); }); + +test("expired deep-health verdict is omitted instead of served stale", async () => { + process.env.DEEP_HEALTH_CHECK_ENABLED = "1"; + delete process.env.DEEP_HEALTH_CHECK_URL; + route.__test_seedDeepHealthVerdict( + { ok: false, failover: true, status: 503, latencyMs: 12, at: "stale" }, + -1 + ); + + const request = (await makeManagementSessionRequest( + "http://localhost/api/monitoring/health?deep=1" + )) as unknown as NextRequest; + const response = await route.GET(request as never); + const body = (await response.json()) as Record; + assert.equal("deepHealth" in body, false); +}); From 2d45137762ed9c81fda2eecf8b23b2764d60f344 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:31:36 +0700 Subject: [PATCH 03/16] fix(openwa): require management auth for service routes --- .../openwa/auto-restart-adopted/route.ts | 4 ++ .../api/services/openwa/auto-start/route.ts | 4 ++ src/app/api/services/openwa/install/route.ts | 4 ++ src/app/api/services/openwa/restart/route.ts | 6 +- src/app/api/services/openwa/start/route.ts | 6 +- src/app/api/services/openwa/status/route.ts | 6 +- src/app/api/services/openwa/stop/route.ts | 6 +- src/app/api/services/openwa/update/route.ts | 6 +- .../openwa-management-auth-ap0197.test.ts | 64 +++++++++++++++++++ 9 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 tests/unit/openwa-management-auth-ap0197.test.ts diff --git a/src/app/api/services/openwa/auto-restart-adopted/route.ts b/src/app/api/services/openwa/auto-restart-adopted/route.ts index 963896ee..d61c7f76 100644 --- a/src/app/api/services/openwa/auto-restart-adopted/route.ts +++ b/src/app/api/services/openwa/auto-restart-adopted/route.ts @@ -2,10 +2,14 @@ import { z } from "zod"; import { updateServiceField } from "@/lib/db/versionManager"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const BodySchema = z.object({ enabled: z.boolean() }); export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + let body: unknown; try { body = await request.json(); diff --git a/src/app/api/services/openwa/auto-start/route.ts b/src/app/api/services/openwa/auto-start/route.ts index 5e966ef9..b71b850a 100644 --- a/src/app/api/services/openwa/auto-start/route.ts +++ b/src/app/api/services/openwa/auto-start/route.ts @@ -2,10 +2,14 @@ import { z } from "zod"; import { updateServiceField } from "@/lib/db/versionManager"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const BodySchema = z.object({ enabled: z.boolean() }); export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + let body: unknown; try { body = await request.json(); diff --git a/src/app/api/services/openwa/install/route.ts b/src/app/api/services/openwa/install/route.ts index ff94790e..81c69ee3 100644 --- a/src/app/api/services/openwa/install/route.ts +++ b/src/app/api/services/openwa/install/route.ts @@ -1,6 +1,10 @@ import { install } from "@/lib/services/installers/openwa"; import { handleServiceInstall } from "@/app/api/services/_shared/installRoute"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + return handleServiceInstall(request, install); } diff --git a/src/app/api/services/openwa/restart/route.ts b/src/app/api/services/openwa/restart/route.ts index 9439ef05..dc3eb719 100644 --- a/src/app/api/services/openwa/restart/route.ts +++ b/src/app/api/services/openwa/restart/route.ts @@ -2,10 +2,14 @@ import { getServiceRow } from "@/lib/db/versionManager"; import { getOrInitSupervisor } from "../_lib"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const TOOL = "openwa"; -export async function POST(): Promise { +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + try { const row = await getServiceRow(TOOL); if (!row || row.status === "not_installed") { diff --git a/src/app/api/services/openwa/start/route.ts b/src/app/api/services/openwa/start/route.ts index 64108624..bb392e75 100644 --- a/src/app/api/services/openwa/start/route.ts +++ b/src/app/api/services/openwa/start/route.ts @@ -2,10 +2,14 @@ import { getServiceRow } from "@/lib/db/versionManager"; import { getOrInitSupervisor } from "../_lib"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const TOOL = "openwa"; -export async function POST(): Promise { +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + try { const row = await getServiceRow(TOOL); if (!row || row.status === "not_installed") { diff --git a/src/app/api/services/openwa/status/route.ts b/src/app/api/services/openwa/status/route.ts index eeddf4dc..295eb719 100644 --- a/src/app/api/services/openwa/status/route.ts +++ b/src/app/api/services/openwa/status/route.ts @@ -7,10 +7,14 @@ import { } from "@/lib/services/installers/openwa"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const TOOL = "openwa"; -export async function GET(): Promise { +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + try { const sup = getSupervisor(TOOL); const row = await getServiceRow(TOOL); diff --git a/src/app/api/services/openwa/stop/route.ts b/src/app/api/services/openwa/stop/route.ts index e9c3ea00..83d03c0a 100644 --- a/src/app/api/services/openwa/stop/route.ts +++ b/src/app/api/services/openwa/stop/route.ts @@ -1,10 +1,14 @@ import { getSupervisor } from "@/lib/services/registry"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const TOOL = "openwa"; -export async function POST(): Promise { +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; + try { const sup = getSupervisor(TOOL); if (!sup) { diff --git a/src/app/api/services/openwa/update/route.ts b/src/app/api/services/openwa/update/route.ts index 7bec141f..2e4026d5 100644 --- a/src/app/api/services/openwa/update/route.ts +++ b/src/app/api/services/openwa/update/route.ts @@ -7,8 +7,12 @@ import { } from "@/lib/services/installers/openwa"; import { createErrorResponse } from "@/lib/api/errorResponse"; import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function POST(request: Request): Promise { + const authError = await requireManagementAuth(request, { alwaysRequireAuth: true }); + if (authError) return authError; -export async function POST(): Promise { try { const [installed, latest] = await Promise.all([getInstalledVersion(), getLatestVersion()]); diff --git a/tests/unit/openwa-management-auth-ap0197.test.ts b/tests/unit/openwa-management-auth-ap0197.test.ts new file mode 100644 index 00000000..a30081f2 --- /dev/null +++ b/tests/unit/openwa-management-auth-ap0197.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agentproxy-openwa-auth-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const autoStart = await import("../../src/app/api/services/openwa/auto-start/route.ts"); +const autoRestart = await import("../../src/app/api/services/openwa/auto-restart-adopted/route.ts"); +const start = await import("../../src/app/api/services/openwa/start/route.ts"); +const restart = await import("../../src/app/api/services/openwa/restart/route.ts"); +const stop = await import("../../src/app/api/services/openwa/stop/route.ts"); +const status = await import("../../src/app/api/services/openwa/status/route.ts"); + +function unauthenticated(pathname: string, body?: unknown): Request { + return new Request(`http://localhost${pathname}`, { + method: body === undefined ? "GET" : "POST", + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("OpenWA lifecycle and persisted-control handlers reject unauthenticated HTTP callers", async () => { + const responses = await Promise.all([ + autoStart.POST(unauthenticated("/api/services/openwa/auto-start", { enabled: true }) as never), + autoRestart.POST( + unauthenticated("/api/services/openwa/auto-restart-adopted", { enabled: true }) as never + ), + start.POST(unauthenticated("/api/services/openwa/start", {}) as never), + restart.POST(unauthenticated("/api/services/openwa/restart", {}) as never), + stop.POST(unauthenticated("/api/services/openwa/stop", {}) as never), + status.GET(unauthenticated("/api/services/openwa/status") as never), + ]); + + assert.deepEqual( + responses.map((response) => response.status), + [401, 401, 401, 401, 401, 401] + ); +}); + +test("every OpenWA HTTP route has an explicit management-auth gate", () => { + for (const relative of [ + "auto-start/route.ts", + "auto-restart-adopted/route.ts", + "install/route.ts", + "restart/route.ts", + "start/route.ts", + "status/route.ts", + "stop/route.ts", + "update/route.ts", + ]) { + const source = fs.readFileSync(`src/app/api/services/openwa/${relative}`, "utf8"); + assert.match(source, /requireManagementAuth/); + assert.match(source, /alwaysRequireAuth:\s*true/); + } +}); From 58a4f999282d249ef8b46fcea4582a4ccc679f55 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:34:56 +0700 Subject: [PATCH 04/16] fix(cache): harden embedding probe credentials --- open-sse/services/cache/embeddingClient.ts | 2 + src/app/api/settings/cache-config/route.ts | 1 - .../cache-config/test-embedding/route.ts | 29 +++++- tests/unit/cache-config-route-8219.test.ts | 12 +++ ...cache-config-test-embedding-ap0197.test.ts | 98 +++++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 tests/unit/cache-config-test-embedding-ap0197.test.ts diff --git a/open-sse/services/cache/embeddingClient.ts b/open-sse/services/cache/embeddingClient.ts index 47a0c117..cf1dd17e 100644 --- a/open-sse/services/cache/embeddingClient.ts +++ b/open-sse/services/cache/embeddingClient.ts @@ -151,6 +151,7 @@ export function createDefaultEmbeddingGenerator(config: { embeddingModel?: string; embeddingBaseUrl?: string; embeddingApiKey?: string; + embeddingDimension?: number; }): EmbeddingGenerator { return async ( text: string, @@ -199,6 +200,7 @@ export function createDefaultEmbeddingGenerator(config: { body: JSON.stringify({ model, input: text, + ...(config.embeddingDimension ? { dimensions: config.embeddingDimension } : {}), }), signal: options?.signal, }); diff --git a/src/app/api/settings/cache-config/route.ts b/src/app/api/settings/cache-config/route.ts index 6f3703d7..46d9e284 100644 --- a/src/app/api/settings/cache-config/route.ts +++ b/src/app/api/settings/cache-config/route.ts @@ -47,7 +47,6 @@ const CACHE_CONFIG_KEYS = [ "semanticCacheEmbeddingModel", "semanticCacheEmbeddingDimension", "semanticCacheEmbeddingBaseUrl", - "semanticCacheEmbeddingApiKey", "semanticCacheRedisUrl", "semanticCacheRedisPrefix", "semanticCacheRequireZeroTemp", diff --git a/src/app/api/settings/cache-config/test-embedding/route.ts b/src/app/api/settings/cache-config/test-embedding/route.ts index d3f00e92..b4f552f6 100644 --- a/src/app/api/settings/cache-config/test-embedding/route.ts +++ b/src/app/api/settings/cache-config/test-embedding/route.ts @@ -5,6 +5,7 @@ import { sanitizeErrorMessage } from "@agentproxy/open-sse/utils/error"; import { z } from "zod"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveProviderConnectionDetails } from "@/lib/cache/semanticCacheDbBridge"; +import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard"; const testEmbeddingSchema = z.object({ provider: z.string().trim().min(1), @@ -34,12 +35,25 @@ export async function POST(request: Request) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { provider, model, baseUrl, apiKey } = validation.data; + const { provider, model, baseUrl, apiKey, dimensions } = validation.data; - // Resolve connection details from DB if not explicitly passed + // A caller-supplied endpoint is untrusted input: keep it public-only and never + // pair it with a credential silently loaded from persistent provider state. + if (baseUrl) { + try { + parseAndValidatePublicUrl(baseUrl); + } catch { + return NextResponse.json({ + ok: false, + error: "Embedding base URL must be a safe public URL", + }); + } + } + + // Saved connection credentials may only travel to the saved connection endpoint. const conn = resolveProviderConnectionDetails(provider); const effectiveBaseUrl = baseUrl || conn.baseUrl; - const effectiveApiKey = apiKey || conn.apiKey; + const effectiveApiKey = apiKey || (baseUrl ? undefined : conn.apiKey); try { const generator = createDefaultEmbeddingGenerator({ @@ -47,6 +61,7 @@ export async function POST(request: Request) { embeddingModel: model, embeddingBaseUrl: effectiveBaseUrl, embeddingApiKey: effectiveApiKey, + embeddingDimension: dimensions, }); const start = Date.now(); @@ -63,6 +78,14 @@ export async function POST(request: Request) { ); } + if (dimensions !== undefined && result.embedding.length !== dimensions) { + return NextResponse.json({ + ok: false, + error: `Embedding dimension mismatch: expected ${dimensions}, got ${result.embedding.length}`, + dimensions: result.embedding.length, + }); + } + return NextResponse.json({ ok: true, latencyMs, diff --git a/tests/unit/cache-config-route-8219.test.ts b/tests/unit/cache-config-route-8219.test.ts index 2f37337c..3c94b4b2 100644 --- a/tests/unit/cache-config-route-8219.test.ts +++ b/tests/unit/cache-config-route-8219.test.ts @@ -70,6 +70,18 @@ test("cache-config route resolves and modelCatalogCacheTtlMs round-trips", async assert.equal(getBody.modelCatalogCacheTtlMs, 4242, "modelCatalogCacheTtlMs must round-trip"); }); + await t.test("GET never returns the stored embedding API key", async () => { + const putResponse = await cacheConfigRoute.PUT( + makeJsonRequest("PUT", { semanticCacheEmbeddingApiKey: "stored-embedding-secret" }) as never + ); + assert.equal(putResponse.status, 200); + + const getResponse = await cacheConfigRoute.GET(makeJsonRequest("GET") as never); + const getBody = await getResponse.json(); + assert.equal("semanticCacheEmbeddingApiKey" in getBody, false); + assert.equal(JSON.stringify(getBody).includes("stored-embedding-secret"), false); + }); + await t.test("PUT persists idempotencyWindowMs via the flat settings module", async () => { const putResponse = await cacheConfigRoute.PUT( makeJsonRequest("PUT", { idempotencyWindowMs: 9000 }) as never diff --git a/tests/unit/cache-config-test-embedding-ap0197.test.ts b/tests/unit/cache-config-test-embedding-ap0197.test.ts new file mode 100644 index 00000000..4404895b --- /dev/null +++ b/tests/unit/cache-config-test-embedding-ap0197.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agentproxy-cache-embed-test-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const route = await import("../../src/app/api/settings/cache-config/test-embedding/route.ts"); +const originalFetch = globalThis.fetch; + +function request(body: unknown): Request { + return new Request("http://localhost/api/settings/cache-config/test-embedding", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("dimension constraint is forwarded and a mismatched embedding is rejected", async () => { + let upstreamBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + upstreamBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ data: [{ embedding: [0.1, 0.2] }] }); + }) as typeof fetch; + + const response = await route.POST( + request({ + provider: "openai", + model: "text-embedding-3-small", + baseUrl: "https://embeddings.example/v1", + apiKey: "caller-key", + dimensions: 3, + }) + ); + const body = (await response.json()) as Record; + assert.equal(upstreamBody?.dimensions, 3); + assert.equal(body.ok, false); +}); + +test("caller-supplied baseUrl cannot inherit a stored provider credential", async () => { + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + apiKey: "saved-provider-secret", + isActive: true, + testStatus: "active", + }); + + let authorization: string | null = null; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + authorization = new Headers(init?.headers).get("authorization"); + return Response.json({ data: [{ embedding: [0.1, 0.2, 0.3] }] }); + }) as typeof fetch; + + const response = await route.POST( + request({ + provider: "openai", + model: "text-embedding-3-small", + baseUrl: "https://attacker.example/v1", + }) + ); + assert.equal(response.status, 200); + assert.equal(authorization, null); +}); + +test("caller-supplied private baseUrl is rejected before transport", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return Response.json({ data: [{ embedding: [1] }] }); + }) as typeof fetch; + + const response = await route.POST( + request({ + provider: "openai", + model: "text-embedding-3-small", + baseUrl: "http://169.254.169.254/latest/meta-data", + apiKey: "caller-key", + }) + ); + const body = (await response.json()) as Record; + assert.equal(body.ok, false); + assert.equal(calls, 0); +}); From b6d3071f24f55d666141b9bbf670d8be4917dec6 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:37:29 +0700 Subject: [PATCH 05/16] fix(auth): atomically claim bootstrap token --- src/app/api/settings/require-login/route.ts | 24 ++++++++++++--------- tests/unit/login-bootstrap-route.test.ts | 15 +++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index 5dbd2dbe..b87b6726 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -90,17 +90,17 @@ export async function GET() { */ export async function POST(request: Request) { const settings = await getSettings(); + let requiresBootstrapTokenClaim = false; if (isBootstrapSecurityWindow(settings)) { const peerLocality = request.headers.get(AUTHZ_HEADER_PEER_LOCALITY); // In production this header is stripped from client input and re-stamped by // the authz pipeline from the authenticated TCP-peer stamp. Reject any // explicitly non-loopback verdict before parsing or persisting the body. - if ( - peerLocality && - peerLocality !== "loopback" && - !peekBootstrapToken(request.headers.get(BOOTSTRAP_TOKEN_HEADER)) - ) { - return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 }); + if (peerLocality && peerLocality !== "loopback") { + if (!peekBootstrapToken(request.headers.get(BOOTSTRAP_TOKEN_HEADER))) { + return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 }); + } + requiresBootstrapTokenClaim = true; } } else if (!(await isAuthenticated(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -140,11 +140,15 @@ export async function POST(request: Request) { updates.password = hashedPassword; } + const bootstrapCandidate = request.headers.get(BOOTSTRAP_TOKEN_HEADER); + if (requiresBootstrapTokenClaim && !consumeBootstrapToken(bootstrapCandidate)) { + return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 }); + } + await updateSettings(updates); - // #14296: one-shot — a Docker/NAT-forwarded operator that authenticated - // this write via the bootstrap token cannot replay it for a second write. - // A no-op when the header is absent or stale (never matches). - consumeBootstrapToken(request.headers.get(BOOTSTRAP_TOKEN_HEADER)); + // Loopback bootstrap does not need the token for authorization, but preserve + // the existing one-shot behavior when a valid token happens to be supplied. + if (!requiresBootstrapTokenClaim) consumeBootstrapToken(bootstrapCandidate); return NextResponse.json({ success: true }); } catch (error) { console.error("[API] Error updating require-login settings:", error); diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index 8f600fd7..f493b9b0 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -346,6 +346,21 @@ test("#14296 AgentProxy route guard accepts and consumes the valid one-shot toke assert.equal(bootstrapToken.peekBootstrapToken(token), false, "successful write consumes token"); }); +test("#14296 concurrent remote bootstrap writes can consume the one-shot token only once", async () => { + await settingsDb.updateSettings({ requireLogin: true, password: "", setupComplete: false }); + const token = bootstrapToken.getOrCreateBootstrapToken({ log: () => {} }); + + const responses = await Promise.all([ + route.POST(remoteBootstrapPost(token)), + route.POST(remoteBootstrapPost(token)), + ]); + assert.deepEqual( + responses.map((response) => response.status).sort((a, b) => a - b), + [200, 403] + ); + assert.equal(bootstrapToken.peekBootstrapToken(token), false); +}); + test("public login bootstrap route POST returns 500 when hashing fails", async () => { bcrypt.hash = async () => { throw new Error("hash failed"); From d396f64298167b9a3648fddfe0100e55a407087d Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:42:12 +0700 Subject: [PATCH 06/16] fix(authz): preserve dashboard session authority --- src/app/api/v1/_helpers/apiKeyScope.ts | 24 +++++-- src/app/api/v1/batches/[id]/cancel/route.ts | 16 ++++- src/app/api/v1/batches/[id]/route.ts | 32 +++++++-- .../api-key-session-precedence-ap0197.test.ts | 48 +++++++++++++ ...apikey-scope-manage-demotion-14481.test.ts | 17 ++--- .../batch-item-session-scope-ap0197.test.ts | 69 +++++++++++++++++++ ...tches-delete-completed-route-scope.test.ts | 59 ++++++---------- 7 files changed, 204 insertions(+), 61 deletions(-) create mode 100644 tests/unit/api-key-session-precedence-ap0197.test.ts create mode 100644 tests/unit/batch-item-session-scope-ap0197.test.ts diff --git a/src/app/api/v1/_helpers/apiKeyScope.ts b/src/app/api/v1/_helpers/apiKeyScope.ts index c48ecc39..76f2116f 100644 --- a/src/app/api/v1/_helpers/apiKeyScope.ts +++ b/src/app/api/v1/_helpers/apiKeyScope.ts @@ -21,6 +21,17 @@ export interface ApiKeyRequestScope { export async function getApiKeyRequestScope(request: Request): Promise { const isSessionAuth = await isDashboardSessionAuthenticated(request); + if (isSessionAuth) { + return { + apiKey: null, + apiKeyId: null, + apiKeyMetadata: null, + keyState: "none", + rejection: null, + isSessionAuth: true, + }; + } + const apiKey = extractApiKey(request); if (!apiKey) { return { @@ -66,7 +77,7 @@ export async function getPolicyAwareApiKeyRequestScope( request: Request ): Promise { const scope = await getApiKeyRequestScope(request); - if (scope.rejection) return scope; + if (scope.rejection || scope.isSessionAuth) return scope; if (scope.apiKey && !scope.apiKeyId) { return { ...scope, rejection: unauthorized("Invalid API key") }; @@ -78,6 +89,7 @@ export async function getPolicyAwareApiKeyRequestScope( return { ...scope, apiKeyId: scope.apiKeyId ?? policy.apiKeyInfo?.id ?? null, + apiKeyMetadata: scope.apiKeyMetadata ?? policy.apiKeyInfo ?? null, }; } @@ -88,9 +100,9 @@ export function canAccessOwnedRecord( scope: OwnershipScope, recordApiKeyId: string | null | undefined ): boolean { + if (scope.isSessionAuth) return true; if (hasManageScope(scope.apiKeyMetadata?.scopes ?? [])) return true; if (scope.apiKeyId) return recordApiKeyId === scope.apiKeyId; - if (scope.isSessionAuth) return true; return recordApiKeyId === ANONYMOUS_OWNER_ID; } @@ -98,9 +110,9 @@ export function resolveEffectiveApiKeyId( scope: Pick, policyApiKeyInfo: { id: string } | null ): string | null { + if (scope.isSessionAuth) return null; if (scope.apiKeyId) return scope.apiKeyId; if (policyApiKeyInfo?.id) return policyApiKeyInfo.id; - if (scope.isSessionAuth) return null; return ANONYMOUS_OWNER_ID; } @@ -110,6 +122,9 @@ export type OwnedListScope = | { mode: "rejected"; response: Response }; export function resolveListScope(scope: ApiKeyRequestScope): OwnedListScope { + if (scope.isSessionAuth) { + return { mode: "instance" }; + } if (scope.apiKey && !scope.apiKeyId) { return { mode: "rejected", response: unauthorized("Invalid API key") }; } @@ -119,8 +134,5 @@ export function resolveListScope(scope: ApiKeyRequestScope): OwnedListScope { if (scope.apiKeyId) { return { mode: "api_key", apiKeyId: scope.apiKeyId }; } - if (scope.isSessionAuth) { - return { mode: "instance" }; - } return { mode: "rejected", response: unauthorized("Authentication required") }; } diff --git a/src/app/api/v1/batches/[id]/cancel/route.ts b/src/app/api/v1/batches/[id]/cancel/route.ts index 4414b9d3..47c1617b 100644 --- a/src/app/api/v1/batches/[id]/cancel/route.ts +++ b/src/app/api/v1/batches/[id]/cancel/route.ts @@ -40,7 +40,9 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: // on scope.apiKeyId let that transport skip the endpoint allowlist, // schedule, usage cap, rate limit and key quota entirely (round-2 PoC // against e543b64). A keyless caller (session/anonymous) is a no-op here. - const policy = await enforceApiKeyPolicy(request, null); + const policy = scope.isSessionAuth + ? { apiKeyInfo: null, rejection: null } + : await enforceApiKeyPolicy(request, null); if (policy.rejection) return policy.rejection; const { id } = await params; @@ -56,7 +58,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: // ANY batch — the old inline check 404'd every dashboard cancel of a // key-owned batch (#13683) — a key cancels its own, and a null-owner batch // is denied to a foreign key and to an anonymous caller (GHSA-2jm2-mpx8-6523). - if (!batch || !canAccessOwnedRecord({ ...scope, apiKeyId: effectiveApiKeyId }, batch.apiKeyId)) { + if ( + !batch || + !canAccessOwnedRecord( + { + ...scope, + apiKeyId: effectiveApiKeyId, + apiKeyMetadata: scope.apiKeyMetadata ?? policy.apiKeyInfo, + }, + batch.apiKeyId + ) + ) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/src/app/api/v1/batches/[id]/route.ts b/src/app/api/v1/batches/[id]/route.ts index dfdf1b71..55f6c596 100644 --- a/src/app/api/v1/batches/[id]/route.ts +++ b/src/app/api/v1/batches/[id]/route.ts @@ -40,7 +40,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: // on scope.apiKeyId let that transport skip the endpoint allowlist, // schedule, usage cap, rate limit and key quota entirely (round-2 PoC // against e543b64). A keyless caller (session/anonymous) is a no-op here. - const policy = await enforceApiKeyPolicy(request, null); + const policy = scope.isSessionAuth + ? { apiKeyInfo: null, rejection: null } + : await enforceApiKeyPolicy(request, null); if (policy.rejection) return policy.rejection; const { id } = await params; @@ -55,7 +57,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: // Session = operator, key = own rows only, null owner = denied // (GHSA-2jm2-mpx8-6523): the previous local check let ANY caller read or // delete an unowned batch by id. - if (!batch || !canAccessOwnedRecord({ ...scope, apiKeyId: effectiveApiKeyId }, batch.apiKeyId)) { + if ( + !batch || + !canAccessOwnedRecord( + { + ...scope, + apiKeyId: effectiveApiKeyId, + apiKeyMetadata: scope.apiKeyMetadata ?? policy.apiKeyInfo, + }, + batch.apiKeyId + ) + ) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } @@ -91,7 +103,9 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i // on scope.apiKeyId let that transport skip the endpoint allowlist, // schedule, usage cap, rate limit and key quota entirely (round-2 PoC // against e543b64). A keyless caller (session/anonymous) is a no-op here. - const policy = await enforceApiKeyPolicy(request, null); + const policy = scope.isSessionAuth + ? { apiKeyInfo: null, rejection: null } + : await enforceApiKeyPolicy(request, null); if (policy.rejection) return policy.rejection; const { id } = await params; @@ -103,7 +117,17 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i // (LEDGER-27, omni-code-sec round 3). const effectiveApiKeyId = resolveEffectiveApiKeyId(scope, policy.apiKeyInfo); - if (!batch || !canAccessOwnedRecord({ ...scope, apiKeyId: effectiveApiKeyId }, batch.apiKeyId)) { + if ( + !batch || + !canAccessOwnedRecord( + { + ...scope, + apiKeyId: effectiveApiKeyId, + apiKeyMetadata: scope.apiKeyMetadata ?? policy.apiKeyInfo, + }, + batch.apiKeyId + ) + ) { return NextResponse.json( { error: { message: "Batch not found", type: "invalid_request_error" } }, { status: 404, headers: CORS_HEADERS } diff --git a/tests/unit/api-key-session-precedence-ap0197.test.ts b/tests/unit/api-key-session-precedence-ap0197.test.ts new file mode 100644 index 00000000..853355de --- /dev/null +++ b/tests/unit/api-key-session-precedence-ap0197.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentproxy-scope-ap0197-")); +process.env.DATA_DIR = dir; +process.env.API_KEY_SECRET = "scope-ap0197-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); +const { + canAccessOwnedRecord, + getPolicyAwareApiKeyRequestScope, + resolveEffectiveApiKeyId, + resolveListScope, +} = await import("../../src/app/api/v1/_helpers/apiKeyScope.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("dashboard session remains instance-authority even when an API key id is also present", () => { + const scope = { + apiKey: "incidental-key", + apiKeyId: "tenant-a", + apiKeyMetadata: { scopes: [] } as never, + keyState: "valid" as const, + rejection: null, + isSessionAuth: true, + }; + assert.equal(canAccessOwnedRecord(scope, "tenant-b"), true); + assert.equal(resolveEffectiveApiKeyId(scope, { id: "tenant-a" }), null); + assert.deepEqual(resolveListScope(scope), { mode: "instance" }); +}); + +test("policy-only x-api-key hydrates manage metadata instead of being demoted to owner scope", async () => { + const key = await createApiKey("policy-only-manage", "machine-policy-only", ["manage"]); + const scope = await getPolicyAwareApiKeyRequestScope( + new Request("http://localhost/api/v1/batches", { headers: { "x-api-key": key.key } }) + ); + assert.equal(scope.rejection, null); + assert.equal(scope.apiKeyId, key.id); + assert.equal(scope.apiKeyMetadata?.scopes.includes("manage"), true); + assert.deepEqual(resolveListScope(scope), { mode: "instance" }); +}); diff --git a/tests/unit/apikey-scope-manage-demotion-14481.test.ts b/tests/unit/apikey-scope-manage-demotion-14481.test.ts index 1de22bb1..0e539c26 100644 --- a/tests/unit/apikey-scope-manage-demotion-14481.test.ts +++ b/tests/unit/apikey-scope-manage-demotion-14481.test.ts @@ -12,13 +12,10 @@ * hasManageScope(scope.apiKeyMetadata?.scopes ?? []) in both, same as * the isSessionAuth branch. * - * (B) item 4/LEDGER-19 — canAccessOwnedRecord() returned true the moment - * isSessionAuth was true, BEFORE checking the apiKeyId override the - * caller passed in — so a request carrying both a dashboard session - * cookie and a foreign tenant's API key was authorized against that - * tenant's file, even though the resulting batch/record is attributed - * to the KEY, not the session. Fixed by checking the apiKeyId override - * first; a pure session caller (no override) is still unrestricted. + * (B) AP-ISS-0197 supersedes the earlier LEDGER-19 precedence: an + * authenticated dashboard session is instance-operator authority even + * when an incidental API-key header is also present. Key-only callers + * remain owner-scoped. * * (C) item 5/LEDGER-20/21 — listBatches()/listFiles() resolved the `after` * cursor via getBatch()/getFile(), which apply NO owner filter, so a @@ -106,8 +103,8 @@ describe("#14481 (A) manage-scope key gets instance-wide access on /v1/files and }); }); -describe("#14481 (B) session+key precedence — the apiKeyId override is checked before isSessionAuth", () => { - it("DENIES a session+foreign-key request access to a file it does not own", () => { +describe("AP-ISS-0197 session+key precedence — dashboard session remains instance authority", () => { + it("ALLOWS a session+incidental-key request access to another key-owned file", () => { const scope: ApiKeyRequestScope = { apiKey: "tenant-a-key-value", apiKeyId: "tenant-a-key-id", @@ -116,7 +113,7 @@ describe("#14481 (B) session+key precedence — the apiKeyId override is checked rejection: null, isSessionAuth: true, }; - assert.strictEqual(canAccessOwnedRecord(scope, "tenant-b-key-id"), false); + assert.strictEqual(canAccessOwnedRecord(scope, "tenant-b-key-id"), true); }); it("still ALLOWS a session+key request access to that key's OWN file", () => { diff --git a/tests/unit/batch-item-session-scope-ap0197.test.ts b/tests/unit/batch-item-session-scope-ap0197.test.ts new file mode 100644 index 00000000..5e35754a --- /dev/null +++ b/tests/unit/batch-item-session-scope-ap0197.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { SignJWT } from "jose"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentproxy-batch-item-ap0197-")); +process.env.DATA_DIR = dir; +process.env.API_KEY_SECRET = "batch-item-ap0197-api-secret"; +process.env.JWT_SECRET = "batch-item-ap0197-jwt-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); +const { createFile } = await import("../../src/lib/db/files.ts"); +const { createBatch } = await import("../../src/lib/db/batches.ts"); +const batchItem = await import("../../src/app/api/v1/batches/[id]/route.ts"); + +async function sessionCookie(): Promise { + const jwt = await new SignJWT({ authenticated: true, sub: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.JWT_SECRET)); + return `auth_token=${jwt}`; +} + +function seed(apiKeyId: string, label: string) { + const file = createFile({ filename: `${label}.jsonl`, purpose: "batch", bytes: 2, apiKeyId }); + return createBatch({ + endpoint: "/v1/chat/completions", + completionWindow: "24h", + inputFileId: file.id, + status: "completed", + apiKeyId, + }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("dashboard session ignores an incidental stale bearer key on batch item access", async () => { + const owner = await createApiKey("batch-owner", "batch-owner-machine", []); + const batch = seed(owner.id, "stale-session"); + const response = await batchItem.GET( + new Request(`http://localhost/api/v1/batches/${batch.id}`, { + headers: { + cookie: await sessionCookie(), + Authorization: "Bearer sk-omni-stale-rotated-key-ap0197", + }, + }), + { params: Promise.resolve({ id: batch.id }) } + ); + assert.equal(response.status, 200); +}); + +test("policy-only manage x-api-key can access another key's batch", async () => { + const manager = await createApiKey("batch-manager", "batch-manager-machine", ["manage"]); + const owner = await createApiKey("batch-owner-two", "batch-owner-machine-two", []); + const batch = seed(owner.id, "manage-policy-only"); + const response = await batchItem.GET( + new Request(`http://localhost/api/v1/batches/${batch.id}`, { + headers: { "x-api-key": manager.key }, + }), + { params: Promise.resolve({ id: batch.id }) } + ); + assert.equal(response.status, 200); +}); diff --git a/tests/unit/batches-delete-completed-route-scope.test.ts b/tests/unit/batches-delete-completed-route-scope.test.ts index 421db206..1d77f695 100644 --- a/tests/unit/batches-delete-completed-route-scope.test.ts +++ b/tests/unit/batches-delete-completed-route-scope.test.ts @@ -11,11 +11,10 @@ * - an inference key must only sweep its own completed batches and the response * must report 0 deletions when it owns none — proven against an un-scoped * `{ allTenants: true }` regression (the "flip" recorded in the PR notes); - * - a presented API key always scopes the sweep to that key, even alongside a - * dashboard session cookie (the key wins, like GET /v1/batches); only a - * session WITHOUT a key sweeps the whole instance; - * - a presented key that does not resolve (deleted/rotated/mistyped) is rejected - * with 401 even when a session cookie is also present (fail closed); + * - a dashboard session is instance-operator authority even when an incidental + * API-key header is also present (AP-ISS-0197); key-only calls stay owner-scoped; + * - unresolved/invalid presented keys fail closed for key-only callers; a valid + * dashboard session does not get narrowed or invalidated by an incidental key; * - a presented key that resolves but is no longer VALID (revoked, deactivated, * banned or expired) is rejected with 401 too — existence of the row is not * authorization (CWE-613); the 401 body is the `buildErrorBody()` shape; @@ -136,7 +135,7 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp assert.strictEqual(getFile(other.file.id), null, "session sweep soft-deletes the files too"); }); - it("a request carrying BOTH a session cookie and an API key is scoped to the key (the key wins, like GET /v1/batches)", async () => { + it("a request carrying BOTH a dashboard session and an API key sweeps the whole instance", async () => { const keyA = await createApiKey("wvxc-route-both-a", "machine-wvxc-ba", []); const keyB = await createApiKey("wvxc-route-both-b", "machine-wvxc-bb", []); const own = seedCompletedBatch(keyA.id, "wvxc-route-both-own"); @@ -149,21 +148,13 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp }); assert.strictEqual(res.status, 200); - assert.strictEqual(body.deletedBatches, 1, "only key A's own completed batch is swept"); - assert.strictEqual(getBatch(own.batch.id), null, "key A's own batch is swept"); - assert.ok( - getBatch(other.batch.id), - "key B's batch survives — a presented key never widens the sweep" - ); - assert.ok(getBatch(unowned.batch.id), "the unowned batch survives a key-scoped sweep"); - assert.strictEqual( - getFileContent(other.file.id)?.toString(), - "wvxc-route-both-other", - "key B's file content is intact" - ); + assert.strictEqual(body.deletedBatches, 3, "dashboard session remains instance-wide"); + assert.strictEqual(getBatch(own.batch.id), null); + assert.strictEqual(getBatch(other.batch.id), null); + assert.strictEqual(getBatch(unowned.batch.id), null); }); - it("rejects a presented API key that does not resolve with 401 — even alongside a session cookie — and deletes nothing", async () => { + it("dashboard session ignores an incidental unresolvable API key and still sweeps instance-wide", async () => { const keyB = await createApiKey("wvxc-route-unknown-b", "machine-wvxc-ub", []); const other = seedCompletedBatch(keyB.id, "wvxc-route-unknown-other"); @@ -172,18 +163,9 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp cookie: await sessionCookie(), }); - assert.strictEqual( - res.status, - 401, - "an unresolvable key must fail closed, not fall through to the session" - ); - assert.match(body.error?.message ?? "", /Invalid API key/); - assert.ok(getBatch(other.batch.id), "nothing is swept on a rejected credential"); - assert.strictEqual( - getFileContent(other.file.id)?.toString(), - "wvxc-route-unknown-other", - "file content is intact on a rejected credential" - ); + assert.strictEqual(res.status, 200); + assert.strictEqual(body.deletedBatches, 1); + assert.strictEqual(getBatch(other.batch.id), null); }); it("rejects a REVOKED key with 401 — the row still exists but is no longer valid — and deletes nothing", async () => { @@ -229,7 +211,7 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp assert.ok(getBatch(own.batch.id), "nothing is swept with a banned key"); }); - it("rejects an EXPIRED key with 401 — even alongside a session cookie — and deletes nothing", async () => { + it("dashboard session ignores an incidental expired API key and still sweeps instance-wide", async () => { const keyA = await createApiKey("wvxc-route-expired-a", "machine-wvxc-ea", []); const own = seedCompletedBatch(keyA.id, "wvxc-route-expired-own"); const unowned = seedCompletedBatch(null, "wvxc-route-expired-unowned"); @@ -240,14 +222,13 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp cookie: await sessionCookie(), }); - assert.strictEqual( - res.status, - 401, - "an expired key must fail closed, not fall through to the session" + assert.strictEqual(res.status, 200); + assert.ok( + (body.deletedBatches ?? 0) >= 2, + "session sweep includes current and previously retained completed batches" ); - assert.strictEqual(body.error?.message, "Invalid API key"); - assert.ok(getBatch(own.batch.id), "nothing is swept with an expired key"); - assert.ok(getBatch(unowned.batch.id), "the session branch is never reached"); + assert.strictEqual(getBatch(own.batch.id), null); + assert.strictEqual(getBatch(unowned.batch.id), null); }); it("rejects an unauthenticated request with 401 and deletes nothing", async () => { From fab384c27b15110da85e48529c7332f041885e08 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:45:24 +0700 Subject: [PATCH 07/16] fix(rerank): retry strict local request dialects --- .../api/v1/_shared/rerankLocalNodeShapes.ts | 39 +++++----- src/app/api/v1/rerank/route.ts | 36 +++++---- tests/unit/rerank-local-node-shapes.test.ts | 76 ++++++++++++++++--- 3 files changed, 107 insertions(+), 44 deletions(-) diff --git a/src/app/api/v1/_shared/rerankLocalNodeShapes.ts b/src/app/api/v1/_shared/rerankLocalNodeShapes.ts index f6440ba0..4375078e 100644 --- a/src/app/api/v1/_shared/rerankLocalNodeShapes.ts +++ b/src/app/api/v1/_shared/rerankLocalNodeShapes.ts @@ -21,10 +21,9 @@ * `score`-only response left the memory engine — which reads `relevance_score` — with * undefined scores. These two pure helpers close that gap: * - * - {@link buildLocalRerankRequestBody} sends both spellings (`documents` + `texts`, - * `return_documents` + `return_text`). Every server named above ignores the fields it - * does not know (TEI's request struct is not `deny_unknown_fields`; the OpenAI-shaped - * servers tolerate extras), so one body serves all of them. + * - {@link buildLocalRerankRequestBody} emits one strict dialect at a time. The route + * sends Cohere/OpenAI first and performs one bounded TEI retry only after a + * schema-validation response, so strict servers never receive unknown fields. * - {@link normalizeLocalRerankResponse} folds any of the observed response shapes into * the Cohere envelope, backfilling `relevance_score` from `score`, sorting by score, * honouring `top_n`, and attaching `document.text` when the caller asked for documents @@ -62,25 +61,31 @@ export function documentText(doc: unknown): string { return doc === null || doc === undefined ? "" : String(doc); } -/** - * Build the upstream request body for a local rerank node. Includes both the - * Cohere/OpenAI spelling (`documents`, `return_documents`) and the TEI spelling - * (`texts`, `return_text`) so the same body works against every known local server. - */ -export function buildLocalRerankRequestBody(input: LocalRerankRequestInput) { - const texts = input.documents.map(documentText); +export type LocalRerankDialect = "cohere" | "tei"; + +/** Build one strict upstream request dialect; never mix unknown fields across dialects. */ +export function buildLocalRerankRequestBody( + input: LocalRerankRequestInput, + dialect: LocalRerankDialect = "cohere" +): Record { const topN = input.top_n || input.documents.length; - // Cohere default: documents are only echoed back when the caller asks for them. const returnDocuments = input.return_documents === true; - return { + const common = { model: input.model, query: input.query, - documents: input.documents, top_n: topN, + }; + if (dialect === "tei") { + return { + ...common, + texts: input.documents.map(documentText), + return_text: returnDocuments, + }; + } + return { + ...common, + documents: input.documents, return_documents: returnDocuments, - // TEI spelling - texts, - return_text: returnDocuments, }; } diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index 4dcb2ba1..768e7e4f 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -181,32 +181,32 @@ async function postHandler(request, _context) { const token = credentials?.apiKey || credentials?.accessToken; const startTime = Date.now(); - const upstreamBody = JSON.stringify( - buildLocalRerankRequestBody({ - model: localModel, - query: body.query, - documents: body.documents, - top_n: body.top_n as number | undefined, - return_documents: body.return_documents as boolean | undefined, - }) - ); - const upstreamInit: RequestInit = { + const rerankInput = { + model: localModel, + query: body.query, + documents: body.documents, + top_n: body.top_n as number | undefined, + return_documents: body.return_documents as boolean | undefined, + }; + const makeUpstreamInit = (dialect: "cohere" | "tei"): RequestInit => ({ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: upstreamBody, - }; + body: JSON.stringify(buildLocalRerankRequestBody(rerankInput, dialect)), + }); try { - let res = await fetch(localProvider.baseUrl, upstreamInit); + let activeUrl = localProvider.baseUrl; + let res = await fetch(activeUrl, makeUpstreamInit("cohere")); - // Some local providers (e.g. Infinity, TEI) mount at /rerank rather than /v1/rerank + // Some local providers (e.g. Infinity, TEI) mount at /rerank rather than /v1/rerank. if (res.status === 404 && localProvider.baseUrl.endsWith("/v1/rerank")) { const fallbackUrl = localProvider.baseUrl.replace(/\/v1\/rerank$/, "/rerank"); try { - const fallbackRes = await fetch(fallbackUrl, upstreamInit); + const fallbackRes = await fetch(fallbackUrl, makeUpstreamInit("cohere")); if (fallbackRes.ok || fallbackRes.status !== 404) { + activeUrl = fallbackUrl; res = fallbackRes; } } catch { @@ -214,6 +214,12 @@ async function postHandler(request, _context) { } } + // Strict servers can reject unknown request fields. Retry exactly once with + // TEI spellings only when the response indicates request-schema validation. + if (res.status === 400 || res.status === 422) { + res = await fetch(activeUrl, makeUpstreamInit("tei")); + } + if (!res.ok) { const errData = await res.json().catch(() => ({})); const errorMessage = diff --git a/tests/unit/rerank-local-node-shapes.test.ts b/tests/unit/rerank-local-node-shapes.test.ts index bb4299c4..40d71c3f 100644 --- a/tests/unit/rerank-local-node-shapes.test.ts +++ b/tests/unit/rerank-local-node-shapes.test.ts @@ -26,7 +26,7 @@ const { POST } = await import("../../src/app/api/v1/rerank/route.ts"); const DOCS = ["a cat is a small animal", "the stock market fell", { text: "cats purr" }]; test.describe("buildLocalRerankRequestBody", () => { - test("sends both the Cohere/OpenAI and the TEI spellings", () => { + test("builds strict Cohere/OpenAI and TEI request bodies without cross-dialect fields", () => { const body = buildLocalRerankRequestBody({ model: "bge-reranker-v2-m3", query: "what is a cat", @@ -37,10 +37,25 @@ test.describe("buildLocalRerankRequestBody", () => { assert.equal(body.model, "bge-reranker-v2-m3"); assert.equal(body.query, "what is a cat"); assert.deepEqual(body.documents, DOCS); - assert.deepEqual(body.texts, ["a cat is a small animal", "the stock market fell", "cats purr"]); + assert.equal("texts" in body, false); assert.equal(body.top_n, 2); assert.equal(body.return_documents, false); - assert.equal(body.return_text, false); + assert.equal("return_text" in body, false); + + const tei = buildLocalRerankRequestBody( + { + model: "bge-reranker-v2-m3", + query: "what is a cat", + documents: DOCS, + top_n: 2, + return_documents: false, + }, + "tei" + ); + assert.equal("documents" in tei, false); + assert.deepEqual(tei.texts, ["a cat is a small animal", "the stock market fell", "cats purr"]); + assert.equal("return_documents" in tei, false); + assert.equal(tei.return_text, false); }); test("defaults top_n to the document count and return_documents to false (Cohere default)", () => { @@ -50,7 +65,7 @@ test.describe("buildLocalRerankRequestBody", () => { const body = buildLocalRerankRequestBody({ model: "m", query: "q", documents: DOCS }); assert.equal(body.top_n, 3); assert.equal(body.return_documents, false); - assert.equal(body.return_text, false); + assert.equal("return_text" in body, false); const explicit = buildLocalRerankRequestBody({ model: "m", query: "q", @@ -58,7 +73,7 @@ test.describe("buildLocalRerankRequestBody", () => { return_documents: true, }); assert.equal(explicit.return_documents, true); - assert.equal(explicit.return_text, true); + assert.equal("return_text" in explicit, false); }); test("documentText flattens strings, {text} objects, and other values", () => { @@ -219,9 +234,12 @@ test.describe("POST /v1/rerank against a TEI-shaped local node", () => { if (String(url).endsWith("/v1/rerank")) { return new Response("Not Found", { status: 404 }); } - // TEI: requires `texts`, answers a bare array sorted by score, `text` when asked. - if (!Array.isArray(body.texts)) { - return new Response(JSON.stringify({ error: "missing field `texts`" }), { status: 422 }); + // Strict TEI: rejects Cohere-only fields, then accepts the one bounded + // alternate-shape retry. + if ("documents" in body || !Array.isArray(body.texts)) { + return new Response(JSON.stringify({ error: "unknown field `documents`" }), { + status: 422, + }); } return new Response( JSON.stringify([ @@ -254,12 +272,46 @@ test.describe("POST /v1/rerank against a TEI-shaped local node", () => { assert.deepEqual(data, { results: [{ index: 0, relevance_score: 0.9875887 }] }); assert.deepEqual( calls.map((c) => c.url), - ["http://127.0.0.1:8081/v1/rerank", "http://127.0.0.1:8081/rerank"] + [ + "http://127.0.0.1:8081/v1/rerank", + "http://127.0.0.1:8081/rerank", + "http://127.0.0.1:8081/rerank", + ] ); - assert.deepEqual(calls[1].body.texts, ["a cat is a small animal", "the stock market fell"]); assert.deepEqual(calls[1].body.documents, ["a cat is a small animal", "the stock market fell"]); - assert.equal(calls[1].body.return_text, false); - assert.equal(calls[1].body.model, "bge-reranker-v2-m3"); + assert.equal("texts" in calls[1].body, false); + assert.deepEqual(calls[2].body.texts, ["a cat is a small animal", "the stock market fell"]); + assert.equal("documents" in calls[2].body, false); + assert.equal(calls[2].body.return_text, false); + assert.equal(calls[2].body.model, "bge-reranker-v2-m3"); + }); + + test("strict Cohere server never receives TEI-only fields", async () => { + let seen: Record | undefined; + globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => { + seen = JSON.parse(String(init?.body || "{}")); + if ("texts" in seen || "return_text" in seen) { + return new Response(JSON.stringify({ error: "unknown field" }), { status: 400 }); + } + return Response.json({ results: [{ index: 0, relevance_score: 0.9 }] }); + }; + + const res = await POST( + new Request("http://localhost/v1/rerank", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "tei/bge-reranker-v2-m3", + query: "cat", + documents: ["cat"], + }), + }), + {} + ); + assert.equal(res.status, 200); + assert.ok(seen); + assert.equal("texts" in seen, false); + assert.equal("return_text" in seen, false); }); test("a `score`-only gateway on /v1/rerank yields relevance_score for clients", async () => { From 3ba282f9322d58efb5cfeed3686d213a1bee5bd4 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:48:13 +0700 Subject: [PATCH 08/16] fix(chat): enforce policy before self-hosted dispatch --- src/app/api/v1/chat/completions/route.ts | 51 ++++++++++----- .../self-hosted-chat-policy-ap0197.test.ts | 65 +++++++++++++++++++ 2 files changed, 98 insertions(+), 18 deletions(-) create mode 100644 tests/unit/self-hosted-chat-policy-ap0197.test.ts diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index dc1a717d..3f946d7a 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -4,7 +4,12 @@ import { handleChat } from "@/sse/handlers/chat"; import { generateRequestId } from "@/shared/utils/requestId"; import { resolveIncomingCorrelationId } from "@/shared/utils/correlationPreserve.ts"; import { errorResponse } from "@agentproxy/open-sse/utils/error.ts"; -import { handleSelfHostedCompletions } from "@agentproxy/open-sse/services/selfHostedEntry.ts"; +import { + CONFIG_ENV as SELF_HOSTED_CONFIG_ENV, + CONFIG_FILE_ENV as SELF_HOSTED_CONFIG_FILE_ENV, + handleSelfHostedCompletions, +} from "@agentproxy/open-sse/services/selfHostedEntry.ts"; +import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; import { initTranslators } from "@agentproxy/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { acceptHeaderForcesStream } from "@agentproxy/open-sse/utils/aiSdkCompat.ts"; @@ -168,28 +173,39 @@ export async function POST(request) { ); } - const { blocked, result } = injectionGuard(parsedBody); - if (blocked) { - return finishAdmission( - new Response( - JSON.stringify({ - error: { - message: "Request blocked: potential prompt injection detected", - type: "injection_detected", - code: "SECURITY_001", - detections: result.detections.length, - }, - }), - { status: 400, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } } - ) - ); - } + const { blocked, result } = injectionGuard(parsedBody); + if (blocked) { + return finishAdmission( + new Response( + JSON.stringify({ + error: { + message: "Request blocked: potential prompt injection detected", + type: "injection_detected", + code: "SECURITY_001", + detections: result.detections.length, + }, + }), + { status: 400, headers: { ...CORS_HEADERS, "Content-Type": "application/json" } } + ) + ); + } // Self-hosted unified entry (D4 — RIC-738): when a provider config is // present, divert BEFORE the cloud-only model retirement/alias checks so // self-hosted model ids (`local/llama3`, `ollama/qwen2`, ...) never trip // cloud-peer 410s or alias rewrites. Config-absent requests proceed to the // normal cloud pipeline unchanged. + const selfHostedConfigured = Boolean( + process.env[SELF_HOSTED_CONFIG_ENV] || process.env[SELF_HOSTED_CONFIG_FILE_ENV] + ); + if (selfHostedConfigured) { + const requestedModel = typeof parsedBody.model === "string" ? parsedBody.model : null; + const selfHostedPolicy = await enforceApiKeyPolicy(request, requestedModel); + if (selfHostedPolicy.rejection) { + return finishAdmission(selfHostedPolicy.rejection); + } + } + const selfHostedResponse = await handleSelfHostedCompletions(request, parsedBody); if (selfHostedResponse) { return finishAdmission(selfHostedResponse); @@ -244,7 +260,6 @@ export async function POST(request) { /* swallow — fall through with original model */ }); } - } } catch (error) { console.error("[SECURITY] Prompt injection guard failed:", error); diff --git a/tests/unit/self-hosted-chat-policy-ap0197.test.ts b/tests/unit/self-hosted-chat-policy-ap0197.test.ts new file mode 100644 index 00000000..7836954b --- /dev/null +++ b/tests/unit/self-hosted-chat-policy-ap0197.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agentproxy-selfhost-policy-")); +process.env.DATA_DIR = dir; +process.env.API_KEY_SECRET = "selfhost-policy-secret"; +process.env.OMNIROUTE_SELF_HOSTED_PROVIDERS = ` +providers: + - id: local + kind: local + baseUrl: https://selfhost.example/v1 + model: llama3 +`; +delete process.env.OMNIROUTE_SELF_HOSTED_API_KEY; + +const core = await import("../../src/lib/db/core.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); +const chatRoute = await import("../../src/app/api/v1/chat/completions/route.ts"); +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + delete process.env.OMNIROUTE_SELF_HOSTED_PROVIDERS; + core.resetDbInstance(); + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("self-hosted early dispatch cannot bypass a restricted API-key model policy", async () => { + const key = await createApiKey("selfhost-restricted", "selfhost-machine", [], { + modelAccessMode: "restricted", + allowedModels: ["allowed/model"], + }); + let upstreamCalls = 0; + globalThis.fetch = (async () => { + upstreamCalls += 1; + return Response.json({ + id: "bypassed", + choices: [{ message: { role: "assistant", content: "x" } }], + }); + }) as typeof fetch; + + const response = await chatRoute.POST( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${key.key}`, + }, + body: JSON.stringify({ + model: "local/llama3", + messages: [{ role: "user", content: "hi" }], + }), + }) + ); + + assert.notEqual(response.status, 200); + assert.equal(upstreamCalls, 0, "policy rejection must happen before self-hosted transport"); +}); From b9cebc639144df6df184fc67da83746f9e7abd26 Mon Sep 17 00:00:00 2001 From: KitDev8 Date: Wed, 14 Oct 2026 08:49:44 +0700 Subject: [PATCH 09/16] fix(gemini): bound inline media payloads --- src/shared/validation/schemas/apiV1.ts | 5 +- src/shared/validation/schemas/gemini.ts | 55 +++++++++++++++++++- tests/unit/v1beta-gemini-inline-data.test.ts | 47 +++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/shared/validation/schemas/apiV1.ts b/src/shared/validation/schemas/apiV1.ts index 1f6d3331..34f5a96f 100644 --- a/src/shared/validation/schemas/apiV1.ts +++ b/src/shared/validation/schemas/apiV1.ts @@ -35,11 +35,12 @@ const MAX_EMBEDDING_URL_LENGTH = 2048; const MAX_MEDIA_TYPE_LENGTH = 255; // Four base64 characters encode at most three bytes. Reject by encoded length first // so multi-megabyte oversize payloads never reach format validation. -const MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH = Math.ceil(MAX_EMBEDDING_INLINE_ITEM_BYTES / 3) * 4; +export const MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH = + Math.ceil(MAX_EMBEDDING_INLINE_ITEM_BYTES / 3) * 4; const BASE64_CHUNK_RE = /^[A-Za-z0-9+/]{4}$/; const BASE64_LAST_CHUNK_RE = /^(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/; -function decodedBase64Bytes(data: string): number { +export function decodedBase64Bytes(data: string): number { const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; return (data.length * 3) / 4 - padding; } diff --git a/src/shared/validation/schemas/gemini.ts b/src/shared/validation/schemas/gemini.ts index 06713e5f..425c9f1d 100644 --- a/src/shared/validation/schemas/gemini.ts +++ b/src/shared/validation/schemas/gemini.ts @@ -13,13 +13,48 @@ import { isForbiddenCustomHeaderName, } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; +import { + MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH, + MAX_EMBEDDING_INLINE_ITEM_BYTES, + MAX_EMBEDDING_INLINE_TOTAL_BYTES, + decodedBase64Bytes, +} from "./apiV1.ts"; + +function inlineDataPayloads(part: unknown): string[] { + if (!part || typeof part !== "object" || Array.isArray(part)) return []; + const record = part as Record; + const payloads: string[] = []; + for (const key of ["inlineData", "inline_data"]) { + const inline = record[key]; + if (!inline || typeof inline !== "object" || Array.isArray(inline)) continue; + const data = (inline as { data?: unknown }).data; + if (typeof data === "string") payloads.push(data); + } + return payloads; +} +function decodedInlineBytesFromPart(part: unknown): number { + return inlineDataPayloads(part).reduce((total, data) => total + decodedBase64Bytes(data), 0); +} export const geminiPartSchema = z .object({ text: z.string().optional(), }) - .catchall(z.unknown()); + .catchall(z.unknown()) + .superRefine((part, ctx) => { + for (const data of inlineDataPayloads(part)) { + if ( + data.length > MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH || + decodedBase64Bytes(data) > MAX_EMBEDDING_INLINE_ITEM_BYTES + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "decoded inline media must not exceed 8 MiB", + }); + } + } + }); export const geminiContentSchema = z .object({ @@ -56,4 +91,20 @@ export const v1betaGeminiGenerateSchema = z path: [], }); } - }); \ No newline at end of file + + const allParts = [ + ...(value.contents ?? []).flatMap((content) => content.parts ?? []), + ...(value.systemInstruction?.parts ?? []), + ]; + const totalInlineBytes = allParts.reduce( + (total, part) => total + decodedInlineBytesFromPart(part), + 0 + ); + if (totalInlineBytes > MAX_EMBEDDING_INLINE_TOTAL_BYTES) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "decoded inline media must not exceed 16 MiB per request", + path: [], + }); + } + }); diff --git a/tests/unit/v1beta-gemini-inline-data.test.ts b/tests/unit/v1beta-gemini-inline-data.test.ts index 720b9065..7263b7a2 100644 --- a/tests/unit/v1beta-gemini-inline-data.test.ts +++ b/tests/unit/v1beta-gemini-inline-data.test.ts @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; const { convertGeminiToInternal } = await import("../../src/app/api/v1beta/models/[...path]/convertGeminiToInternal.ts"); +const { v1betaGeminiGenerateSchema } = + await import("../../src/shared/validation/schemas/gemini.ts"); +const { MAX_EMBEDDING_INLINE_ITEM_BYTES } = + await import("../../src/shared/validation/schemas/apiV1.ts"); const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"; @@ -80,3 +84,46 @@ test("v1beta does not emit assistant inlineData as image content", () => { ); assert.deepEqual(out.messages, [{ role: "assistant", content: "done" }]); }); + +test("v1beta rejects a single inlineData blob larger than 8 MiB decoded", () => { + const data = Buffer.alloc(MAX_EMBEDDING_INLINE_ITEM_BYTES + 1).toString("base64"); + const parsed = v1betaGeminiGenerateSchema.safeParse({ + contents: [{ role: "user", parts: [{ inlineData: { mimeType: "image/png", data } }] }], + }); + assert.equal(parsed.success, false); + if (!parsed.success) { + assert.match(parsed.error.issues.map((issue) => issue.message).join(" "), /8 MiB/i); + } +}); + +test("v1beta rejects more than 16 MiB aggregate inline media", () => { + const exactlyEightMiB = Buffer.alloc(MAX_EMBEDDING_INLINE_ITEM_BYTES).toString("base64"); + const parsed = v1betaGeminiGenerateSchema.safeParse({ + contents: [ + { + role: "user", + parts: [ + { inlineData: { mimeType: "image/png", data: exactlyEightMiB } }, + { inline_data: { mime_type: "image/png", data: exactlyEightMiB } }, + { inlineData: { mimeType: "image/png", data: "AAAA" } }, + ], + }, + ], + }); + assert.equal(parsed.success, false); + if (!parsed.success) { + assert.match(parsed.error.issues.map((issue) => issue.message).join(" "), /16 MiB/i); + } +}); + +test("v1beta still accepts bounded inlineData", () => { + const parsed = v1betaGeminiGenerateSchema.safeParse({ + contents: [ + { + role: "user", + parts: [{ text: "describe" }, { inlineData: { mimeType: "image/png", data: PNG } }], + }, + ], + }); + assert.equal(parsed.success, true); +}); From 3e69c02447f40301be7ef87edb6f0a6e2b6ffacc Mon Sep 17 00:00:00 2001 From: KitDev25 Date: Thu, 15 Oct 2026 18:27:30 +0700 Subject: [PATCH 10/16] test(audit): close AP-ISS-0197 dispositions --- docs/audits/ap-iss-0197-dispositions.md | 53 +++++++++++++++++++ ...hboard-session-invalidation-ap0126.test.ts | 31 +++++++++++ 2 files changed, 84 insertions(+) create mode 100644 docs/audits/ap-iss-0197-dispositions.md diff --git a/docs/audits/ap-iss-0197-dispositions.md b/docs/audits/ap-iss-0197-dispositions.md new file mode 100644 index 00000000..5e8c07bc --- /dev/null +++ b/docs/audits/ap-iss-0197-dispositions.md @@ -0,0 +1,53 @@ +# AP-ISS-0197 — API-route security/lifecycle disposition ledger + +Revalidated by KitDev25 Gen10/S1 against protected `origin/main` baseline +`08bbb79b5dcbf3823e8826220c771cd7856ae054` on 2026-10-14. The older accepted-main SHA +embedded in the issue file was not used as execution authority. Reproduced defects were +changed only after an observed RED regression and each fix is tied to the immutable commit +listed below. + +| Source ID | Terminal disposition | Current-code evidence | RED/GREEN or source evidence | Fix commit | +| ------------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| `4233356532` | **NOT_REPRODUCED / CURRENT-SAFE** | `src/app/api/auth/logout/route.ts`; current settings write path preserved concurrent revocations under route-level interleaving. | `tests/unit/dashboard-session-invalidation-ap0126.test.ts` — `concurrent sign-outs preserve both session revocations` passes with both JWTs invalidated. No production mutation. | — | +| `4233356573` | **NOT_REPRODUCED / CURRENT-SAFE** | `src/app/api/internal/codex-responses-ws/route.ts:51` declares `const log = logger("RESPONSES_WS")`; the proxy-resolution catch therefore cannot fail from the reported missing identifier. | Direct source revalidation at baseline SHA; existing Codex WS focused suite remains part of final verification. | — | +| `4233356588` | **NOT_REPRODUCED / CURRENT-SAFE** | `src/app/api/internal/codex-responses-ws/route.ts:412-428,549-574,612-621,692` — post-acquisition error paths release the lease and successful context construction includes `leaseId` plus the required fields. | Direct control-flow revalidation plus Codex WS lease-contract suite in final verification. No unreachable cleanup added. | — | +| `4233356660` | **REPRODUCED / FIXED** | `src/app/api/memory/rerank-providers/route.ts` optional local-node listing could reject the whole route. | RED source contract failed on unguarded lookup; GREEN `tests/unit/rerank-providers-route.test.ts` 2/2 after isolating only that optional lookup. | `f706cb1903` | +| `4233356681` | **REPRODUCED / FIXED** | `src/lib/monitoring/observability.ts`, `src/app/api/monitoring/health/route.ts` allowed literal private/link-local target and redirect-capable credentialed fetch. | RED reached `169.254.169.254`; GREEN deep-health suite 8/8 with public URL validation, pinned-DNS safe fetch and redirects disabled. | `96810ecb51` | +| `4233356701` | **REPRODUCED / FIXED** | `src/app/api/monitoring/health/route.ts` attached an explicitly expired cached verdict. | RED returned seeded expired verdict; GREEN omits it when `Date.now() > expiresAt`, covered in same 8/8 suite. | `96810ecb51` | +| `4233360138` | **REPRODUCED / FIXED** | OpenWA `auto-start`, `auto-restart-adopted`, `restart` accepted unauthenticated state/process control. | RED route test observed unauthenticated success/non-auth responses; GREEN all OpenWA HTTP controls require management auth. | `2d45137762` | +| `4233360580` | **REPRODUCED / FIXED** | OpenWA start/stop/restart/install/update/status surfaces lacked user auth. | RED observed `[204,204,409,409,200,200]` across representative unauthenticated handlers; GREEN behavior+contract tests 2/2 across all eight routes. | `2d45137762` | +| `4233361013` | **REPRODUCED / FIXED** | `src/app/api/settings/cache-config/route.ts` returned stored `semanticCacheEmbeddingApiKey`. | RED GET response contained the stored secret; GREEN cache tests confirm it is omitted while PUT remains supported. | `58a4f99928` | +| `4233361460` | **REPRODUCED / FIXED** | `src/app/api/settings/cache-config/test-embedding/route.ts` accepted but ignored `dimensions`. | RED upstream body lacked `dimensions` and mismatch returned success; GREEN forwards dimension and returns `ok:false` on width mismatch. | `58a4f99928` | +| `4233361942` | **REPRODUCED / FIXED** | Same test-embedding route paired caller `baseUrl` with saved credential and allowed literal link-local target. | RED attacker URL received saved auth and link-local reached transport; GREEN explicit URLs must be safe-public and cannot inherit persistent credentials. | `58a4f99928` | +| `4233362434` | **REPRODUCED / FIXED** | `src/app/api/settings/require-login/route.ts` peeked token before write and consumed after write. | RED two concurrent remote bootstrap requests both returned 200; GREEN exactly one crosses persistence and the other returns 403; full bootstrap suite 16/16. | `b6d3071f24` | +| `4233363247` | **NOT_APPLICABLE / FALSE PREMISE** | `src/app/api/usage/glm-reset-card/route.ts` already requires management auth; `src/lib/db/core.ts` `provider_connections` schema has no tenant/user/owner column or modeled ownership boundary. | Source/schema revalidation: there is no per-tenant connection authority to enforce here. Inventing tenancy would be a product/model change, not a hardening fix. | — | +| `4233363743` | **REPRODUCED / FIXED** | `src/app/api/v1/_helpers/apiKeyScope.ts` populated policy-resolved ID without metadata. | RED policy-only `x-api-key` lost `manage`; GREEN metadata is hydrated and instance management scope is retained. | `d396f64298` | +| `4233364219` | **REPRODUCED / FIXED** | Same scope helper checked key identity before dashboard-session authority. | RED session+incidental key lost access to another key-owned record; GREEN session is normalized first as instance-operator authority while key-only callers remain scoped. | `d396f64298` | +| `4233365176` | **REPRODUCED / FIXED** | `src/app/api/v1/_shared/rerankLocalNodeShapes.ts` mixed Cohere and TEI fields in one payload. | RED strict Cohere/TEI tests rejected unknown fields; GREEN 13/13 using strict Cohere first and exactly one TEI retry on 400/422, preserving 404 path fallback. | `fab384c27b` | +| `4233365683` | **REPRODUCED / FIXED** | `src/app/api/v1/batches/[id]/route.ts` re-applied incidental key policy to a valid dashboard session. | RED session+stale bearer returned 401; GREEN returns operator-authorized batch access without weakening key-only rejection. | `d396f64298` | +| `4233366208` | **REPRODUCED / FIXED** | Batch item policy-only key scope lost `manage` metadata before ownership decision. | RED manage key saw 404 on another key's batch; GREEN sees 200 with hydrated policy metadata. | `d396f64298` | +| `4233366654` | **REPRODUCED / FIXED** | `src/app/api/v1/batches/delete-completed/route.ts` inherited key scope even when dashboard session was authenticated. | RED/legacy conflict resolved in favor of AP-ISS-0197: session+key now performs instance sweep; key-only invalid/revoked/deactivated/banned paths remain fail-closed. Combined scope suites 25/25. | `d396f64298` | +| `4233367153` | **REPRODUCED / FIXED** | `src/app/api/v1/chat/completions/route.ts` self-hosted dispatch ran before canonical API-key policy. | RED restricted key reached self-hosted upstream and got 200; GREEN policy rejects before transport and self-hosted + route-shape suites pass 7/7. | `3ba282f932` | +| `4233368115` | **REPRODUCED / FIXED** | `src/shared/validation/schemas/gemini.ts` accepted unbounded `inlineData.data` before `convertGeminiToInternal` amplified it into data URLs. | RED >8 MiB item and >16 MiB aggregate both parsed successfully; GREEN validator rejects both before conversion and bounded payload still passes, 6/6. | `b9cebc6391` | + +## Summary + +- **21/21 terminally dispositioned.** +- **17 reproduced and fixed**, **3 not reproduced/current-safe**, **1 not applicable/false premise**. +- No OCR suggestion was applied without current-code reproduction. +- No Green Release publication is part of this issue. + +## Combined verification — Gen10/S1 + +Fresh pre-integration evidence on the AP-ISS-0197 worktree: + +- Focused 15-file AP-ISS-0197 regression set: **91/91 PASS**. +- `npm run typecheck:core`: PASS. +- `npm run check:open-sse-typecheck`: PASS. +- `npm run check:route-guard-membership`: PASS. +- `npm run check:public-creds`: PASS. +- `npm run test:security`: **7/7 PASS**. +- Changed-file ESLint: **37 files PASS** using the repository suppression bank plus `--pass-on-unpruned-suppressions`. +- Repository-wide `npm run lint`: blocked only by pre-existing unused suppression-count hygiene; a prune check on the only two changed suppressed files (`apiV1.ts`, `gemini.ts`) leaves their suppression entries unchanged. +- `npm run check:secrets`: documented environment skip because `gitleaks` is unavailable. +- Full `npm test`: still running under its original bounded execution at the time this ledger update was written; terminal outcome is recorded in final task evidence rather than fabricated here. diff --git a/tests/unit/dashboard-session-invalidation-ap0126.test.ts b/tests/unit/dashboard-session-invalidation-ap0126.test.ts index 141b0c94..5fe891d2 100644 --- a/tests/unit/dashboard-session-invalidation-ap0126.test.ts +++ b/tests/unit/dashboard-session-invalidation-ap0126.test.ts @@ -157,6 +157,37 @@ test("signing out revokes that session only", async () => { assert.ok(await verifyDashboardSessionToken(otherDevice)); }); +test("concurrent sign-outs preserve both session revocations", async () => { + const sessions = [ + await mintDashboardSessionToken(secret), + await mintDashboardSessionToken(secret), + ]; + const stores = sessions.map((token) => ({ + get: (name: string) => (name === DASHBOARD_SESSION_COOKIE ? { value: token } : undefined), + delete: () => {}, + })); + let index = 0; + const original = logoutRoute.logoutRouteInternals.getCookieStore; + logoutRoute.logoutRouteInternals.getCookieStore = (async () => + stores[index++]) as typeof original; + try { + const responses = await Promise.all( + sessions.map(() => + logoutRoute.POST(new Request("http://localhost/api/auth/logout", { method: "POST" })) + ) + ); + assert.deepEqual( + responses.map((response) => response.status), + [200, 200] + ); + } finally { + logoutRoute.logoutRouteInternals.getCookieStore = original; + } + + assert.equal(await verifyDashboardSessionToken(sessions[0]), null); + assert.equal(await verifyDashboardSessionToken(sessions[1]), null); +}); + test("signing out with an invalid or missing cookie still clears it and revokes nothing", async () => { const deleted: string[] = []; const original = logoutRoute.logoutRouteInternals.getCookieStore; From 357ca1b2df61eb705b5eca54ca763c44dd6110aa Mon Sep 17 00:00:00 2001 From: KitDev2 Date: Thu, 15 Oct 2026 19:45:51 +0700 Subject: [PATCH 11/16] docs(audit): record AP-ISS-0197 verification --- docs/audits/ap-iss-0197-dispositions.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/audits/ap-iss-0197-dispositions.md b/docs/audits/ap-iss-0197-dispositions.md index 5e8c07bc..8d7f05ab 100644 --- a/docs/audits/ap-iss-0197-dispositions.md +++ b/docs/audits/ap-iss-0197-dispositions.md @@ -39,15 +39,27 @@ listed below. ## Combined verification — Gen10/S1 -Fresh pre-integration evidence on the AP-ISS-0197 worktree: +Pre-integration verification on the AP-ISS-0197 worktree: -- Focused 15-file AP-ISS-0197 regression set: **91/91 PASS**. +- Focused AP-ISS-0197 regression set: **91/91 PASS**. - `npm run typecheck:core`: PASS. - `npm run check:open-sse-typecheck`: PASS. - `npm run check:route-guard-membership`: PASS. - `npm run check:public-creds`: PASS. -- `npm run test:security`: **7/7 PASS**. -- Changed-file ESLint: **37 files PASS** using the repository suppression bank plus `--pass-on-unpruned-suppressions`. +- `npm run test:security`: PASS. +- Changed-file ESLint: PASS using the repository suppression bank plus `--pass-on-unpruned-suppressions`. - Repository-wide `npm run lint`: blocked only by pre-existing unused suppression-count hygiene; a prune check on the only two changed suppressed files (`apiV1.ts`, `gemini.ts`) leaves their suppression entries unchanged. -- `npm run check:secrets`: documented environment skip because `gitleaks` is unavailable. -- Full `npm test`: still running under its original bounded execution at the time this ledger update was written; terminal outcome is recorded in final task evidence rather than fabricated here. +- `npm run check:secrets`: environment SKIP because `gitleaks` is unavailable. +- Bounded full `npm test` was not treated as green: at the ~30-minute boundary Node reported **10,973 tests / 6,012 pass / 0 fail / 4,959 cancelled / 2 skipped**. The outer wrapper did not persist `FULL_TEST_RC`; therefore no exit code is fabricated and this run is recorded as incomplete timeout evidence, not a test failure or pass. + +Protected-main integration and exact-head verification: + +- Fetched protected `origin/main` at `166b4cbb52595005522d2aed5d47282a0942918e` and merged it without conflict. +- The sole overlapping file, `open-sse/services/cache/embeddingClient.ts`, preserves both AP-ISS-0195 SSRF/cooldown hardening and AP-ISS-0197 embedding-dimension propagation. +- Post-merge head `315e10e4924293b3a6c7787bd62695948573c9ce`: conservative focused superset **97/97 PASS**, including `tests/unit/embedding-client-ssrf-0195.test.ts` and the chat route-shape gate. +- Post-merge `npm run test:security`: **23/23 PASS**. +- Post-merge core and Open-SSE typechecks: PASS; Open-SSE reports `openSseTypecheckErrors=0`. +- Post-merge route-guard membership and public-credential checks: PASS. +- Post-merge scoped ESLint over AP-ISS-0197 changed TypeScript files: PASS with the repository suppression bank. +- Post-merge `git diff --check`: PASS. +- Hosted PR/check evidence is recorded in the canonical KitControl terminal handoff after publication; this issue is not DONE until that evidence is green/terminal. From 9f642a6456b43c35272b5db775fb408f6edd51f3 Mon Sep 17 00:00:00 2001 From: KitDev25 Date: Thu, 15 Oct 2026 20:01:13 +0700 Subject: [PATCH 12/16] test(authz): preserve session sweep assertions --- tests/unit/batches-delete-completed-route-scope.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/batches-delete-completed-route-scope.test.ts b/tests/unit/batches-delete-completed-route-scope.test.ts index 1d77f695..019647c4 100644 --- a/tests/unit/batches-delete-completed-route-scope.test.ts +++ b/tests/unit/batches-delete-completed-route-scope.test.ts @@ -152,6 +152,7 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp assert.strictEqual(getBatch(own.batch.id), null); assert.strictEqual(getBatch(other.batch.id), null); assert.strictEqual(getBatch(unowned.batch.id), null); + assert.strictEqual(getFile(other.file.id), null, "session sweep soft-deletes key B's file"); }); it("dashboard session ignores an incidental unresolvable API key and still sweeps instance-wide", async () => { @@ -166,6 +167,7 @@ describe("DELETE /api/v1/batches/delete-completed — caller scope (GHSA-wvxc-jp assert.strictEqual(res.status, 200); assert.strictEqual(body.deletedBatches, 1); assert.strictEqual(getBatch(other.batch.id), null); + assert.strictEqual(getFile(other.file.id), null, "session sweep soft-deletes the batch file"); }); it("rejects a REVOKED key with 401 — the row still exists but is no longer valid — and deletes nothing", async () => { From 2f295462565c4113071cb4b784aa6cab2ea2228b Mon Sep 17 00:00:00 2001 From: KitDev25 Date: Thu, 15 Oct 2026 20:03:06 +0700 Subject: [PATCH 13/16] fix(authz): align policy scope metadata type --- src/app/api/v1/_helpers/apiKeyScope.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/api/v1/_helpers/apiKeyScope.ts b/src/app/api/v1/_helpers/apiKeyScope.ts index 76f2116f..5536c447 100644 --- a/src/app/api/v1/_helpers/apiKeyScope.ts +++ b/src/app/api/v1/_helpers/apiKeyScope.ts @@ -10,10 +10,12 @@ import { hasManageScope } from "@/shared/constants/managementScopes"; export type ApiKeyState = "none" | "unresolved" | "invalid" | "valid"; +type ApiKeyScopeMetadata = { id: string; scopes?: string[] }; + export interface ApiKeyRequestScope { apiKey: string | null; apiKeyId: string | null; - apiKeyMetadata: Awaited>; + apiKeyMetadata: ApiKeyScopeMetadata | null; keyState: ApiKeyState; rejection: Response | null; isSessionAuth: boolean; From e9611720ae09c0a534756c85c213c57e2e22c3fe Mon Sep 17 00:00:00 2001 From: KitDev25 Date: Thu, 15 Oct 2026 20:12:09 +0700 Subject: [PATCH 14/16] test(authz): align legacy session ownership guards --- docs/audits/ap-iss-0197-dispositions.md | 2 +- .../files-batches-ownership-2jm2-m3hp.test.ts | 33 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/audits/ap-iss-0197-dispositions.md b/docs/audits/ap-iss-0197-dispositions.md index 8d7f05ab..0bf92d0a 100644 --- a/docs/audits/ap-iss-0197-dispositions.md +++ b/docs/audits/ap-iss-0197-dispositions.md @@ -50,7 +50,7 @@ Pre-integration verification on the AP-ISS-0197 worktree: - Changed-file ESLint: PASS using the repository suppression bank plus `--pass-on-unpruned-suppressions`. - Repository-wide `npm run lint`: blocked only by pre-existing unused suppression-count hygiene; a prune check on the only two changed suppressed files (`apiV1.ts`, `gemini.ts`) leaves their suppression entries unchanged. - `npm run check:secrets`: environment SKIP because `gitleaks` is unavailable. -- Bounded full `npm test` was not treated as green: at the ~30-minute boundary Node reported **10,973 tests / 6,012 pass / 0 fail / 4,959 cancelled / 2 skipped**. The outer wrapper did not persist `FULL_TEST_RC`; therefore no exit code is fabricated and this run is recorded as incomplete timeout evidence, not a test failure or pass. +- Bounded full `npm test` was not treated as green: at the ~30-minute boundary Node reported **10,973 tests / 6,012 pass / 0 fail / 4,959 cancelled / 2 skipped**. The outer wrapper did not persist its terminal return-code marker; therefore no exit code is fabricated and this run is recorded as incomplete timeout evidence, not a test failure or pass. Protected-main integration and exact-head verification: diff --git a/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts b/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts index cafb7d54..72d45a8b 100644 --- a/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts +++ b/tests/unit/files-batches-ownership-2jm2-m3hp.test.ts @@ -182,12 +182,12 @@ function assertInvalidKey401(res: Response, body: ErrorBody, label: string) { } describe("canAccessOwnedRecord — the shared 3-way ownership rule", () => { - it("a dashboard session without a key may act on any record; a mixed key remains scoped", () => { + it("a dashboard session may act on any record even when an incidental key id is present", () => { assert.strictEqual( canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, "key-1"), true ); - assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: "k" }, "key-1"), false); + assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: "k" }, "key-1"), true); assert.strictEqual(canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, null), true); assert.strictEqual( canAccessOwnedRecord({ isSessionAuth: true, apiKeyId: null }, undefined), @@ -234,9 +234,9 @@ describe("GET /api/v1/files + GET /api/v1/batches — caller scope (GHSA-m3hp-hq assert.strictEqual(batches.body.data, undefined, "no batch rows in a 401 body"); }); - it("(b) an invalid/rotated bearer → 401 on both lists — even alongside a session cookie", async () => { + it("(b) an invalid/rotated bearer fails closed key-only but is incidental to a valid dashboard session", async () => { const keyA = await createApiKey("m3hp-b-key", "machine-m3hp-b", []); - seedBatch(keyA.id, "m3hp-b-victim"); + const seeded = seedBatch(keyA.id, "m3hp-b-victim"); const bogus = { Authorization: "Bearer sk-omni-this-key-was-rotated-away-m3hp" }; const files = await listFilesVia(bogus); @@ -246,9 +246,20 @@ describe("GET /api/v1/files + GET /api/v1/batches — caller scope (GHSA-m3hp-hq const withSession = { ...bogus, cookie: await sessionCookie() }; const files2 = await listFilesVia(withSession); - assertInvalidKey401(files2.res, files2.body, "GET /v1/files + session cookie"); + assert.strictEqual(files2.res.status, 200, "dashboard session remains instance authority"); + assert.strictEqual(files2.body.object, "list"); + assert.ok(Array.isArray(files2.body.data)); + assert.ok(files2.body.data!.some((file) => file.id === seeded.file.id)); + const batches2 = await listBatchesVia(withSession); - assertInvalidKey401(batches2.res, batches2.body, "GET /v1/batches + session cookie"); + assert.strictEqual( + batches2.res.status, + 200, + "dashboard session ignores incidental stale bearer" + ); + assert.strictEqual(batches2.body.object, "list"); + assert.ok(Array.isArray(batches2.body.data)); + assert.ok(batches2.body.data!.some((batch) => batch.id === seeded.batch.id)); }); it("(c) key A lists only A's rows — B's and null-owner rows never appear", async () => { @@ -301,7 +312,7 @@ describe("GET /api/v1/files + GET /api/v1/batches — caller scope (GHSA-m3hp-hq } }); - it("(e) a request carrying BOTH a session cookie and key A stays scoped to key A (the key wins)", async () => { + it("(e) a request carrying BOTH a dashboard session and key A remains instance-wide", async () => { const keyA = await createApiKey("m3hp-e-key-a", "machine-m3hp-ea", []); const keyB = await createApiKey("m3hp-e-key-b", "machine-m3hp-eb", []); const own = seedBatch(keyA.id, "m3hp-e-own"); @@ -313,15 +324,15 @@ describe("GET /api/v1/files + GET /api/v1/batches — caller scope (GHSA-m3hp-hq assert.strictEqual(files.res.status, 200); const fileIds = new Set(files.body.data!.map((f) => f.id)); assert.ok(fileIds.has(own.file.id)); - assert.ok(!fileIds.has(other.file.id), "a session cookie never widens a key's file list"); - assert.ok(!fileIds.has(unowned.file.id)); + assert.ok(fileIds.has(other.file.id), "dashboard session sees key B's file"); + assert.ok(fileIds.has(unowned.file.id), "dashboard session sees the unowned file"); const batches = await listBatchesVia(headers); assert.strictEqual(batches.res.status, 200); const batchIds = new Set(batches.body.data!.map((b) => b.id)); assert.ok(batchIds.has(own.batch.id)); - assert.ok(!batchIds.has(other.batch.id), "a session cookie never widens a key's batch list"); - assert.ok(!batchIds.has(unowned.batch.id)); + assert.ok(batchIds.has(other.batch.id), "dashboard session sees key B's batch"); + assert.ok(batchIds.has(unowned.batch.id), "dashboard session sees the unowned batch"); }); }); From 1794e1654b92a3ff79a0abe1916d7ae096e311a8 Mon Sep 17 00:00:00 2001 From: KitDev2 Date: Tue, 20 Oct 2026 19:23:06 +0700 Subject: [PATCH 15/16] fix(security): address AP-ISS-0197 hosted review regressions Preserve one-shot bootstrap retry semantics and strict embedding/rerank/Gemini boundaries; fail closed on self-hosted policy failure. Add direct-peer/auth rejection regressions and 8-finding review disposition evidence. --- docs/audits/ap-iss-0197-dispositions.md | 21 +++++++++ open-sse/services/cache/embeddingClient.ts | 14 +++++- src/app/api/settings/cache-config/route.ts | 2 +- .../cache-config/test-embedding/route.ts | 2 +- src/app/api/settings/require-login/route.ts | 30 ++++++++++--- .../api/v1/_shared/rerankLocalNodeShapes.ts | 11 ++--- src/app/api/v1/chat/completions/route.ts | 23 ++++++++-- src/app/api/v1/rerank/route.ts | 13 +++++- .../[...path]/convertGeminiToInternal.ts | 9 ++-- src/lib/auth/bootstrapToken.ts | 33 +++++++++++++- src/shared/validation/schemas/gemini.ts | 17 +++++--- tests/unit/cache-config-route-8219.test.ts | 26 ++++++++++- ...cache-config-test-embedding-ap0197.test.ts | 43 ++++++++++++++++++- tests/unit/login-bootstrap-route.test.ts | 14 ++++++ tests/unit/rerank-local-node-shapes.test.ts | 30 ++++++++++++- tests/unit/rerank-providers-route.test.ts | 11 ++++- .../self-hosted-chat-policy-ap0197.test.ts | 12 ++++++ tests/unit/v1beta-gemini-inline-data.test.ts | 14 ++++++ 18 files changed, 288 insertions(+), 37 deletions(-) diff --git a/docs/audits/ap-iss-0197-dispositions.md b/docs/audits/ap-iss-0197-dispositions.md index 0bf92d0a..8d6c15f4 100644 --- a/docs/audits/ap-iss-0197-dispositions.md +++ b/docs/audits/ap-iss-0197-dispositions.md @@ -63,3 +63,24 @@ Protected-main integration and exact-head verification: - Post-merge scoped ESLint over AP-ISS-0197 changed TypeScript files: PASS with the repository suppression bank. - Post-merge `git diff --check`: PASS. - Hosted PR/check evidence is recorded in the canonical KitControl terminal handoff after publication; this issue is not DONE until that evidence is green/terminal. + +## Hosted PR #142 review follow-up — KitDev25, 2026-10-20 (Gen16) + +This is a **post-HEAD review-remediation checkpoint**, not a claim of integration or full-CI PASS. The previously published PR HEAD is `e9611720ae09c0a534756c85c213c57e2e22c3fe`; the 8 automated inline review findings refer to that immutable commit. All existing task WIP was preserved through the KitControl validated Gen16 recovery. + +| PR review concern | Disposition and regression evidence | +| --- | --- | +| Fractional embedding `dimensions` | FIXED in request schema and embedding generator positive-integer boundary; focused probe test rejects `1.5` before transport. | +| Redacted semantic-cache API key cleared on partial UI save | FIXED: null/redacted update means unchanged; targeted cache GET/PUT test preserves the stored secret without exposing it in GET. | +| One-shot bootstrap token lost on DB write failure | FIXED: atomically claim token before write, commit claim only on success and release on failure; login bootstrap regression exercises retry. | +| TEI `model`/`top_n` leakage | FIXED: TEI-specific request body omits Cohere-only fields; rerank strict-shape tests pass. | +| Self-hosted policy erroneously applied to cloud dispatch when config is present | **NOT REPRODUCED / FALSE PREMISE FOR CURRENT ENTRY CONTRACT**: `open-sse/services/selfHostedEntry.ts:400+` returns `null` *only* when provider config is absent, and `src/app/api/v1/chat/completions/route.ts` tests the same two config env vars. A configured self-hosted entry owns this endpoint and returns a response or configured-entry error, not cloud fallthrough. Preserve fail-closed policy before any self-hosted upstream dispatch. Do not accept the suggested post-dispatch policy because it would authorize after side effects. | +| Policy-backend exception could fall through injection `try/catch` | FIXED: dedicated helper catches policy exceptions and returns sanitized 503 before transport; focused unit regression passes. | +| Blind rerank retries on all 400/422 | FIXED: retry TEI dialect only on response-body validation/schema indicators; semantic 400 regression asserts no retry. | +| Gemini snake_case `system_instruction` inlineData outside bound | FIXED: validation accounts for snake_case alias and conversion recognizes it; focused inline-media limit test passes. | + +**Fresh evidence on 2026-10-20 host 19:12–19:21 ICT:** initial standalone cache/rerank tests RED because synthetic `localhost` URL supplied no trustworthy direct-peer evidence and the current authentication guard correctly returned 401. Test-only fixture update supplies an explicit direct-loopback socket peer; two added negative tests prove anonymous requests lacking this peer still receive 401 and do not call upstream. Cache/embedding/rerank suites individually GREEN **14/14**; extended AP-ISS-0197 related group **72/72** PASS. `npm run typecheck:core`, `npm run check:open-sse-typecheck`, `npm run test:security` (23/23), `npm run check:route-guard-membership`, `npm run check:public-creds`, Prettier, and `git diff --check` all passed. ESLint on changed files emitted 11 **pre-existing baseline unused-import diagnostics** for `src/shared/validation/schemas/gemini.ts` (same import list at old HEAD), so it is not claimed wholly clean. Bounded full suite and hosted CI have NOT been rerun against the new worktree changes. + +**NEXT:** verify focused regression superset and review/quality gates against the exact new immutable commit, stage/commit through dedicated `git_admin` only, publish the **same SHA** through an authorized Git transport if supported, request independent review and wait for exact-head CI. Revalidate accepted main and integration/episode provenance before terminal close. + +**DO-NOT:** no reset/stash/clean/force push; do not update protected Git admin with raw shell workarounds; do not claim historical CI is proof for unpublished WIP; do not treat recovery/board label as worker progress. diff --git a/open-sse/services/cache/embeddingClient.ts b/open-sse/services/cache/embeddingClient.ts index 8f140e83..98695c56 100644 --- a/open-sse/services/cache/embeddingClient.ts +++ b/open-sse/services/cache/embeddingClient.ts @@ -147,7 +147,8 @@ export async function generateEmbeddingWithTimeout( embeddingGeneratorCooldownUntil.set(generator, cooldowns); if (generatorPromise) { quarantineToken = Symbol(cooldownKey); - const inFlight = embeddingGeneratorTimedOutInFlight.get(generator) ?? new Map(); + const inFlight = + embeddingGeneratorTimedOutInFlight.get(generator) ?? new Map(); inFlight.set(cooldownKey, quarantineToken); embeddingGeneratorTimedOutInFlight.set(generator, inFlight); setTimeout(clearTimedOutInFlight, timeoutMs); @@ -220,6 +221,13 @@ export function createDefaultEmbeddingGenerator(config: { } } const apiKey = config.embeddingApiKey; + if ( + config.embeddingDimension !== undefined && + (!Number.isInteger(config.embeddingDimension) || config.embeddingDimension <= 0) + ) { + console.warn("[CACHE] Invalid embedding dimension; expected a positive integer"); + return null; + } try { const guard = getProviderValidationGuard(); @@ -248,7 +256,9 @@ export function createDefaultEmbeddingGenerator(config: { body: JSON.stringify({ model, input: text, - ...(config.embeddingDimension ? { dimensions: config.embeddingDimension } : {}), + ...(config.embeddingDimension !== undefined + ? { dimensions: config.embeddingDimension } + : {}), }), signal: options?.signal, }); diff --git a/src/app/api/settings/cache-config/route.ts b/src/app/api/settings/cache-config/route.ts index 46d9e284..e6391579 100644 --- a/src/app/api/settings/cache-config/route.ts +++ b/src/app/api/settings/cache-config/route.ts @@ -23,7 +23,7 @@ const cacheConfigUpdateSchema = z.object({ semanticCacheThreshold: z.number().min(0).max(1).optional(), semanticCacheEmbeddingProvider: z.string().trim().optional(), semanticCacheEmbeddingModel: z.string().trim().optional(), - semanticCacheEmbeddingDimension: z.number().positive().nullable().optional(), + semanticCacheEmbeddingDimension: z.number().int().positive().nullable().optional(), semanticCacheEmbeddingBaseUrl: z.string().trim().nullable().optional(), semanticCacheEmbeddingApiKey: z.string().trim().nullable().optional(), semanticCacheRedisUrl: z.string().trim().nullable().optional(), diff --git a/src/app/api/settings/cache-config/test-embedding/route.ts b/src/app/api/settings/cache-config/test-embedding/route.ts index b4f552f6..95da1240 100644 --- a/src/app/api/settings/cache-config/test-embedding/route.ts +++ b/src/app/api/settings/cache-config/test-embedding/route.ts @@ -12,7 +12,7 @@ const testEmbeddingSchema = z.object({ model: z.string().trim().min(1), baseUrl: z.string().trim().optional(), apiKey: z.string().trim().optional(), - dimensions: z.number().positive().optional(), + dimensions: z.number().int().positive().optional(), }); export async function POST(request: Request) { diff --git a/src/app/api/settings/require-login/route.ts b/src/app/api/settings/require-login/route.ts index b87b6726..149c7cbf 100644 --- a/src/app/api/settings/require-login/route.ts +++ b/src/app/api/settings/require-login/route.ts @@ -7,7 +7,13 @@ import { hasManagementPasswordConfigured, hashManagementPassword, } from "@/lib/auth/managementPassword"; -import { consumeBootstrapToken, peekBootstrapToken } from "@/lib/auth/bootstrapToken"; +import { + claimBootstrapToken, + commitBootstrapTokenClaim, + consumeBootstrapToken, + peekBootstrapToken, + releaseBootstrapTokenClaim, +} from "@/lib/auth/bootstrapToken"; import { hasConfiguredOidc, isAuthenticated } from "@/shared/utils/apiAuth"; import { AUTHZ_HEADER_PEER_LOCALITY, BOOTSTRAP_TOKEN_HEADER } from "@/server/authz/headers"; import { getNodeRuntimeSupport } from "@/shared/utils/nodeRuntimeSupport.ts"; @@ -91,6 +97,7 @@ export async function GET() { export async function POST(request: Request) { const settings = await getSettings(); let requiresBootstrapTokenClaim = false; + let bootstrapTokenClaim: ReturnType = null; if (isBootstrapSecurityWindow(settings)) { const peerLocality = request.headers.get(AUTHZ_HEADER_PEER_LOCALITY); // In production this header is stripped from client input and re-stamped by @@ -141,16 +148,27 @@ export async function POST(request: Request) { } const bootstrapCandidate = request.headers.get(BOOTSTRAP_TOKEN_HEADER); - if (requiresBootstrapTokenClaim && !consumeBootstrapToken(bootstrapCandidate)) { - return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 }); + if (requiresBootstrapTokenClaim) { + bootstrapTokenClaim = claimBootstrapToken(bootstrapCandidate); + if (!bootstrapTokenClaim) { + return NextResponse.json({ error: "Local bootstrap required" }, { status: 403 }); + } } await updateSettings(updates); - // Loopback bootstrap does not need the token for authorization, but preserve - // the existing one-shot behavior when a valid token happens to be supplied. - if (!requiresBootstrapTokenClaim) consumeBootstrapToken(bootstrapCandidate); + if (bootstrapTokenClaim) { + if (!commitBootstrapTokenClaim(bootstrapTokenClaim)) { + throw new Error("Bootstrap token claim was lost before commit"); + } + bootstrapTokenClaim = null; + } else { + // Loopback bootstrap does not need the token for authorization, but preserve + // the existing one-shot behavior when a valid token happens to be supplied. + consumeBootstrapToken(bootstrapCandidate); + } return NextResponse.json({ success: true }); } catch (error) { + if (bootstrapTokenClaim) releaseBootstrapTokenClaim(bootstrapTokenClaim); console.error("[API] Error updating require-login settings:", error); return NextResponse.json( { error: error instanceof Error ? error.message : "Failed to update settings" }, diff --git a/src/app/api/v1/_shared/rerankLocalNodeShapes.ts b/src/app/api/v1/_shared/rerankLocalNodeShapes.ts index 4375078e..fb26fc61 100644 --- a/src/app/api/v1/_shared/rerankLocalNodeShapes.ts +++ b/src/app/api/v1/_shared/rerankLocalNodeShapes.ts @@ -70,20 +70,17 @@ export function buildLocalRerankRequestBody( ): Record { const topN = input.top_n || input.documents.length; const returnDocuments = input.return_documents === true; - const common = { - model: input.model, - query: input.query, - top_n: topN, - }; if (dialect === "tei") { return { - ...common, + query: input.query, texts: input.documents.map(documentText), return_text: returnDocuments, }; } return { - ...common, + model: input.model, + query: input.query, + top_n: topN, documents: input.documents, return_documents: returnDocuments, }; diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 3f946d7a..baacb9a2 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -67,6 +67,20 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +export async function enforceSelfHostedPolicyOrReject( + request: Request, + requestedModel: string | null, + policyFn: typeof enforceApiKeyPolicy = enforceApiKeyPolicy +): Promise { + try { + const policy = await policyFn(request, requestedModel); + return policy.rejection ?? null; + } catch (error) { + console.error("[SECURITY] Self-hosted API-key policy unavailable:", error); + return errorResponse(503, "API key policy unavailable"); + } +} + // Minimal request-shape validation (Rule #7 / T06 gate). This is the hottest path in the // proxy, and the body is already parsed exactly once above into `parsedBody` (see the // #4380/#7862 comment on the single `request.json()` call) — so this only runs `.safeParse()` @@ -200,9 +214,12 @@ export async function POST(request) { ); if (selfHostedConfigured) { const requestedModel = typeof parsedBody.model === "string" ? parsedBody.model : null; - const selfHostedPolicy = await enforceApiKeyPolicy(request, requestedModel); - if (selfHostedPolicy.rejection) { - return finishAdmission(selfHostedPolicy.rejection); + const selfHostedPolicyRejection = await enforceSelfHostedPolicyOrReject( + request, + requestedModel + ); + if (selfHostedPolicyRejection) { + return finishAdmission(selfHostedPolicyRejection); } } diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index 768e7e4f..f7ac7496 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -38,6 +38,17 @@ export async function OPTIONS() { }); } +async function isLocalRerankSchemaValidationResponse(response: Response): Promise { + if (response.status !== 400 && response.status !== 422) return false; + const text = await response + .clone() + .text() + .catch(() => ""); + return /(?:unknown|unrecognized|unexpected|extra)\s+(?:field|fields|input)|field\s+required|validation\s+error|"type"\s*:\s*"(?:missing|extra_forbidden)"/i.test( + text + ); +} + /** * POST /v1/rerank - Cohere-compatible rerank endpoint * @@ -216,7 +227,7 @@ async function postHandler(request, _context) { // Strict servers can reject unknown request fields. Retry exactly once with // TEI spellings only when the response indicates request-schema validation. - if (res.status === 400 || res.status === 422) { + if (await isLocalRerankSchemaValidationResponse(res)) { res = await fetch(activeUrl, makeUpstreamInit("tei")); } diff --git a/src/app/api/v1beta/models/[...path]/convertGeminiToInternal.ts b/src/app/api/v1beta/models/[...path]/convertGeminiToInternal.ts index ec87192f..e00c45ec 100644 --- a/src/app/api/v1beta/models/[...path]/convertGeminiToInternal.ts +++ b/src/app/api/v1beta/models/[...path]/convertGeminiToInternal.ts @@ -53,6 +53,7 @@ interface GeminiTool { interface GeminiGenerateBody { systemInstruction?: { parts?: GeminiPart[] }; + system_instruction?: { parts?: GeminiPart[] }; contents?: GeminiContent[]; tools?: GeminiTool[]; generationConfig?: { @@ -209,10 +210,10 @@ export function convertGeminiToInternal( ): InternalChatBody { const messages: InternalMessage[] = []; - // Convert system instruction - if (geminiBody.systemInstruction) { - const systemText = - geminiBody.systemInstruction.parts?.map((p) => p.text ?? "").join("\n") || ""; + // Convert either Gemini JS camelCase or REST snake_case system instruction. + const systemInstruction = geminiBody.systemInstruction ?? geminiBody.system_instruction; + if (systemInstruction) { + const systemText = systemInstruction.parts?.map((p) => p.text ?? "").join("\n") || ""; if (systemText) { messages.push({ role: "system", content: systemText }); } diff --git a/src/lib/auth/bootstrapToken.ts b/src/lib/auth/bootstrapToken.ts index 640eed2a..4acad0a0 100644 --- a/src/lib/auth/bootstrapToken.ts +++ b/src/lib/auth/bootstrapToken.ts @@ -31,6 +31,12 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; let currentToken: string | null = null; let announced = false; +export interface BootstrapTokenClaim { + readonly kind: "bootstrap-token-claim"; +} + +let activeClaim: BootstrapTokenClaim | null = null; + function generateToken(): string { return randomBytes(24).toString("base64url"); } @@ -80,8 +86,32 @@ export function peekBootstrapToken(candidate: string | null | undefined): boolea * Route handlers call this once, after a bootstrap write has actually * succeeded, so the token cannot be replayed for a second write. */ +export function claimBootstrapToken( + candidate: string | null | undefined +): BootstrapTokenClaim | null { + if (activeClaim || !peekBootstrapToken(candidate)) return null; + activeClaim = Object.freeze({ kind: "bootstrap-token-claim" }); + return activeClaim; +} + +/** Release an in-flight claim after persistence fails so the operator can retry. */ +export function releaseBootstrapTokenClaim(claim: BootstrapTokenClaim): boolean { + if (activeClaim !== claim) return false; + activeClaim = null; + return true; +} + +/** Commit an in-flight claim only after the guarded bootstrap write succeeds. */ +export function commitBootstrapTokenClaim(claim: BootstrapTokenClaim): boolean { + if (activeClaim !== claim || !currentToken) return false; + currentToken = null; + activeClaim = null; + announced = false; + return true; +} + export function consumeBootstrapToken(candidate: string | null | undefined): boolean { - if (!peekBootstrapToken(candidate)) return false; + if (activeClaim || !peekBootstrapToken(candidate)) return false; currentToken = null; announced = false; return true; @@ -90,5 +120,6 @@ export function consumeBootstrapToken(candidate: string | null | undefined): boo /** Test helper — resets the module singleton between test cases. */ export function __resetBootstrapTokenForTest(): void { currentToken = null; + activeClaim = null; announced = false; } diff --git a/src/shared/validation/schemas/gemini.ts b/src/shared/validation/schemas/gemini.ts index 425c9f1d..b1298004 100644 --- a/src/shared/validation/schemas/gemini.ts +++ b/src/shared/validation/schemas/gemini.ts @@ -63,15 +63,17 @@ export const geminiContentSchema = z }) .catchall(z.unknown()); +const geminiSystemInstructionSchema = z + .object({ + parts: z.array(geminiPartSchema).optional(), + }) + .catchall(z.unknown()); + export const v1betaGeminiGenerateSchema = z .object({ contents: z.array(geminiContentSchema).optional(), - systemInstruction: z - .object({ - parts: z.array(geminiPartSchema).optional(), - }) - .catchall(z.unknown()) - .optional(), + systemInstruction: geminiSystemInstructionSchema.optional(), + system_instruction: geminiSystemInstructionSchema.optional(), generationConfig: z .object({ stream: z.boolean().optional(), @@ -84,7 +86,7 @@ export const v1betaGeminiGenerateSchema = z }) .catchall(z.unknown()) .superRefine((value, ctx) => { - if (!value.contents && !value.systemInstruction) { + if (!value.contents && !value.systemInstruction && !value.system_instruction) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "contents or systemInstruction is required", @@ -95,6 +97,7 @@ export const v1betaGeminiGenerateSchema = z const allParts = [ ...(value.contents ?? []).flatMap((content) => content.parts ?? []), ...(value.systemInstruction?.parts ?? []), + ...(value.system_instruction?.parts ?? []), ]; const totalInlineBytes = allParts.reduce( (total, part) => total + decodedInlineBytesFromPart(part), diff --git a/tests/unit/cache-config-route-8219.test.ts b/tests/unit/cache-config-route-8219.test.ts index 3c94b4b2..1bde2d15 100644 --- a/tests/unit/cache-config-route-8219.test.ts +++ b/tests/unit/cache-config-route-8219.test.ts @@ -26,11 +26,14 @@ function resetStorage() { } function makeJsonRequest(method: string, body?: unknown): Request { - return new Request("http://localhost/api/settings/cache-config", { + const req = new Request("http://localhost/api/settings/cache-config", { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); + // The unconfigured fresh-install gate permits only a verified direct loopback peer. + Object.defineProperty(req, "ip", { value: "127.0.0.1" }); + return req; } test.beforeEach(() => { @@ -82,6 +85,27 @@ test("cache-config route resolves and modelCatalogCacheTtlMs round-trips", async assert.equal(JSON.stringify(getBody).includes("stored-embedding-secret"), false); }); + await t.test("redacted API-key save does not clear the stored embedding credential", async () => { + const { getDatabaseSettings } = await import("../../src/lib/db/databaseSettings.ts"); + const first = await cacheConfigRoute.PUT( + makeJsonRequest("PUT", { semanticCacheEmbeddingApiKey: "stored-embedding-secret" }) as never + ); + assert.equal(first.status, 200); + + const redactedSave = await cacheConfigRoute.PUT( + makeJsonRequest("PUT", { + semanticCacheEmbeddingApiKey: null, + semanticCacheThreshold: 0.81, + }) as never + ); + assert.equal(redactedSave.status, 200); + assert.equal( + getDatabaseSettings().cache.semanticCacheEmbeddingApiKey, + "stored-embedding-secret", + "null from a redacted UI field must mean unchanged, not clear" + ); + }); + await t.test("PUT persists idempotencyWindowMs via the flat settings module", async () => { const putResponse = await cacheConfigRoute.PUT( makeJsonRequest("PUT", { idempotencyWindowMs: 9000 }) as never diff --git a/tests/unit/cache-config-test-embedding-ap0197.test.ts b/tests/unit/cache-config-test-embedding-ap0197.test.ts index 4404895b..ef2dbc2b 100644 --- a/tests/unit/cache-config-test-embedding-ap0197.test.ts +++ b/tests/unit/cache-config-test-embedding-ap0197.test.ts @@ -14,11 +14,14 @@ const route = await import("../../src/app/api/settings/cache-config/test-embeddi const originalFetch = globalThis.fetch; function request(body: unknown): Request { - return new Request("http://localhost/api/settings/cache-config/test-embedding", { + const req = new Request("http://localhost/api/settings/cache-config/test-embedding", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); + // Simulate an observed direct loopback socket peer, not a spoofable Host header. + Object.defineProperty(req, "ip", { value: "127.0.0.1" }); + return req; } test.afterEach(() => { @@ -30,6 +33,23 @@ test.after(() => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); +test("anonymous request without a verified loopback peer remains unauthorized", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return Response.json({ data: [{ embedding: [1] }] }); + }) as typeof fetch; + const response = await route.POST( + new Request("http://localhost/api/settings/cache-config/test-embedding", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "openai", model: "text-embedding-3-small" }), + }) + ); + assert.equal(response.status, 401); + assert.equal(calls, 0); +}); + test("dimension constraint is forwarded and a mismatched embedding is rejected", async () => { let upstreamBody: Record | undefined; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -51,6 +71,27 @@ test("dimension constraint is forwarded and a mismatched embedding is rejected", assert.equal(body.ok, false); }); +test("fractional embedding dimensions are rejected before transport", async () => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return Response.json({ data: [{ embedding: [1] }] }); + }) as typeof fetch; + + const response = await route.POST( + request({ + provider: "openai", + model: "text-embedding-3-small", + baseUrl: "https://embeddings.example/v1", + apiKey: "caller-key", + dimensions: 1.5, + }) + ); + + assert.equal(response.status, 400); + assert.equal(calls, 0); +}); + test("caller-supplied baseUrl cannot inherit a stored provider credential", async () => { await providersDb.createProviderConnection({ provider: "openai", diff --git a/tests/unit/login-bootstrap-route.test.ts b/tests/unit/login-bootstrap-route.test.ts index f493b9b0..0fb04091 100644 --- a/tests/unit/login-bootstrap-route.test.ts +++ b/tests/unit/login-bootstrap-route.test.ts @@ -361,6 +361,20 @@ test("#14296 concurrent remote bootstrap writes can consume the one-shot token o assert.equal(bootstrapToken.peekBootstrapToken(token), false); }); +test("#14296 a claimed bootstrap token can be released after persistence failure", () => { + const token = bootstrapToken.getOrCreateBootstrapToken({ log: () => {} }); + const claim = bootstrapToken.claimBootstrapToken(token); + assert.ok(claim, "first claimant owns the token"); + assert.equal(bootstrapToken.claimBootstrapToken(token), null, "concurrent claim is rejected"); + bootstrapToken.releaseBootstrapTokenClaim(claim); + assert.equal(bootstrapToken.peekBootstrapToken(token), true, "released claim remains retryable"); + + const retryClaim = bootstrapToken.claimBootstrapToken(token); + assert.ok(retryClaim); + assert.equal(bootstrapToken.commitBootstrapTokenClaim(retryClaim), true); + assert.equal(bootstrapToken.peekBootstrapToken(token), false, "committed claim consumes token"); +}); + test("public login bootstrap route POST returns 500 when hashing fails", async () => { bcrypt.hash = async () => { throw new Error("hash failed"); diff --git a/tests/unit/rerank-local-node-shapes.test.ts b/tests/unit/rerank-local-node-shapes.test.ts index 40d71c3f..cd37e1be 100644 --- a/tests/unit/rerank-local-node-shapes.test.ts +++ b/tests/unit/rerank-local-node-shapes.test.ts @@ -283,7 +283,8 @@ test.describe("POST /v1/rerank against a TEI-shaped local node", () => { assert.deepEqual(calls[2].body.texts, ["a cat is a small animal", "the stock market fell"]); assert.equal("documents" in calls[2].body, false); assert.equal(calls[2].body.return_text, false); - assert.equal(calls[2].body.model, "bge-reranker-v2-m3"); + assert.equal("model" in calls[2].body, false); + assert.equal("top_n" in calls[2].body, false); }); test("strict Cohere server never receives TEI-only fields", async () => { @@ -314,6 +315,33 @@ test.describe("POST /v1/rerank against a TEI-shaped local node", () => { assert.equal("return_text" in seen, false); }); + test("semantic 400 errors do not trigger an alternate-dialect retry", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(JSON.stringify({ error: "model does not support this query" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + }; + + const res = await POST( + new Request("http://localhost/v1/rerank", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "tei/bge-reranker-v2-m3", + query: "cat", + documents: ["cat"], + }), + }), + {} + ); + + assert.equal(res.status, 400); + assert.equal(calls, 1, "semantic errors must not be amplified into a second inference request"); + }); + test("a `score`-only gateway on /v1/rerank yields relevance_score for clients", async () => { globalThis.fetch = async () => new Response( diff --git a/tests/unit/rerank-providers-route.test.ts b/tests/unit/rerank-providers-route.test.ts index 159f1f19..c8129c7b 100644 --- a/tests/unit/rerank-providers-route.test.ts +++ b/tests/unit/rerank-providers-route.test.ts @@ -28,7 +28,9 @@ test("GET /api/memory/rerank-providers includes rerank-capable local provider no }); const response = await rerankProvidersRoute.GET( - new NextRequest("http://localhost/api/memory/rerank-providers") + Object.assign(new NextRequest("http://localhost/api/memory/rerank-providers"), { + ip: "127.0.0.1", + }) ); const body = await response.json(); @@ -41,6 +43,13 @@ test("GET /api/memory/rerank-providers includes rerank-capable local provider no ); }); +test("anonymous remote requests cannot list providers despite a localhost URL", async () => { + const response = await rerankProvidersRoute.GET( + new NextRequest("http://localhost/api/memory/rerank-providers") + ); + assert.equal(response.status, 401); +}); + test("local node listing failure is isolated from curated rerank providers", () => { const source = fs.readFileSync("src/app/api/memory/rerank-providers/route.ts", "utf8"); assert.match( diff --git a/tests/unit/self-hosted-chat-policy-ap0197.test.ts b/tests/unit/self-hosted-chat-policy-ap0197.test.ts index 7836954b..62ad5ae8 100644 --- a/tests/unit/self-hosted-chat-policy-ap0197.test.ts +++ b/tests/unit/self-hosted-chat-policy-ap0197.test.ts @@ -25,6 +25,18 @@ test.afterEach(() => { globalThis.fetch = originalFetch; }); +test("self-hosted policy backend exceptions fail closed", async () => { + const response = await chatRoute.enforceSelfHostedPolicyOrReject( + new Request("http://localhost/v1/chat/completions"), + "local/llama3", + async () => { + throw new Error("policy backend unavailable"); + } + ); + assert.ok(response); + assert.equal(response.status, 503); +}); + test.after(() => { globalThis.fetch = originalFetch; delete process.env.OMNIROUTE_SELF_HOSTED_PROVIDERS; diff --git a/tests/unit/v1beta-gemini-inline-data.test.ts b/tests/unit/v1beta-gemini-inline-data.test.ts index 7263b7a2..506b77d2 100644 --- a/tests/unit/v1beta-gemini-inline-data.test.ts +++ b/tests/unit/v1beta-gemini-inline-data.test.ts @@ -116,6 +116,20 @@ test("v1beta rejects more than 16 MiB aggregate inline media", () => { } }); +test("v1beta counts snake_case system_instruction inline media toward request limits", () => { + const data = Buffer.alloc(MAX_EMBEDDING_INLINE_ITEM_BYTES + 1).toString("base64"); + const parsed = v1betaGeminiGenerateSchema.safeParse({ + contents: [{ role: "user", parts: [{ text: "hello" }] }], + system_instruction: { + parts: [{ inline_data: { mime_type: "image/png", data } }], + }, + }); + assert.equal(parsed.success, false); + if (!parsed.success) { + assert.match(parsed.error.issues.map((issue) => issue.message).join(" "), /8 MiB|16 MiB/i); + } +}); + test("v1beta still accepts bounded inlineData", () => { const parsed = v1betaGeminiGenerateSchema.safeParse({ contents: [ From c35014d5990b93739d99c675f436f589f564342c Mon Sep 17 00:00:00 2001 From: KitDev2 Date: Tue, 20 Oct 2026 19:28:59 +0700 Subject: [PATCH 16/16] test(auth): align legacy cache preference route fixture with peer locality Preserve management auth's fail-closed behavior; model a trusted loopback socket peer in the old settings regression and document red-to-green verification. --- docs/audits/ap-iss-0197-dispositions.md | 2 ++ tests/unit/cache-config-preserve-client-cache.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/audits/ap-iss-0197-dispositions.md b/docs/audits/ap-iss-0197-dispositions.md index 8d6c15f4..e07a549b 100644 --- a/docs/audits/ap-iss-0197-dispositions.md +++ b/docs/audits/ap-iss-0197-dispositions.md @@ -84,3 +84,5 @@ This is a **post-HEAD review-remediation checkpoint**, not a claim of integratio **NEXT:** verify focused regression superset and review/quality gates against the exact new immutable commit, stage/commit through dedicated `git_admin` only, publish the **same SHA** through an authorized Git transport if supported, request independent review and wait for exact-head CI. Revalidate accepted main and integration/episode provenance before terminal close. **DO-NOT:** no reset/stash/clean/force push; do not update protected Git admin with raw shell workarounds; do not claim historical CI is proof for unpublished WIP; do not treat recovery/board label as worker progress. + +**Additional adjacent legacy regression (Gen16):** `tests/unit/cache-config-preserve-client-cache.test.ts` originally returned 401 because synthetic requests had a localhost URL but no trusted direct-peer metadata. The route's fail-closed behavior was correct. Updated that test-only fixture to represent a genuine loopback peer; no authentication production code changed. diff --git a/tests/unit/cache-config-preserve-client-cache.test.ts b/tests/unit/cache-config-preserve-client-cache.test.ts index 406b013f..ba9c53bd 100644 --- a/tests/unit/cache-config-preserve-client-cache.test.ts +++ b/tests/unit/cache-config-preserve-client-cache.test.ts @@ -24,11 +24,14 @@ function resetStorage() { } function makeJsonRequest(method: string, body?: unknown): Request { - return new Request("http://localhost/api/settings/cache-config", { + const req = new Request("http://localhost/api/settings/cache-config", { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); + // Route tests exercise a direct loopback socket, not a remote caller with a localhost URL. + Object.defineProperty(req, "ip", { value: "127.0.0.1" }); + return req; } test.beforeEach(() => {