fix: validate the snapshot that installs, not the object that was shown

Three trust boundaries checked a caller's object and then read it again to
use it. Between those two reads an accessor or a Proxy can answer
differently, so the value that passed validation and the value that was
installed were not the same value.

A credential owner's answer was read field by field outside the auth
boundary: a throwing `kind` getter escaped into the transport catch and an
auth outage reached operators as `NETWORK_FAILURE`. Contract composition
validated a contribution and then copied it, so a policy that answered
10,000 to the ceiling check and 999,999 to the copy installed the second
value. The cursor runtime validated its profile once and re-read it on
every page, so raising `maxPages` after construction widened a cap that
had already been checked.

`src/contracts/exact-snapshot.ts` is the one descriptor-based decoder they
now share: every property is read exactly once, an accessor, a symbol, an
inherited or non-enumerable field and a throwing trap all resolve to a
typed failure, and validation runs on the owned copy.

Separately, the `responseBody: NONE` probe awaited a bare `read()`. The
deadline produced a bounded public result while the raw reader kept its
lease, so the body stayed locked and the outer compensator could not
cancel it. The probe now takes the operation lifetime and owns the cancel
and the lock release itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 01:25:12 +09:00
co-authored by Claude Opus 5
parent cc91fc6ae0
commit df18349682
12 changed files with 943 additions and 79 deletions
@@ -71,7 +71,6 @@ describe("HTTP operation execution contract", () => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}));
const observedKeys: Array<string | null> = [];
const fetcher = vi.fn(
@@ -99,6 +99,188 @@ describe("LIVE-01 credential integration failures are not user session failures"
});
}
/**
* NS-01. Reading `patch.kind` and `patch.headers` off the raw answer put the
* credential decode outside the auth boundary: a throwing getter escaped into
* the transport catch and the outage was reported as `NETWORK_FAILURE`, so
* the operator saw a network incident instead of an auth integration one.
*/
const hostileOwners = [
{
label: "exposes a throwing kind getter",
attach: () =>
Object.defineProperty({}, "kind", {
enumerable: true,
get() {
throw new TypeError("hostile kind getter");
},
}) as never,
},
{
label: "exposes a throwing headers getter",
attach: () =>
Object.defineProperty({ kind: "READY" }, "headers", {
enumerable: true,
get() {
throw new TypeError("hostile headers getter");
},
}) as never,
},
{
label: "throws from an ownKeys trap",
attach: () =>
new Proxy(
{ kind: "READY", headers: { authorization: "Bearer ok" } },
{
ownKeys() {
throw new TypeError("hostile ownKeys trap");
},
},
) as never,
},
{
label: "throws from a getOwnPropertyDescriptor trap",
attach: () =>
new Proxy(
{ kind: "READY", headers: { authorization: "Bearer ok" } },
{
getOwnPropertyDescriptor() {
throw new TypeError("hostile descriptor trap");
},
},
) as never,
},
{
label: "carries the outcome only on its prototype",
attach: () =>
Object.create({
kind: "READY",
headers: { authorization: "Bearer ok" },
}) as never,
},
{
label: "carries an extra own field",
attach: () =>
Object.freeze({
kind: "READY",
headers: Object.freeze({ authorization: "Bearer ok" }),
injected: true,
}) as never,
},
{
label: "carries a symbol field",
attach: () =>
Object.freeze({
kind: "READY",
headers: Object.freeze({ authorization: "Bearer ok" }),
[Symbol.for("injected")]: true,
}) as never,
},
{
label: "hides the outcome behind a non-enumerable own field",
attach: () =>
Object.defineProperties(
{ kind: "READY" },
{
headers: {
enumerable: false,
value: { authorization: "Bearer ok" },
},
},
) as never,
},
];
for (const owner of hostileOwners) {
it(`closes as AUTH_INTEGRATION_FAILURE when the owner ${owner.label}`, async () => {
const fetcher = vi.fn(async () => jsonResponse([]));
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: owner.attach,
fetcher: fetcher as unknown as typeof fetch,
});
const outcome = await executor.execute(
bearerOperation(),
{ limit: 1 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome.kind).toBe("AUTH_INTEGRATION_FAILURE");
expect(outcome.effect).toBe("NOT_APPLICABLE");
expect(fetcher).toHaveBeenCalledTimes(0);
});
}
it("reads each field exactly once so a stateful answer cannot swap it", async () => {
const fetcher = vi.fn(async () => jsonResponse([]));
const kindReads: string[] = [];
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () =>
new Proxy(
{ kind: "READY", headers: { authorization: "Bearer first" } },
{
getOwnPropertyDescriptor(target, key) {
if (key === "kind") {
kindReads.push(key);
return {
configurable: true,
enumerable: true,
// A second read would answer with a different verdict.
value: kindReads.length > 1 ? "UNAUTHENTICATED" : "READY",
};
}
return Reflect.getOwnPropertyDescriptor(target, key);
},
},
) as never,
fetcher: fetcher as unknown as typeof fetch,
});
const outcome = await executor.execute(
bearerOperation(),
{ limit: 1 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome.kind).toBe("SUCCESS");
expect(kindReads).toHaveLength(1);
});
it("sends an owned header snapshot rather than the owner's live object", async () => {
const headers: Record<string, string> = { authorization: "Bearer first" };
let sentHeaders: Record<string, string> | undefined;
const fetcher = vi.fn(async (_url: unknown, init?: RequestInit) => {
sentHeaders = init?.headers as Record<string, string>;
return jsonResponse([]);
});
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
// The owner keeps a live reference to the object it handed over.
attachCredentials: () => ({ kind: "READY", headers }) as never,
fetcher: fetcher as unknown as typeof fetch,
});
const outcome = await executor.execute(
bearerOperation(),
{ limit: 1 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome.kind).toBe("SUCCESS");
headers.authorization = "Bearer swapped";
expect(sentHeaders?.["Authorization"] ?? sentHeaders?.["authorization"]).toBe(
"Bearer first",
);
});
it("still reports a real absent session as UNAUTHENTICATED", async () => {
const fetcher = vi.fn(async () => jsonResponse([]));
const executor = createContractHttpExecutor({
@@ -153,6 +335,61 @@ describe("LIVE-04 the total deadline owns every physical wait", () => {
).toBe("TIMEOUT");
});
/**
* NS-03. The `NONE` probe used to await `reader.read()` with no signal, so a
* deadline produced a bounded public result while the raw reader kept its
* lease on the body: the connection and the buffer stayed held after the
* operation had already ended.
*/
it("cancels and releases the NONE probe reader when the deadline owns the execution", async () => {
let pulls = 0;
let cancels = 0;
const neverEndingBody = new ReadableStream<Uint8Array>({
pull() {
pulls += 1;
return new Promise<void>(() => {});
},
cancel() {
cancels += 1;
},
});
const response = new Response(neverEndingBody, { status: 200 });
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
authProfiles: TEST_PROFILES,
attachCredentials: () =>
Object.freeze({
kind: "READY" as const,
headers: { authorization: "Bearer t" },
}),
fetcher: (async () => response) as unknown as typeof fetch,
});
const noBodyOperation = {
...bearerOperation(20),
contract: {
...bearerOperation(20).contract,
responseBody: "NONE" as const,
},
};
const outcome = await executor.execute(
noBodyOperation as never,
{ limit: 1 },
{ routeId: ROUTE_ID, scope: scopeSnapshot() },
);
expect(outcome.kind).toBe("TRANSPORT_FAILURE");
expect(
outcome.kind === "TRANSPORT_FAILURE" ? outcome.failure.kind : null,
).toBe("TIMEOUT");
expect(pulls).toBe(1);
await vi.waitFor(() => {
expect(cancels).toBe(1);
});
expect(response.body?.locked).toBe(false);
});
it("does not wait for a non-cooperative body reader past the deadline", async () => {
const neverEndingBody = new ReadableStream<Uint8Array>({
pull() {
@@ -106,7 +106,6 @@ describe("V3 HTTP observability projection", () => {
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: testCase.fetcher,
observe: sinks.projector,
@@ -152,7 +151,6 @@ describe("V3 HTTP observability projection", () => {
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () => {
throw new TypeError("network down");
@@ -195,7 +193,6 @@ describe("V3 HTTP observability projection", () => {
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
@@ -221,7 +218,6 @@ describe("V3 HTTP observability projection", () => {
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () =>
Response.json([{ id: "a", name: "A" }])) as unknown as typeof fetch,
@@ -288,7 +284,6 @@ describe("V3 HTTP observability projection", () => {
attachCredentials: () => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}),
fetcher: (async () =>
Response.json({ id: "created", name: "Created" }, {
@@ -169,4 +169,169 @@ describe("LIVE-03 composed contract registry", () => {
).toThrow();
}
});
/**
* NS-02. Composition validated the caller's object and then read it again to
* copy it. Between those two reads a stateful answer could swap a deadline or
* a retry budget, so the value that passed the ceiling check and the value the
* executor ran with were two different things.
*/
describe("validate the snapshot, never the source", () => {
/** Answers a safe value to a plain read and a hostile one to a copy. */
const statefulFrontend = () =>
new Proxy(
{ ...TEST_CONTRACT_CONTRIBUTION.http[0]!.frontend },
{
get(target, key, receiver) {
if (key === "totalDeadlineMs") return 10_000;
return Reflect.get(target, key, receiver);
},
getOwnPropertyDescriptor(target, key) {
if (key === "totalDeadlineMs") {
return {
configurable: true,
enumerable: true,
value: 999_999,
};
}
return Reflect.getOwnPropertyDescriptor(target, key);
},
},
);
const contributionWith = (row: unknown) => [
{ ...TEST_CONTRACT_CONTRIBUTION, http: [row] },
];
it("validates the deadline it will install, not the one it was shown", () => {
// The copied value exceeds the hard ceiling, so composition must stop
// rather than install a deadline no check ever saw.
expect(() =>
composeContractContributions(
contributionWith({
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
frontend: statefulFrontend(),
}) as never,
),
).toThrow();
});
it("keys the registry by the operation id it copied, not the one it was shown", () => {
const base = { ...TEST_CONTRACT_CONTRIBUTION.http[0]!.contract };
const contract = new Proxy(base, {
get(target, key, receiver) {
if (key === "operationId") return base.operationId;
return Reflect.get(target, key, receiver);
},
getOwnPropertyDescriptor(target, key) {
if (key === "operationId") {
return {
configurable: true,
enumerable: true,
value: "SwappedOperation",
};
}
return Reflect.getOwnPropertyDescriptor(target, key);
},
});
const composed = composeContractContributions(
contributionWith({
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
contract,
}) as never,
);
expect([...composed.httpByOperationId.keys()]).toEqual([
"SwappedOperation",
]);
expect(
composed.httpByOperationId.get("SwappedOperation")!.contract
.operationId,
).toBe("SwappedOperation");
});
const hostileRows = [
{
label: "an installed row with an extra own field",
row: () => ({
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
injected: true,
}),
},
{
label: "an installed row with a symbol field",
row: () => ({
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
[Symbol.for("injected")]: true,
}),
},
{
label: "an installed row with a custom prototype",
row: () =>
Object.assign(Object.create({ injected: true }), {
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
}),
},
{
label: "an installed row hiding a non-enumerable own field",
row: () =>
Object.defineProperty(
{ ...TEST_CONTRACT_CONTRIBUTION.http[0]! },
"injected",
{ enumerable: false, value: true },
),
},
{
label: "an installed row behind a throwing ownKeys trap",
row: () =>
new Proxy(
{ ...TEST_CONTRACT_CONTRIBUTION.http[0]! },
{
ownKeys() {
throw new TypeError("hostile ownKeys trap");
},
},
),
},
];
for (const { label, row } of hostileRows) {
it(`refuses to compose ${label}`, () => {
expect(() =>
composeContractContributions(contributionWith(row()) as never),
).toThrow();
});
}
it("refuses a contribution whose own shape is not exact", () => {
for (const contribution of [
{ ...TEST_CONTRACT_CONTRIBUTION, injected: true },
Object.assign(Object.create({ injected: true }), {
...TEST_CONTRACT_CONTRIBUTION,
}),
{
...TEST_CONTRACT_CONTRIBUTION,
[Symbol.for("injected")]: true,
},
]) {
expect(() =>
composeContractContributions([contribution] as never),
).toThrow();
}
});
it("refuses an event contract whose own shape is not exact", () => {
const events = TEST_CONTRACT_CONTRIBUTION.events;
if (events.length === 0) return;
for (const event of [
{ ...events[0]!, injected: true },
Object.assign(Object.create({ injected: true }), { ...events[0]! }),
]) {
expect(() =>
composeContractContributions([
{ ...TEST_CONTRACT_CONTRIBUTION, events: [event] },
] as never),
).toThrow();
}
});
});
});
@@ -158,3 +158,121 @@ describe("bounded cursor pagination runtime", () => {
});
});
});
/**
* NS-07. The profile was validated once and then re-read on every page, so
* raising `maxPages` after construction widened a cap that had already been
* checked — the runtime issued more requests and returned more items than the
* validated profile allowed.
*/
describe("NS-07 the caps are the ones that were validated", () => {
it("keeps the page cap captured at construction", async () => {
const mutable: {
profileId: string;
maxPages: number;
maxTotalItems: number;
maxEstimatedBytes: number;
maxCursorBytes: number;
allowSparsePage: boolean;
} = { ...profile, maxPages: 1 };
const loadPage = vi.fn(async () => ({
ok: true as const,
value: {
items: [1],
nextCursor: `cursor-${loadPage.mock.calls.length}`,
hasMore: true,
snapshotToken: "snapshot-a",
},
}));
const runtime = createCursorPaginationRuntime({
definitionId: "LIST_ALL",
profile: mutable,
loadPage,
});
mutable.maxPages = 3;
mutable.maxTotalItems = 99;
await expect(runtime.loadAll({})).resolves.toMatchObject({
ok: false,
error: { code: "PAGINATION_PAGE_LIMIT" },
});
expect(loadPage).toHaveBeenCalledTimes(1);
});
it("keeps the loader captured at construction", async () => {
const original = vi.fn(async () => ({
ok: true as const,
value: {
items: [1],
nextCursor: null,
hasMore: false,
snapshotToken: null,
},
}));
const replacement = vi.fn();
const dependencies = {
definitionId: "LIST_ALL",
profile,
loadPage: original,
};
const runtime = createCursorPaginationRuntime(dependencies);
dependencies.loadPage = replacement as never;
await expect(runtime.loadAll({})).resolves.toMatchObject({ ok: true });
expect(original).toHaveBeenCalledTimes(1);
expect(replacement).not.toHaveBeenCalled();
});
const hostileProfiles: readonly (readonly [string, () => unknown])[] = [
[
"an accessor cap",
() =>
Object.defineProperty({ ...profile }, "maxPages", {
enumerable: true,
get: () => 3,
}),
],
[
"an inherited cap",
() => Object.create({ ...profile }) as unknown,
],
["an extra own field", () => ({ ...profile, injected: true })],
[
"a symbol field",
() => ({ ...profile, [Symbol.for("injected")]: true }),
],
[
"a non-enumerable own field",
() =>
Object.defineProperty({ ...profile }, "injected", {
enumerable: false,
value: true,
}),
],
[
"a throwing ownKeys trap",
() =>
new Proxy(
{ ...profile },
{
ownKeys() {
throw new TypeError("hostile ownKeys trap");
},
},
),
],
];
for (const [label, build] of hostileProfiles) {
it(`refuses to build a runtime from ${label}`, () => {
expect(() =>
createCursorPaginationRuntime({
definitionId: "LIST_ALL",
profile: build() as never,
loadPage: vi.fn(),
}),
).toThrow(TypeError);
});
}
});
-14
View File
@@ -74,7 +74,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: async () =>
Response.json(
@@ -106,7 +105,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
@@ -145,7 +143,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: {},
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
@@ -191,7 +188,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: { [headerName]: "credential-owned-key" },
credentials: "omit" as const,
}));
const fetcher = vi.fn();
const executor = createContractHttpExecutor({
@@ -236,7 +232,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
sleep: async () => {},
@@ -277,7 +272,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
});
@@ -302,7 +296,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(async (input) => {
urls.push(String(input));
@@ -351,7 +344,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
});
@@ -371,7 +363,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(),
});
@@ -409,7 +400,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(async () => Response.json({ malformed: true }, { status: 201 })),
});
@@ -445,7 +435,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(async () => {
current = false;
@@ -582,7 +571,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher,
monotonicNow: () => 0,
@@ -636,7 +624,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(async () =>
Response.json(
@@ -678,7 +665,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
attachCredentials: () => ({
kind: "READY",
headers: {},
credentials: "omit",
}),
fetcher: vi.fn(async () => new Response(body, { status: 200 })),
});