Files
OmniRoute/tests/unit/management-auth-hardening.test.ts
ThongAccount 1188847f0f feat: add setting for provider/model-specific parameters (#6649)
* fix(electron): bump electron 42→43 + build better-sqlite3 from source (ABI 148) (#6605)

fix(electron): bump electron 42→43 + rebuild better-sqlite3 from source against the Electron ABI (148).

Electron 43 raises NODE_MODULE_VERSION to 148; better-sqlite3@12.11.1 has no electron-v148 prebuild, so the packaged app died with 'Nenhum driver SQLite disponível'. prepare-electron-standalone now compiles better-sqlite3 from source against the electron headers into build/Release (where 'bindings' resolves it). Validated by Electron Package Smoke (green) + local (node_register_module_v148).

Supersedes #6378. (--admin: the only reds are SonarQube/SonarCloud failing on a coverage-report artifact digest-mismatch — a GitHub Actions infra flake, not this diff; Sonar is green on main and the diff touches only the electron build.)

* deps: bump the development group across 1 directory with 6 updates (#6588)

deps: bump the development group (6 updates). Rebased onto current main; all checks green after the electron-smoke fix (#6605).

* fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump (#6620)

fix(proxy): force CONNECT tunnel for HTTP proxied requests (undici 8.7) + production deps bump.

undici 8.6+ changed ProxyAgent to forward plain-HTTP via request-proxy instead of CONNECT, breaking OAuth refresh through a connection proxy (501). proxyDispatcher now passes proxyTunnel:true. Validated: Unit Tests 3/8 (the OAuth-proxy test) green, new regression test green (fails without the fix on undici 8.7), SonarQube green.

Supersedes #6380. (--admin: the only red is Electron Package Smoke failing on a next-build artifact 'digest-mismatch' — a GitHub Actions infra flake corrupting the asar ('file data stream has unexpected number of bytes'); the better-sqlite3 rebuild itself succeeded (gyp ok) and the electron path is unchanged from #6605 which passed the smoke. Not this diff.)

* feat(db): add provider param filter config store (key_value namespace)

Add paramFilters.ts module for CRUD against provider_param_filters
namespace in the key_value table, with in-memory cache + generation
counter invalidation. Supports denylist/allowlist per provider and
per model, plus auto-learn flag.

Migration 118 documents the namespace (no schema change).

Issue: #6625

* feat(proxy): add detectUnsupportedParam regex for auto-learning

Add UNSUPPORTED_PARAM_RE and detectUnsupportedParam() to extract
the offending parameter name from upstream 400 error messages like
'Unsupported parameter(s): thinking'.

Issue: #6625

* feat(proxy): extend stripUnsupportedParams with config-driven denylist/allowlist

Add applyConfigFilters() called after hardcoded STRIP_RULES in
stripUnsupportedParams(). Config-driven rules (DB-backed via
paramFilters.ts) support provider-level and model-level:
  1. Provider denylist (delete body[key])
  2. Model denylist (delete body[key])
  3. Provider allowlist (restore from pre-strip snapshot)
  4. Model allowlist (restore from pre-strip snapshot)

Allowlist only restores keys the client actually sent — never
introduces new params.

Issue: #6625

* feat(proxy): wire auto-learn of unsupported params into 400-downgrade loop

When a provider returns 400 with 'Unsupported parameter: X' and the
provider config has autoLearn enabled, auto-detect the param name
via detectUnsupportedParam(), persist it to the provider's block
list via addParamToBlocklist(), then strip and retry.

Issue: #6625

* test: add tests for provider param filter denylist/allowlist/auto-learn

Three new test files:
- param-filters-apply.test.ts — hardcoded rules regression + direct
  applyConfigFilters tests (no DB dependency)
- param-filters-db.test.ts — CRUD against key_value, cache invalidation,
  full filter pipeline (DB-backed config → stripUnsupportedParams),
  16 tests in isolated temp DB
- param-filters-auto-learn.test.ts — UNSUPPORTED_PARAM_RE regex matching
  and detectUnsupportedParam edge cases

All existing tests unchanged and passing.

Issue: #6625

* feat(proxy): add global auto-learn flag for unsupported params

Add isAutoLearnGloballyEnabled() and setGlobalAutoLearnEnabled() to
paramFilters.ts. The global flag (stored as key __global__ in the
provider_param_filters namespace) acts as a master switch: when
enabled, ALL providers auto-learn unsupported params from 400 errors.

In base.ts, the auto-learn check now evaluates:
  shouldAutoLearn = isAutoLearnGloballyEnabled() || perProviderConfig?.autoLearn

Global flag defaults to false (opt-in). Tests cover enable/disable/
default/no-interference-with-per-provider-config.

Issue: #6625

* fix: apply PR#6649 review feedback — model-scoped auto-learn and precedence order

Fixes from gemini-code-assist[bot] review:
- HIGH: Auto-learn now scoped to the specific model that triggered the 400
  (addParamToBlocklist(this.provider, autoLearned, model)) instead of
  adding to the provider-level blocklist globally
- HIGH: Reordered applyConfigFilters so model-level operations run AFTER
  provider-level operations (model denylist → model allowlist override
  provider allowlist → provider denylist)
- MEDIUM: Include model name in auto-learn log message

Adds regression test verifying model-level denylist beats provider-level
allowlist.

Issue: #6625
PR: #6649

* feat(ui): add provider-level param filter section to detail page

Add ProviderParamFilterSection component rendered on each provider
detail page, backed by GET|PUT|DELETE /api/providers/[id]/param-filters.

UI allows operators to configure:
- Blocked params (comma-separated, stripped from outgoing requests)
- Allowed params (comma-separated, re-added after denylist stripping)
- Auto-learn toggle (per-provider, enables auto-learning from 400 errors)

Wired into ProviderDetailPageClient.tsx between the Playground panel
and the Modals section.

Issue: #6625
PR: #6649

* feat(ui): add model-level param filter fields in compat popover

Extend ModelCompatPopover with Blocked params and Allowed params
text inputs for model-level denylist/allowlist overrides.

Model-specific block/allow data is persisted via the param-filters
API endpoint (PUT /api/providers/:id/param-filters) with the model
scope under the models key.

Both ModelRow and PassthroughModelRow now pass providerId and modelId
to the popover.

Issue: #6625
PR: #6649

* chore: gitignore .claude-flow/

* fix(param-filters): review follow-ups — auth gate, error sanitization, Zod body validation, typecheck, file-size, i18n keys

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(param-filters): drop unrelated main-drift from the fork branch (deps/electron/proxy files belong to #6620/#6605/#6588, not this PR)

* refactor(param-filters): split oversized functions — keep complexity gate at baseline

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(param-filters): decompose config parser helpers — keep cognitive-complexity gate at baseline

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): restore sibling #6648 bullet eaten by merge auto-resolve + re-insert #6649 entry

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-07-08 14:55:00 -03:00

297 lines
12 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
test("Codex apply-local auth route requires management authentication before local writes", () => {
const content = fs.readFileSync(
"src/app/api/providers/[id]/codex-auth/apply-local/route.ts",
"utf8"
);
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'));
assert.ok(content.includes("const authError = await requireManagementAuth(request);"));
assert.ok(content.includes("if (authError) return authError;"));
assert.ok(
content.indexOf("requireManagementAuth(request)") <
content.indexOf("ensureCliConfigWriteAllowed()")
);
});
test("admin concurrency route requires management authentication before read or reset", () => {
const content = fs.readFileSync("src/app/api/admin/concurrency/route.ts", "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'));
assert.ok(content.includes("const authError = await requireManagementAuth(request);"));
assert.ok(content.includes("if (authError) return authError;"));
assert.ok(
content.indexOf("requireManagementAuth(request)") < content.indexOf("getAllRateLimitStatus()")
);
assert.ok(
content.indexOf("requireManagementAuth(request)") < content.indexOf("resetAllSemaphores()")
);
});
test("compression analytics route requires management authentication before returning metrics", () => {
const content = fs.readFileSync("src/app/api/analytics/compression/route.ts", "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'));
assert.ok(!content.includes("enforceApiKeyPolicy"));
assert.ok(content.includes("const authError = await requireManagementAuth(req);"));
assert.ok(content.includes("if (authError) return authError;"));
assert.ok(
content.indexOf("requireManagementAuth(req)") <
content.indexOf("getCompressionAnalyticsSummary(")
);
});
test("administrative pricing and routing routes require management authentication", () => {
const routePaths = [
"src/app/api/pricing/route.ts",
"src/app/api/pricing/sync/route.ts",
"src/app/api/model-combo-mappings/route.ts",
"src/app/api/model-combo-mappings/[id]/route.ts",
];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
}
});
test("memory management routes require management authentication", () => {
const routePaths = ["src/app/api/memory/route.ts", "src/app/api/memory/[id]/route.ts"];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
}
});
test("provider validation routes require management authentication before reading credentials", () => {
const routePaths = [
"src/app/api/provider-nodes/validate/route.ts",
"src/app/api/providers/validate/route.ts",
];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
assert.ok(
content.indexOf("requireManagementAuth(request)") < content.indexOf("request.json()"),
`${routePath} should authenticate before parsing submitted provider credentials`
);
}
});
test("provider param-filters route requires management authentication on GET/PUT/DELETE (#6649)", () => {
const routePath = "src/app/api/providers/[id]/param-filters/route.ts";
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'));
// GET, PUT, and DELETE must each gate on requireManagementAuth() before
// touching the param filter config.
const handlers = ["GET", "PUT", "DELETE"];
for (const handler of handlers) {
const handlerStart = content.indexOf(`export async function ${handler}(`);
assert.ok(handlerStart >= 0, `${routePath} is missing a ${handler} handler`);
const nextHandlerStart = content.indexOf("export async function", handlerStart + 1);
const handlerBody = content.slice(
handlerStart,
nextHandlerStart === -1 ? content.length : nextHandlerStart
);
assert.ok(
handlerBody.includes("const authError = await requireManagementAuth(request);"),
`${routePath} ${handler} handler must call requireManagementAuth(request)`
);
assert.ok(
handlerBody.includes("if (authError) return authError;"),
`${routePath} ${handler} handler must short-circuit on authError`
);
}
});
test("Antigravity CLI (agy) credential import routes require management authentication before reading the body", () => {
// Routes that parse a JSON body — auth MUST run before request.json().
const jsonBodyRoutes = [
"src/app/api/providers/agy-auth/import/route.ts",
"src/app/api/providers/agy-auth/import-bulk/route.ts",
];
for (const routePath of jsonBodyRoutes) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
assert.ok(
content.indexOf("requireManagementAuth(request)") < content.indexOf("request.json()"),
`${routePath} should authenticate before parsing the submitted token`
);
}
// Routes that read non-JSON bodies (local file / uploaded ZIP) — auth still comes first.
const otherBodyRoutes = [
"src/app/api/providers/agy-auth/apply-local/route.ts",
"src/app/api/providers/agy-auth/zip-extract/route.ts",
];
for (const routePath of otherBodyRoutes) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
}
});
test("usage analytics and request log routes require management authentication", () => {
const routePaths = [
"src/app/api/usage/analytics/route.ts",
"src/app/api/usage/history/route.ts",
"src/app/api/usage/request-logs/route.ts",
"src/app/api/usage/logs/route.ts",
];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
}
});
test("middleware hook registry routes require management authentication before storing or running code", () => {
// /api/middleware/hooks accepts arbitrary JS code that is compiled with
// `new Function` in `src/lib/middleware/registry.ts` and executed on every
// chat-completion request via `runHooks()`. Without management auth, anyone
// who can reach the server can register a hook and achieve RCE. The
// centralized authz pipeline (src/proxy.ts) is dead code in production
// because Next.js requires the file to be named middleware.ts (see commit
// 3fb72b973), so each route must self-gate.
const routePaths = [
"src/app/api/middleware/hooks/route.ts",
"src/app/api/middleware/hooks/[name]/route.ts",
];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
assert.ok(
content.indexOf("requireManagementAuth(request)") < content.indexOf("registerHook(saved"),
`${routePath} must authenticate before calling registerHook (RCE surface)`
);
}
});
test("MCP transport and inspection routes require management authentication", () => {
// /api/mcp/* is classified as LOCAL_ONLY in routeGuard.ts, but that
// classification only takes effect inside runAuthzPipeline, which is not
// invoked by Next.js in production (src/proxy.ts vs middleware.ts). Each
// route must self-enforce. requireManagementAuth covers: CLI machine
// token (loopback), dashboard session cookie, and manage-scope API key —
// matching the documented bypasses in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES.
const routePaths = [
"src/app/api/mcp/status/route.ts",
"src/app/api/mcp/tools/route.ts",
"src/app/api/mcp/audit/route.ts",
"src/app/api/mcp/audit/stats/route.ts",
"src/app/api/mcp/sse/route.ts",
"src/app/api/mcp/stream/route.ts",
];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'), routePath);
assert.ok(
content.includes("const authError = await requireManagementAuth(request);"),
routePath
);
assert.ok(content.includes("if (authError) return authError;"), routePath);
}
});
test("management routes sanitize error.message before returning it to clients", () => {
// Hard Rule #12 (docs/security/ERROR_SANITIZATION.md): HTTP responses must
// route every err.message/err.stack through sanitizeErrorMessage() or
// buildErrorBody(). Raw error.message can leak absolute paths, stack
// traces, or upstream provider internals into client-visible JSON bodies.
const routePaths = [
"src/app/api/analytics/diversity/route.ts",
"src/app/api/db-backups/export/route.ts",
"src/app/api/db-backups/import/route.ts",
"src/app/api/db-backups/route.ts",
"src/app/api/evals/route.ts",
"src/app/api/evals/[suiteId]/route.ts",
"src/app/api/providers/[id]/models/route.ts",
"src/app/api/providers/[id]/sync-models/route.ts",
"src/app/api/providers/[id]/param-filters/route.ts",
"src/app/api/sessions/route.ts",
"src/app/api/storage/health/route.ts",
"src/app/api/sync/cloud/route.ts",
"src/app/api/telemetry/summary/route.ts",
"src/app/api/translator/history/route.ts",
"src/app/api/cache/reasoning/route.ts",
"src/app/api/cache/route.ts",
"src/app/api/models/test/route.ts",
"src/app/api/settings/proxy/test/route.ts",
];
// The pattern we forbid is `error.message` (or `e.message`/`err.message`) appearing
// inside a JSON response body or being returned directly from a helper. We require
// these files to either:
// (a) import sanitizeErrorMessage / buildErrorBody from open-sse/utils/error, OR
// (b) import createErrorResponse from @/lib/api/errorResponse (which already wraps
// message through structured envelope).
const sanitizerHints = [
'from "@omniroute/open-sse/utils/error"',
'from "@/lib/api/errorResponse"',
"sanitizeErrorMessage(",
"buildErrorBody(",
];
for (const routePath of routePaths) {
const content = fs.readFileSync(routePath, "utf8");
const hasSanitizer = sanitizerHints.some((hint) => content.includes(hint));
assert.ok(
hasSanitizer,
`${routePath} must route error.message through sanitizeErrorMessage() or buildErrorBody() — Hard Rule #12`
);
}
});
test("memory health endpoint requires management authentication", () => {
// verifyExtractionPipeline() exposes the memory subsystem state (Qdrant
// reachability, DB error paths). Same precedent as /api/db/health and
// /api/monitoring/health, which require management auth.
const routePath = "src/app/api/memory/health/route.ts";
const content = fs.readFileSync(routePath, "utf8");
assert.ok(content.includes('from "@/lib/api/requireManagementAuth"'));
assert.ok(content.includes("const authError = await requireManagementAuth(request);"));
assert.ok(content.includes("if (authError) return authError;"));
});