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
+15 -1
View File
@@ -116,6 +116,7 @@ export async function readBoundedBytes(
*/
export async function probeForbiddenBody(
response: Response,
signal?: AbortSignal,
): Promise<BodyProbeOutcome> {
const declared = declaredContentLength(response);
if (declared !== null && declared > 0) {
@@ -128,7 +129,20 @@ export async function probeForbiddenBody(
const reader = response.body.getReader();
try {
const next = await reader.read();
// NS-03. The probe owns the reader it opened, so the operation's lifetime
// has to reach it. Awaiting a bare `read()` left a non-cooperative stream
// locked after the deadline had already closed the public result, and the
// outer compensator could not cancel a body this reader still held.
const next = await readOrAbandon(reader, signal);
if (next === READ_ABANDONED) {
// Never awaited: cancelling a stream whose source ignores its signal can
// itself hang, and the caller already owns the terminal result.
void reader.cancel().catch(() => {});
return Object.freeze({
ok: false as const,
code: "RESPONSE_STREAM_FAILURE" as const,
});
}
if (next.done || !next.value || next.value.byteLength === 0) {
return Object.freeze({ ok: true as const, present: false });
}
+71 -20
View File
@@ -29,6 +29,10 @@ import {
INSTALLED_REST_AUTH_PROFILES,
type InstalledRestAuthProfiles,
} from "../../contracts/rest-profiles.ts";
import {
snapshotExactObject,
snapshotOwnDataRecord,
} from "../../contracts/exact-snapshot.ts";
import {
certaintyForAbandonedAttempt,
classifyProblemEffect,
@@ -604,25 +608,12 @@ export function createContractHttpExecutor(
"TIMEOUT",
);
}
const patch = patchResult;
if (patch?.kind === "SCOPE_FENCED") {
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
}
if (patch?.kind === "UNAUTHENTICATED") {
// A missing credential never downgrades into an anonymous request.
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
}
if (patch?.kind === "UNAVAILABLE") {
return finish(
authIntegrationFailure("CREDENTIAL_OWNER_UNAVAILABLE", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
if (
patch?.kind !== "READY" ||
patch.headers === null ||
typeof patch.headers !== "object"
) {
// NS-01. The whole answer is decoded once, inside the auth boundary, before
// any field is used. Reading `kind` and `headers` off the raw object left
// the decode outside that boundary: a throwing getter escaped into the
// transport catch and an auth outage was classified as a network failure.
const patch = decodeCredentialPatch(patchResult);
if (patch === null) {
// An off-contract answer is a collaborator breach, never a session
// verdict the caller may act on.
return finish(
@@ -630,6 +621,19 @@ export function createContractHttpExecutor(
"AUTH_INTEGRATION_FAILURE",
);
}
if (patch.kind === "SCOPE_FENCED") {
return finish(scopeFenced(preDispatchEffect(isCommand)), "NOT_STARTED");
}
if (patch.kind === "UNAUTHENTICATED") {
// A missing credential never downgrades into an anonymous request.
return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED");
}
if (patch.kind === "UNAVAILABLE") {
return finish(
authIntegrationFailure("CREDENTIAL_OWNER_UNAVAILABLE", isCommand),
"AUTH_INTEGRATION_FAILURE",
);
}
// The idempotency key is contract-owned, so a credential owner supplying it
// stays the more specific request-contract violation.
@@ -926,6 +930,53 @@ export function createContractHttpExecutor(
return Object.freeze({ execute });
}
const CREDENTIAL_HEADER_VALUE_CEILING = 8_192;
/**
* NS-01. Decodes a credential owner's answer into an owned, frozen value. Every
* field is read exactly once through its own data descriptor, so an accessor, a
* Proxy that answers differently on a second read, an inherited or smuggled
* field, or a trap that throws all resolve to `null` — a collaborator breach —
* rather than escaping as an exception or being installed unvalidated.
*/
function decodeCredentialPatch(
source: unknown,
): CredentialPatchOutcome | null {
const outer = snapshotExactObject(source, {
allowed: ["kind", "headers"],
required: ["kind"],
});
if (outer === null) return null;
const kind = outer["kind"];
if (
kind === "UNAUTHENTICATED" ||
kind === "UNAVAILABLE" ||
kind === "SCOPE_FENCED"
) {
return Object.hasOwn(outer, "headers")
? null
: Object.freeze({ kind } as const);
}
if (kind !== "READY") return null;
// The key set stays open here so the profile's own admission — and the more
// specific reserved-header violation — can still report the precise reason.
const headers = snapshotOwnDataRecord(outer["headers"]);
if (headers === null) return null;
for (const value of Object.values(headers)) {
if (
typeof value !== "string" ||
value.length > CREDENTIAL_HEADER_VALUE_CEILING
) {
return null;
}
}
return Object.freeze({
kind: "READY" as const,
headers,
}) as CredentialPatchOutcome;
}
type AdmissionOutcome<Value, Problem> = Readonly<{
result: HttpExecutionOutcome<Value, Problem>;
certainty: string;
@@ -1013,7 +1064,7 @@ async function admitResponse<Input, WireOutput, Problem>(
// Success status: body policy first.
if (contract.responseBody === "NONE") {
const probe = await probeForbiddenBody(response);
const probe = await probeForbiddenBody(response, signal);
if (!probe.ok) {
return settled(
transportFailure(