mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-11 01:13:02 +03:00
Compare commits
1 Commits
fix/12251-
...
fix/12745-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222bab0abb |
@@ -1 +0,0 @@
|
||||
- fix(dashboard): allow deleting the last extra-upstream-header row even when invalid (#12251)
|
||||
1
changelog.d/fixes/12745-memory-rerank-loopback-auth.md
Normal file
1
changelog.d/fixes/12745-memory-rerank-loopback-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(memory): authenticate the internal /v1/rerank loopback call so memory reranking no longer silently degrades to unranked order when REQUIRE_API_KEY=true (#12745)
|
||||
@@ -707,10 +707,7 @@ export default function ModelCompatPopover({
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
disabled ||
|
||||
(headerRows.length <= 1 && !row.name.trim() && !row.value.trim())
|
||||
}
|
||||
disabled={disabled || headerRows.length <= 1}
|
||||
onClick={() => removeHeaderRow(row.id)}
|
||||
title={t("compatUpstreamRemoveRow")}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/80 text-text-muted hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted transition-colors"
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Repro for #12251
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ModelCompatPopover from "../ModelCompatPopover";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover() {
|
||||
const trigger = container.querySelector("button") as HTMLButtonElement;
|
||||
await act(async () => trigger.click());
|
||||
await flushEffects();
|
||||
}
|
||||
|
||||
describe("ModelCompatPopover upstream headers — invalid single row cannot be deleted (#12251)", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("lets the user delete the invalid header via the delete icon when it is the ONLY row present", async () => {
|
||||
const onCompatPatch = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({
|
||||
"https://evil.example.com/callback": "some-secret-value",
|
||||
})}
|
||||
onCompatPatch={onCompatPatch}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await openPopover();
|
||||
|
||||
const nameInput = document.querySelector(
|
||||
'input[placeholder="compatUpstreamHeaderNamePlaceholder"]'
|
||||
) as HTMLInputElement;
|
||||
expect(nameInput).toBeTruthy();
|
||||
expect(nameInput.value).toBe("https://evil.example.com/callback");
|
||||
|
||||
const rowButtons = document.querySelectorAll('button[title="compatUpstreamRemoveRow"]');
|
||||
expect(rowButtons.length).toBe(1);
|
||||
|
||||
const removeButton = rowButtons[0] as HTMLButtonElement;
|
||||
|
||||
// EXPECTED (fixed) behavior: a populated row should always be removable via
|
||||
// its own delete icon, even when it is the only row.
|
||||
expect(removeButton.disabled).toBe(false);
|
||||
|
||||
await act(async () => removeButton.click());
|
||||
await flushEffects();
|
||||
|
||||
expect(onCompatPatch).toHaveBeenCalledWith("openai", { upstreamHeaders: {} });
|
||||
});
|
||||
|
||||
it("keeps the delete button disabled when the sole row is genuinely blank", async () => {
|
||||
const onCompatPatch = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ModelCompatPopover
|
||||
t={(key) => key}
|
||||
providerId="openai"
|
||||
modelId="gpt-test"
|
||||
effectiveModelNormalize={() => false}
|
||||
effectiveModelPreserveDeveloper={() => true}
|
||||
getUpstreamHeadersRecord={() => ({})}
|
||||
onCompatPatch={onCompatPatch}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await openPopover();
|
||||
|
||||
const rowButtons = document.querySelectorAll('button[title="compatUpstreamRemoveRow"]');
|
||||
expect(rowButtons.length).toBe(1);
|
||||
|
||||
const removeButton = rowButtons[0] as HTMLButtonElement;
|
||||
|
||||
// A blank sole row must stay non-deletable so the form always shows an
|
||||
// editable add-affordance.
|
||||
expect(removeButton.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
214
src/lib/memory/__tests__/rerank-loopback-auth-12745.test.ts
Normal file
214
src/lib/memory/__tests__/rerank-loopback-auth-12745.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* src/lib/memory/__tests__/rerank-loopback-auth-12745.test.ts
|
||||
*
|
||||
* Regression guard for #12745 — applyRerank()'s internal loopback call to
|
||||
* /v1/rerank used to carry no credential, so with REQUIRE_API_KEY=true the
|
||||
* global authz proxy's clientApiPolicy would 401 it and rerank silently
|
||||
* degraded to unranked order (fail-open by design, so nothing ever surfaced
|
||||
* the failure).
|
||||
*
|
||||
* This file proves two things:
|
||||
* 1. The loopback fetch retrieval.ts's applyRerank() issues now carries a
|
||||
* real Authorization: Bearer <internal key> header (fixed by attaching
|
||||
* pickApiKeyForInternalUse() — the same internal-probe selector already
|
||||
* used by combo-health-check / cloud-sync-verify).
|
||||
* 2. That fix was NOT done by exempting /v1/rerank from auth: an
|
||||
* unauthenticated *external* request to /api/v1/rerank is still
|
||||
* rejected by clientApiPolicy when REQUIRE_API_KEY=true.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-rerank-auth-12745-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
process.env.VECTOR_STORE_DISABLE_VEC = "true";
|
||||
|
||||
const INTERNAL_KEY = "sk-internal-test-key-12745";
|
||||
|
||||
vi.mock("../settings", () => ({
|
||||
getMemorySettings: async () => ({
|
||||
enabled: true,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "semantic",
|
||||
skillsEnabled: true,
|
||||
embeddingSource: "static",
|
||||
embeddingProviderModel: null,
|
||||
customBaseUrl: null,
|
||||
customModelId: null,
|
||||
transformersEnabled: false,
|
||||
staticEnabled: true,
|
||||
rerankEnabled: true,
|
||||
rerankProviderModel: "test-provider/test-rerank-model",
|
||||
vectorStore: "sqlite-vec",
|
||||
primaryBackend: "sqlite",
|
||||
fallbackBackends: [],
|
||||
backendConfigs: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../embedding", () => ({
|
||||
resolveEmbeddingSource: () => ({
|
||||
source: "static",
|
||||
model: "static-hash-8",
|
||||
dimensions: 8,
|
||||
identity: "static",
|
||||
signature: "static-8",
|
||||
reason: "test: static embedding, no network",
|
||||
}),
|
||||
embed: async () => ({
|
||||
vector: new Float32Array([1, 0, 0, 0, 0, 0, 0, 0]),
|
||||
source: "static",
|
||||
model: "static-hash-8",
|
||||
dimensions: 8,
|
||||
latencyMs: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../vectorStore", () => ({
|
||||
getVectorStore: () => ({
|
||||
ensureReady: async () => ({ ready: true, reason: "test" }),
|
||||
upsertVector: async () => undefined,
|
||||
deleteVector: async () => undefined,
|
||||
searchVector: async () => [
|
||||
{ memoryId: "rrk-auth-1", score: 0.91 },
|
||||
{ memoryId: "rrk-auth-2", score: 0.82 },
|
||||
],
|
||||
searchHybrid: async () => [],
|
||||
stats: async () => ({ rowCount: 2, needsReindex: 0, activeDim: 8 }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../db/apiKeys", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../db/apiKeys")>();
|
||||
return {
|
||||
...actual,
|
||||
pickApiKeyForInternalUse: vi.fn(async () => INTERNAL_KEY),
|
||||
};
|
||||
});
|
||||
|
||||
const core = await import("../../db/core");
|
||||
const { retrievePreview } = await import("../retrieval");
|
||||
const { pickApiKeyForInternalUse } = await import("../../db/apiKeys");
|
||||
|
||||
function cleanupDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function insertMemory(apiKeyId: string, id: string, content: string) {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO memories (id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at)
|
||||
VALUES (?, ?, ?, 'factual', ?, ?, '{}', datetime('now'), datetime('now'), NULL)`
|
||||
).run(id, apiKeyId, "", `key-${id}`, content);
|
||||
}
|
||||
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanupDb();
|
||||
originalFetch = globalThis.fetch;
|
||||
vi.mocked(pickApiKeyForInternalUse).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("#12745 — memory rerank loopback call authentication", () => {
|
||||
test("applyRerank()'s loopback fetch to /v1/rerank carries an internal Authorization bearer", async () => {
|
||||
insertMemory("api-rrk-auth", "rrk-auth-1", "The capital of France is Paris.");
|
||||
insertMemory("api-rrk-auth", "rrk-auth-2", "TypeScript is a superset of JavaScript.");
|
||||
|
||||
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const headers: Record<string, string> = {};
|
||||
new Headers(init?.headers).forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
});
|
||||
calls.push({ url, headers });
|
||||
|
||||
// Emulate the REAL clientApiPolicy behavior this loopback call hits in
|
||||
// production: reject without a bearer/x-api-key, accept a valid one.
|
||||
const hasCredential = Boolean(headers["authorization"] || headers["x-api-key"]);
|
||||
if (!hasCredential) {
|
||||
return new Response(JSON.stringify({ error: { message: "Authentication required" } }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{ index: 1, relevance_score: 0.95 },
|
||||
{ index: 0, relevance_score: 0.4 },
|
||||
],
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const bundle = await retrievePreview("api-rrk-auth", "capital of France", {
|
||||
strategy: "semantic",
|
||||
maxTokens: 2000,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const rerankCall = calls.find((c) => c.url.includes("/v1/rerank"));
|
||||
expect(rerankCall).toBeDefined();
|
||||
|
||||
const hasCredential = Boolean(
|
||||
rerankCall?.headers["authorization"] || rerankCall?.headers["x-api-key"]
|
||||
);
|
||||
expect(hasCredential).toBe(true);
|
||||
expect(rerankCall?.headers["authorization"]).toBe(`Bearer ${INTERNAL_KEY}`);
|
||||
|
||||
// Functional consequence: with a valid credential the rerank response is
|
||||
// actually honored (item order follows relevance_score) instead of
|
||||
// silently keeping pre-rerank vector-search order.
|
||||
expect(bundle.items[0]?.memory.id).toBe("rrk-auth-2");
|
||||
});
|
||||
|
||||
test("without a credential the same loopback call would still be 401'd (no auth bypass introduced)", async () => {
|
||||
insertMemory("api-rrk-noauth", "rrk-auth-1", "The capital of France is Paris.");
|
||||
insertMemory("api-rrk-noauth", "rrk-auth-2", "TypeScript is a superset of JavaScript.");
|
||||
|
||||
// Simulate the pre-fix condition: internal key selector finds nothing.
|
||||
vi.mocked(pickApiKeyForInternalUse).mockResolvedValueOnce(null);
|
||||
|
||||
let sawUnauthenticatedRerankCall = false;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const headers: Record<string, string> = {};
|
||||
new Headers(init?.headers).forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
});
|
||||
const hasCredential = Boolean(headers["authorization"] || headers["x-api-key"]);
|
||||
if (url.includes("/v1/rerank") && !hasCredential) {
|
||||
sawUnauthenticatedRerankCall = true;
|
||||
return new Response(JSON.stringify({ error: { message: "Authentication required" } }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ results: [] }), { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const bundle = await retrievePreview("api-rrk-noauth", "capital of France", {
|
||||
strategy: "semantic",
|
||||
maxTokens: 2000,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(sawUnauthenticatedRerankCall).toBe(true);
|
||||
// Fail-open by design: retrieval keeps working (unranked) rather than throwing.
|
||||
expect(bundle.items.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { getQdrantConfig, checkQdrantHealth, searchSemanticMemory } from "./qdra
|
||||
import type { MemoryEngineStatus } from "@/shared/schemas/memory";
|
||||
import { supportsFts5 } from "../db/migrationRunner";
|
||||
import type { SqliteAdapter } from "../db/adapters/types";
|
||||
import { pickApiKeyForInternalUse } from "../db/apiKeys";
|
||||
import { getRuntimePorts } from "../runtime/ports";
|
||||
import {
|
||||
estimateTokens,
|
||||
parseMetadata,
|
||||
@@ -143,16 +145,29 @@ function buildFtsRows(apiKeyId: string, config: FtsColConfig): MemoryRow[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Loopback rerank URL — localhost only, never routed over the network.
|
||||
// nosemgrep: javascript.lang.security.audit.non-literal-regexp.non-literal-regexp
|
||||
const RERANK_LOOPBACK_URL = "http://127.0.0.1:20128/v1/rerank";
|
||||
// Loopback rerank URL — localhost only, never routed over the network. The port is
|
||||
// derived from the same runtime source every other internal self-call uses
|
||||
// (getRuntimePorts()/process.env.PORT — see src/lib/runtime/ports.ts), never hardcoded,
|
||||
// so this keeps working when an operator overrides PORT/API_PORT (#12745).
|
||||
function getRerankLoopbackUrl(): string {
|
||||
const { apiPort } = getRuntimePorts();
|
||||
// nosemgrep: javascript.lang.security.audit.non-literal-regexp.non-literal-regexp
|
||||
return `http://127.0.0.1:${apiPort}/v1/rerank`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply reranking via /v1/rerank (loopback-only) if rerankEnabled + rerankProviderModel is set.
|
||||
* Returns reordered array (or original order on any error — rerank failure never fails retrieval).
|
||||
*
|
||||
* Security note: the URL is a hardcoded loopback address (127.0.0.1:20128) — it never
|
||||
* carries sensitive data over a network link. HTTP is safe for loopback-only IPC.
|
||||
* Auth note (#12745): /v1/rerank is a CLIENT_API route gated by clientApiPolicy — with
|
||||
* REQUIRE_API_KEY=true an unauthenticated loopback call gets 401'd by the same policy
|
||||
* that protects it from the outside, and this call used to send no credential at all,
|
||||
* silently degrading retrieval to unranked order. Attach a real, DB-backed API key
|
||||
* (the same internal-probe selector already used by combo-health-check / cloud-sync-verify,
|
||||
* see pickApiKeyForInternalUse()) as a Bearer token instead of exempting the route.
|
||||
*
|
||||
* Security note: the URL is a loopback address (127.0.0.1) — it never carries sensitive
|
||||
* data over a network link. HTTP is safe for loopback-only IPC.
|
||||
* nosemgrep: javascript.lang.security.detect-non-literal-url
|
||||
*/
|
||||
async function applyRerank<T extends { memory: Memory; score: number }>(
|
||||
@@ -171,10 +186,14 @@ async function applyRerank<T extends { memory: Memory; score: number }>(
|
||||
top_n: items.length,
|
||||
};
|
||||
|
||||
const res = await fetch(RERANK_LOOPBACK_URL, {
|
||||
const internalKey = await pickApiKeyForInternalUse("internal-probe");
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (internalKey) headers.authorization = `Bearer ${internalKey}`;
|
||||
|
||||
const res = await fetch(getRerankLoopbackUrl(), {
|
||||
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user