fix(providers): reject silent validation degradation with 400 and rejected keys (#11101)

Validated on the combined batch board over release/v3.8.50 tip d91238b7: static gates clean, typecheck:core clean, focused tests green.

Strict schema + {sanitized, rejected} DB boundary — silent validation degradation now answers 400 with the offending keys. Caller audit done: only the providers write path consumes the sanitizers. Thank you @maxmad64bis!
This commit is contained in:
Dizzle
2026-08-22 19:39:58 +02:00
committed by GitHub
parent d021423af3
commit f3b190ba3e
10 changed files with 247 additions and 59 deletions

View File

@@ -0,0 +1 @@
- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101))

View File

@@ -121,7 +121,17 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
const { id } = await params;
const validation = validateBody(updateProviderConnectionSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
// never drop an operator's intent silently. Surface the rejected
// keys (field paths and unrecognized-key names) alongside the existing
// error envelope so clients and the UI can tell exactly what was refused.
const rejected = [
...validation.error.details.map((d) => d.field).filter(Boolean),
...validation.error.details.flatMap((d) => d.keys ?? []),
];
return NextResponse.json(
{ error: { ...validation.error, rejected } },
{ status: 400 }
);
}
const body = validation.data;
const {

View File

@@ -627,15 +627,26 @@ export async function createProviderConnection(data: JsonRecord) {
// to no-overrides) keeps the field present on the returned object so the
// UI can tell "field was read, no overrides" apart from "field absent."
if ("quotaWindowThresholds" in connection) {
connection.quotaWindowThresholds = sanitizeQuotaWindowThresholds(
connection.quotaWindowThresholds
);
const result = sanitizeQuotaWindowThresholds(connection.quotaWindowThresholds);
if (result.rejected.length > 0) {
throw new Error(
`Refusing to persist quotaWindowThresholds with rejected keys: ${result.rejected.join(", ")}`
);
}
connection.quotaWindowThresholds = result.sanitized;
}
// Same sanitization for rateLimitOverrides — keep in-memory representation
// in sync with what gets persisted.
// in sync with what gets persisted. Reject (don't silently drop) invalid
// keys/values so a direct DB writer can't lose operator intent.
if ("rateLimitOverrides" in connection) {
connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides);
const result = sanitizeRateLimitOverrides(connection.rateLimitOverrides);
if (result.rejected.length > 0) {
throw new Error(
`Refusing to persist rateLimitOverrides with rejected keys: ${result.rejected.join(", ")}`
);
}
connection.rateLimitOverrides = result.sanitized;
}
_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
@@ -849,13 +860,24 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
// Mirror the sanitization the create path applies — keep the returned
// object in lockstep with what we persist.
if ("quotaWindowThresholds" in merged) {
const sanitized = sanitizeQuotaWindowThresholds(merged.quotaWindowThresholds);
const result = sanitizeQuotaWindowThresholds(merged.quotaWindowThresholds);
if (result.rejected.length > 0) {
throw new Error(
`Refusing to persist quotaWindowThresholds with rejected keys: ${result.rejected.join(", ")}`
);
}
// For updates we always carry the key forward (even as null) so the read
// path surfaces the cleared state to callers that just patched it.
merged.quotaWindowThresholds = sanitized;
// path surfaces the cleared state to callers that merged it.
merged.quotaWindowThresholds = result.sanitized;
}
if ("rateLimitOverrides" in merged) {
merged.rateLimitOverrides = sanitizeRateLimitOverrides(merged.rateLimitOverrides);
const result = sanitizeRateLimitOverrides(merged.rateLimitOverrides);
if (result.rejected.length > 0) {
throw new Error(
`Refusing to persist rateLimitOverrides with rejected keys: ${result.rejected.join(", ")}`
);
}
merged.rateLimitOverrides = result.sanitized;
}
const existingRecord = toRecord(existing);

View File

@@ -64,20 +64,37 @@ export function normalizeBooleanColumn(value: unknown, fallback: boolean): boole
return fallback;
}
// Result of sanitizing a per-connection overrides/threshold map. `sanitized`
// is the cleaned value (or null when it collapses to nothing); `rejected`
// lists every key that was refused so callers can fail loudly
// instead of silently dropping the operator's input.
export type SanitizeResult = {
sanitized: Record<string, number> | null;
rejected: string[];
};
// Sanitize the per-connection rate limit overrides map: keep only known
// fields with valid numeric values. Called once at each write-path boundary.
export function sanitizeRateLimitOverrides(value: unknown): Record<string, number> | null {
if (value === null || value === undefined) return null;
if (typeof value !== "object" || Array.isArray(value)) return null;
// fields with valid non-negative integer values. Called once at each
// write-path boundary. Unknown keys and invalid values go into `rejected`
// rather than being dropped in silence.
export function sanitizeRateLimitOverrides(value: unknown): SanitizeResult {
if (value === null || value === undefined) return { sanitized: null, rejected: [] };
if (typeof value !== "object" || Array.isArray(value)) return { sanitized: null, rejected: [] };
const allowedKeys = new Set(["rpm", "tpm", "tpd", "minTime", "maxConcurrent"]);
const rejected: string[] = [];
const map: Record<string, number> = {};
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
if (!allowedKeys.has(key)) continue;
if (!allowedKeys.has(key)) {
rejected.push(key);
continue;
}
if (typeof v === "number" && Number.isInteger(v) && v >= 0) {
map[key] = v;
} else {
rejected.push(key);
}
}
return Object.keys(map).length === 0 ? null : map;
return { sanitized: Object.keys(map).length === 0 ? null : map, rejected };
}
// Serialize an already-sanitized map for SQLite TEXT storage.
@@ -91,20 +108,29 @@ export function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" ? (value as JsonRecord) : {};
}
// Sanitize the per-window threshold map: keep only 0-100 integer values.
// Called once at each write-path boundary (createProviderConnection +
// updateProviderConnection) so both the in-memory return and the persisted
// row share the same shape. Serialization below trusts this output.
export function sanitizeQuotaWindowThresholds(value: unknown): Record<string, number> | null {
if (value === null || value === undefined) return null;
if (typeof value !== "object" || Array.isArray(value)) return null;
// Sanitize the per-window threshold map: keep only 0-100 integer values with
// keys no longer than 64 chars. Called once at each write-path boundary
// (createProviderConnection + updateProviderConnection) so both the in-memory
// return and the persisted row share the same shape. Serialization below
// trusts this output. Invalid keys/values go into `rejected` rather than being
// dropped in silence.
export function sanitizeQuotaWindowThresholds(value: unknown): SanitizeResult {
if (value === null || value === undefined) return { sanitized: null, rejected: [] };
if (typeof value !== "object" || Array.isArray(value)) return { sanitized: null, rejected: [] };
const rejected: string[] = [];
const map: Record<string, number> = {};
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
if (key.length > 64) {
rejected.push(key);
continue;
}
if (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100) {
map[key] = v;
} else {
rejected.push(key);
}
}
return Object.keys(map).length === 0 ? null : map;
return { sanitized: Object.keys(map).length === 0 ? null : map, rejected };
}
export function toStringOrNull(value: unknown): string | null {

View File

@@ -4,6 +4,10 @@ import { z } from "zod";
type ValidationErrorDetail = {
field: string;
message: string;
// Present for `unrecognized_keys` issues: the unknown key names that were
// refused (e.g. a typo'd override key). Surfaced by callers so clients can
// tell exactly which keys were rejected.
keys?: string[];
};
type ValidationErrorPayload = {
@@ -45,6 +49,9 @@ export function validateBody<TSchema extends z.ZodTypeAny>(
details: issues.map((e) => ({
field: e.path.join("."),
message: e.message,
...(("keys" in e && (e as { keys?: string[] }).keys)
? { keys: (e as { keys: string[] }).keys }
: {}),
})),
},
};

View File

@@ -420,6 +420,25 @@ export const providerNodeValidateSchema = z.object({
modelId: z.string().trim().max(200).optional().or(z.literal("")),
});
// rate-limit override numeric fields must reject operator intent loss.
// `z.coerce.number()` silently turns "" into 0 and "60abc" into NaN, which
// would drop or distort the value instead of rejecting it. Preprocess first so
// an empty/non-numeric string fails validation (surfaced as a 400), while still
// coercing legit numeric strings like "60".
function rateLimitOverrideNumber(max: number) {
return z.preprocess(
(raw) => {
if (typeof raw === "string") {
if (raw.trim() === "") return NaN;
const parsed = Number(raw);
return Number.isNaN(parsed) ? raw : parsed;
}
return raw;
},
z.coerce.number().int().min(0).max(max)
);
}
export const updateProviderConnectionSchema = z
.object({
name: z.string().max(200).optional(),
@@ -468,17 +487,24 @@ export const updateProviderConnectionSchema = z
projectId: z.union([z.string(), z.null()]).optional(),
// Per-connection rate limit overrides — overrides the global RequestQueueSettings
// for this connection. Set to null to clear all overrides.
// Per-connection rate limit overrides — overrides the global
// RequestQueueSettings for this connection. Set to null to clear all
// overrides. `.strict()` rejects unknown keys (e.g. a typo'd `tmp`) with a
// 400 instead of silently stripping them: the operator's intent is
// never dropped without an error. `.nullable()` (rather than a
// `z.union([z.null(), …])`) keeps the `unrecognized_keys` issue at the top
// level so the rejected key name survives into the 400 response.
rateLimitOverrides: z
.union([
z.null(),
z.object({
rpm: z.coerce.number().int().min(0).max(1_000_000).optional(),
tpm: z.coerce.number().int().min(0).max(100_000_000).optional(),
tpd: z.coerce.number().int().min(0).max(10_000_000_000).optional(),
minTime: z.coerce.number().int().min(0).max(60_000).optional(),
maxConcurrent: z.coerce.number().int().min(0).max(10_000).optional(),
}),
])
.object({
rpm: rateLimitOverrideNumber(1_000_000).optional(),
tpm: rateLimitOverrideNumber(100_000_000).optional(),
tpd: rateLimitOverrideNumber(10_000_000_000).optional(),
minTime: rateLimitOverrideNumber(60_000).optional(),
maxConcurrent: rateLimitOverrideNumber(10_000).optional(),
})
.partial()
.strict()
.nullable()
.optional(),
proxyEnabled: z.boolean().optional(),
perKeyProxyEnabled: z.boolean().optional(),

View File

@@ -0,0 +1,24 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
sanitizeRateLimitOverrides,
sanitizeQuotaWindowThresholds,
} from "@/lib/db/providers/columns";
test("sanitizeRateLimitOverrides surfaces rejected keys (blocking, not silent)", () => {
const r = sanitizeRateLimitOverrides({ rpm: 10, foo: 1, tpm: -1 });
assert.deepEqual(r.sanitized, { rpm: 10 });
assert.deepEqual(r.rejected.sort(), ["foo", "tpm"]);
});
test("sanitizeQuotaWindowThresholds surfaces key-too-long and out-of-range", () => {
const r = sanitizeQuotaWindowThresholds({ ["a".repeat(65)]: 50, win: 101 });
assert.ok(r.rejected.length >= 1);
assert.ok(r.rejected.includes("win"));
});
test("valid input yields no rejected keys", () => {
const r = sanitizeRateLimitOverrides({ rpm: 10, tpm: 20 });
assert.deepEqual(r.rejected, []);
assert.deepEqual(r.sanitized, { rpm: 10, tpm: 20 });
});

View File

@@ -52,26 +52,38 @@ describe("providers/columns — normalizeBooleanColumn", () => {
});
describe("providers/columns — sanitizeRateLimitOverrides", () => {
it("returns null for nullish / non-object / array input", () => {
assert.equal(sanitizeRateLimitOverrides(null), null);
assert.equal(sanitizeRateLimitOverrides(undefined), null);
assert.equal(sanitizeRateLimitOverrides("x"), null);
assert.equal(sanitizeRateLimitOverrides([1, 2]), null);
it("returns {sanitized:null,rejected:[]} for nullish / non-object / array input", () => {
assert.deepEqual(sanitizeRateLimitOverrides(null), { sanitized: null, rejected: [] });
assert.deepEqual(sanitizeRateLimitOverrides(undefined), { sanitized: null, rejected: [] });
assert.deepEqual(sanitizeRateLimitOverrides("x"), { sanitized: null, rejected: [] });
assert.deepEqual(sanitizeRateLimitOverrides([1, 2]), { sanitized: null, rejected: [] });
});
it("keeps only allowed keys with non-negative integers", () => {
assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 10, bogus: 5, tpm: -1 }), { rpm: 10 });
it("keeps only allowed keys with non-negative integers, reports the rest as rejected", () => {
assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 10, bogus: 5, tpm: -1 }), {
sanitized: { rpm: 10 },
rejected: ["bogus", "tpm"],
});
});
it("returns null when nothing valid remains", () => {
assert.equal(sanitizeRateLimitOverrides({ rpm: 1.5, nope: 3 }), null);
it("returns {sanitized:null} when nothing valid remains, with rejected keys", () => {
assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 1.5, nope: 3 }), {
sanitized: null,
rejected: ["rpm", "nope"],
});
});
});
describe("providers/columns — sanitizeQuotaWindowThresholds", () => {
it("keeps only 0-100 integers", () => {
assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 50, b: 120, c: 0 }), { a: 50, c: 0 });
it("keeps only 0-100 integers, reports the rest as rejected", () => {
assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 50, b: 120, c: 0 }), {
sanitized: { a: 50, c: 0 },
rejected: ["b"],
});
});
it("returns null when empty", () => {
assert.equal(sanitizeQuotaWindowThresholds({ a: 200 }), null);
it("returns {sanitized:null} when nothing valid remains, with rejected keys", () => {
assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 200 }), {
sanitized: null,
rejected: ["a"],
});
});
});

View File

@@ -106,18 +106,36 @@ test("updateProviderConnection with explicit null clears the column entirely", a
assert.ok(reread.quotaWindowThresholds === null || reread.quotaWindowThresholds === undefined);
});
test("DB serializer drops out-of-range values silently", async () => {
// The DB module sanitizes the map on the way in; values outside 0-100 or
// non-integers are pruned. This is a defense in depth — the Zod schema
// already rejects them at the API boundary, but the DB shouldn't trust.
const created = await providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
name: "Codex Sanitize",
apiKey: "sk-san",
quotaWindowThresholds: { window5h: 95, bogus: 999, fractional: 1.5 },
});
assert.deepEqual(created.quotaWindowThresholds, { window5h: 95 });
test("DB serializer refuses out-of-range / invalid values instead of dropping silently", async () => {
// the DB module must refuse the write (throw) rather than
// silently prune invalid keys/values on the way in, so operator intent is
// never lost without an error. The Zod schema already rejects at the API
// boundary; this is defense in depth for direct DB writers (seed/scripts).
await assert.rejects(
() =>
providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
name: "Codex Sanitize",
apiKey: "sk-san",
quotaWindowThresholds: { window5h: 95, bogus: 999, fractional: 1.5 },
}),
/rejected keys/
);
});
test("DB serializer refuses unknown rate-limit override keys instead of dropping silently", async () => {
await assert.rejects(
() =>
providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
name: "Codex Sanitize RLO",
apiKey: "sk-san-2",
rateLimitOverrides: { rpm: 10, bogus: 999, tpm: -1 },
}),
/rejected keys/
);
});
test("updateProviderConnectionSchema accepts a valid window map", () => {

View File

@@ -0,0 +1,42 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { updateProviderConnectionSchema } from "@/shared/validation/schemas/provider";
test("PATCH rateLimitOverrides {rpm:\"60\"} coerces to a valid number", () => {
const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "60" } });
assert.equal(r.success, true);
});
test("unknown key in rateLimitOverrides is rejected (no silent drop)", () => {
const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: 10, foo: 1 } });
assert.equal(r.success, false);
const flaggedFoo = r.error!.issues.some(
(i) => i.path.includes("foo") || (i as { keys?: string[] }).keys?.includes("foo") || i.message.includes("foo")
);
assert.ok(
flaggedFoo,
`expected an issue flagging "foo", got: ${JSON.stringify(r.error!.issues)}`
);
});
test("quotaWindowThresholds key longer than 64 chars is rejected", () => {
const r = updateProviderConnectionSchema.safeParse({
quotaWindowThresholds: { ["a".repeat(65)]: 50 },
});
assert.equal(r.success, false);
});
test("empty string rate limit value is rejected (coerce \"\"→0 trap)", () => {
const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "" } });
assert.equal(r.success, false);
});
test("non-numeric rate limit value is rejected", () => {
const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "60abc" } });
assert.equal(r.success, false);
});
test("quotaWindowThresholds value outside 0-100 is rejected", () => {
const r = updateProviderConnectionSchema.safeParse({ quotaWindowThresholds: { win: 101 } });
assert.equal(r.success, false);
});