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:
co-authored by
Claude Opus 5
parent
cc91fc6ae0
commit
df18349682
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user