test(kit): refuse a sealed field in any response the route kit drives

callRoute now fails a call whose JSON body carries a key ending in
Encrypted, or bytes serialised as an index-keyed object or a Buffer, on
every route it drives and regardless of the contract exemption. A sealed
column is decrypted or dropped before a response is built; one that leaks
is a defect, whatever the contract says. The kit's own test proves the
check finds both shapes and leaves an hour map alone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:41:11 +02:00
1 parent 36fe53bbaa
commit 7b9737c7b1
2 files changed
+80 -1

No files matched your search

+31 -1
View File
@@ -12,7 +12,12 @@ import { describe, expect, it } from "vitest";
import { apiHandler } from "@/lib/api-handler";
import { ContractViolation, anonymous, callRoute } from "../../kit/call-route";
import {
ContractViolation,
anonymous,
callRoute,
sealedFieldPaths,
} from "../../kit/call-route";
const token = {
id: "tok-1",
@@ -28,6 +33,31 @@ function answering(status: number, body: unknown) {
return apiHandler(async () => NextResponse.json(body, { status }));
}
describe("callRoute's sealed-field check", () => {
it("finds a sealed key and serialised bytes at any depth, and nothing else", () => {
const bytes = Object.fromEntries(
Array.from({ length: 32 }, (_, i) => [String(i), (i * 7) % 256]),
);
expect(
sealedFieldPaths({
data: {
rows: [{ notes: "a", notesEncrypted: null }],
track: { geometry: bytes },
raw: { type: "Buffer", data: [1, 2, 3] },
hours: { "0": 3, "1": 4, "2": 5 },
},
}),
).toEqual([
"data.rows[0].notesEncrypted",
"data.track.geometry",
"data.raw",
]);
expect(sealedFieldPaths({ data: { notes: "plain", count: 3 } })).toEqual(
[],
);
});
});
describe("callRoute's contract check", () => {
it("accepts a body the registered schema describes", async () => {
const res = await callRoute(
+49
View File
@@ -153,6 +153,10 @@ export async function callRoute<T = unknown>(
const isJson = contentType.includes("json") && text.length > 0;
const body: unknown = isJson ? JSON.parse(text) : text;
// Not skippable with `contract`: a sealed column leaving the server is a
// defect whatever the contract says, so no test can opt a route out of it.
if (isJson) assertNoSealedFields(options.method, options.route, body);
if (!options.contract?.skip) {
checkContract(
options.method,
@@ -182,6 +186,51 @@ export async function callRoute<T = unknown>(
};
}
/**
* A column the server stores sealed (`notesEncrypted`, `geometryEncrypted`,
* every `*Encrypted` field) is decrypted or dropped before a response is
* built. One that reaches a response anyway arrives as its key or as the
* serialised bytes (`{"0":…,"1":…}`, or `{ type: "Buffer", data: […] }`).
* Either fails the call, on every route the kit drives.
*/
export function sealedFieldPaths(value: unknown, at = ""): string[] {
if (Array.isArray(value)) {
return value.flatMap((item, i) => sealedFieldPaths(item, `${at}[${i}]`));
}
if (!isRecord(value)) return [];
const keys = Object.keys(value);
// A sealed value is at least a nonce and a tag (28 bytes), so a short
// index-keyed object (an hour map, a weekday tally) is not mistaken for one.
const bufferShape =
(value.type === "Buffer" && Array.isArray(value.data)) ||
(keys.length >= 16 &&
keys.every((k, i) => k === String(i)) &&
Object.values(value).every(
(v) =>
Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 255,
));
if (bufferShape) return [at || "(body)"];
return keys.flatMap((key) => {
const path = at ? `${at}.${key}` : key;
return /encrypted$/i.test(key)
? [path]
: sealedFieldPaths(value[key], path);
});
}
function assertNoSealedFields(
method: HttpMethod,
route: string,
body: unknown,
): void {
const found = sealedFieldPaths(body);
if (found.length > 0) {
throw new ContractViolation(
`${method} ${route} sent sealed or raw byte fields to the client: ${found.join(", ")}. Decrypt onto the readable field or drop the column before the response is built.`,
);
}
}
function concretePath(route: string, params: RouteParams): string {
const used = new Set<string>();
const path = route.replace(/\{([^}]+)\}/g, (_, name: string) => {