fix: hold transfer inputs and raw transfer work to what was verified
The capability vault checked an issuer's registration and then read it again to store it, including its nested header rows. A stateful issuer could show an allowed header set to the forbidden-header check and hand `Authorization` to the copy, so the vault stored — and the executor sent — a credential no rule had ever seen. The registration and everything nested in it is now snapshotted once, and only that snapshot is validated, frozen and stored. The upload control plane had the same shape one level down: a `sessionId` that answered `session_01` to the regex and `../../unsafe` to the result snapshot reached a success receipt. Two lifetimes were also unowned. A download source lease that resolved after the caller's abort never reached the holder, so nothing closed it and its fetch reader and capability lease outlived the terminal result; a compensator sharing the holder's close-once latch now closes it exactly once. And `dispose()` proved quiescence from the wrapper registry alone, so a provider that ignored its attempt deadline let teardown report a drained runtime and close the checkpoint store while the provider was still running. Raw provider promises are now their own registry and the drain must prove both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
aa8ac35600
commit
39a4a973a8
@@ -12,6 +12,11 @@ import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../browser-file-storage/result.ts";
|
||||
import {
|
||||
ownDataValue,
|
||||
snapshotExactArray,
|
||||
snapshotExactObject,
|
||||
} from "../../../contracts/exact-snapshot.ts";
|
||||
|
||||
export type PresignedHeaderBinding = Readonly<{
|
||||
name: string;
|
||||
@@ -167,28 +172,6 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
if (!registration || typeof registration !== "object") return invalid();
|
||||
// TR-RR-03. Exact own data only: an accessor re-runs on every later read,
|
||||
// an inherited field can be replaced through the prototype, and a symbol
|
||||
// key escapes a name-based sweep. The vault validates what the executor
|
||||
// will read, so it must read what it validated.
|
||||
try {
|
||||
if (Object.getOwnPropertySymbols(registration).length > 0) {
|
||||
return invalid();
|
||||
}
|
||||
const names = Object.getOwnPropertyNames(registration).sort();
|
||||
if (
|
||||
names.length !== REGISTRATION_KEYS.length ||
|
||||
names.some((name, index) => name !== REGISTRATION_KEYS[index])
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
for (const name of names) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(registration, name);
|
||||
if (!descriptor || !("value" in descriptor)) return invalid();
|
||||
}
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
if (registration.protocol !== PRESIGNED_TRANSFER_PROTOCOL) {
|
||||
return invalid();
|
||||
}
|
||||
@@ -229,14 +212,18 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
...registration.requiredResponseHeaders,
|
||||
]) {
|
||||
if (
|
||||
!header ||
|
||||
typeof header.name !== "string" ||
|
||||
typeof header.value !== "string" ||
|
||||
FORBIDDEN_CAPABILITY_HEADERS.has(header.name.toLowerCase())
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
if (
|
||||
registration.allowedQueryParameters.some(
|
||||
(parameter) => typeof parameter !== "string" || parameter.length === 0,
|
||||
)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(registration.expectedStatus) ||
|
||||
registration.expectedStatus < 200 ||
|
||||
@@ -270,11 +257,22 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
if (disposed) {
|
||||
return browserDataFailure("UNAVAILABLE", "PRESIGNED_TRANSFER");
|
||||
}
|
||||
// TR-01. One owned snapshot first, then validate and store only that
|
||||
// snapshot. Validating the issuer's own object and reading it again to
|
||||
// copy it let a stateful answer show an allowed header set to the
|
||||
// forbidden-header check and hand `Authorization` to the copy, so the
|
||||
// vault stored a capability no rule had ever seen.
|
||||
const snapshot = snapshotRegistration(registration);
|
||||
if (!snapshot) {
|
||||
return browserDataFailure("POLICY_REJECTED", "PRESIGNED_TRANSFER", {
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
});
|
||||
}
|
||||
// BT-PRE-04. The vault owns its own registration invariants so a second
|
||||
// issuer adapter, a test seam or composition code cannot register a
|
||||
// weaker capability of the same type. The HTTP decoder still owns the
|
||||
// wire shape; this only re-checks runtime invariants.
|
||||
const invalid = validatePresignedCapabilityRegistration(registration);
|
||||
const invalid = validatePresignedCapabilityRegistration(snapshot);
|
||||
if (invalid) return invalid;
|
||||
pruneExpired();
|
||||
if (
|
||||
@@ -293,14 +291,14 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
}
|
||||
|
||||
const capability = Object.freeze({
|
||||
capabilityReceipt: registration.capabilityReceipt,
|
||||
method: registration.method,
|
||||
binding: freezeBinding(registration.binding),
|
||||
mediaType: registration.mediaType,
|
||||
byteLength: registration.byteLength,
|
||||
maxBytes: registration.maxBytes,
|
||||
expectedSha256: registration.expectedSha256,
|
||||
expiresAtEpochMs: registration.expiresAtEpochMs,
|
||||
capabilityReceipt: snapshot.capabilityReceipt,
|
||||
method: snapshot.method,
|
||||
binding: freezeBinding(snapshot.binding),
|
||||
mediaType: snapshot.mediaType,
|
||||
byteLength: snapshot.byteLength,
|
||||
maxBytes: snapshot.maxBytes,
|
||||
expectedSha256: snapshot.expectedSha256,
|
||||
expiresAtEpochMs: snapshot.expiresAtEpochMs,
|
||||
}) as PresignedTransferCapability;
|
||||
const binding: PresignedCapabilityBinding = Object.freeze({
|
||||
capability,
|
||||
@@ -308,22 +306,22 @@ export function createPresignedCapabilityVault(options: Readonly<{
|
||||
capabilityReceipt: capability.capabilityReceipt,
|
||||
method: capability.method,
|
||||
binding: capability.binding,
|
||||
href: registration.href,
|
||||
origin: registration.origin,
|
||||
path: registration.path,
|
||||
href: snapshot.href,
|
||||
origin: snapshot.origin,
|
||||
path: snapshot.path,
|
||||
allowedQueryParameters: Object.freeze([
|
||||
...registration.allowedQueryParameters,
|
||||
...snapshot.allowedQueryParameters,
|
||||
]),
|
||||
requestHeaders: freezeHeaders(registration.requestHeaders),
|
||||
requestHeaders: freezeHeaders(snapshot.requestHeaders),
|
||||
requiredResponseHeaders: freezeHeaders(
|
||||
registration.requiredResponseHeaders,
|
||||
snapshot.requiredResponseHeaders,
|
||||
),
|
||||
digestRequestHeader: registration.digestRequestHeader,
|
||||
digestResponseHeader: registration.digestResponseHeader,
|
||||
receiptResponseHeader: registration.receiptResponseHeader,
|
||||
expectedStatus: registration.expectedStatus,
|
||||
digestRequestHeader: snapshot.digestRequestHeader,
|
||||
digestResponseHeader: snapshot.digestResponseHeader,
|
||||
receiptResponseHeader: snapshot.receiptResponseHeader,
|
||||
expectedStatus: snapshot.expectedStatus,
|
||||
expectedResponseByteLength:
|
||||
registration.expectedResponseByteLength,
|
||||
snapshot.expectedResponseByteLength,
|
||||
mediaType: capability.mediaType,
|
||||
byteLength: capability.byteLength,
|
||||
maxBytes: capability.maxBytes,
|
||||
@@ -403,6 +401,95 @@ export function createSingleUsePresignedReplayGuard():
|
||||
});
|
||||
}
|
||||
|
||||
const DOWNLOAD_BINDING_KEYS = Object.freeze(["kind", "resourceId"]);
|
||||
const UPLOAD_BINDING_KEYS = Object.freeze([
|
||||
"kind",
|
||||
"protocol",
|
||||
"sessionId",
|
||||
"requestBindingSha256",
|
||||
"uploadBindingSha256",
|
||||
"partNumber",
|
||||
"offset",
|
||||
"idempotencyKey",
|
||||
]);
|
||||
|
||||
/**
|
||||
* TR-01. Copies a registration and everything nested inside it into owned,
|
||||
* frozen values, reading each property exactly once. Only this snapshot is
|
||||
* validated and stored, so a stateful issuer cannot show one value to the
|
||||
* forbidden-header and HTTPS checks and hand another to the vault. A hostile
|
||||
* trap, an accessor, an inherited or extra field and a non-iterable header
|
||||
* array all resolve to `null` — a typed `POLICY_REJECTED` — rather than
|
||||
* escaping as a native exception.
|
||||
*/
|
||||
function snapshotRegistration(
|
||||
source: unknown,
|
||||
): PresignedCapabilityRegistration | null {
|
||||
const outer = snapshotExactObject(source, {
|
||||
allowed: REGISTRATION_KEYS,
|
||||
required: REGISTRATION_KEYS,
|
||||
});
|
||||
if (outer === null) return null;
|
||||
|
||||
const binding = snapshotBinding(outer["binding"]);
|
||||
if (binding === null) return null;
|
||||
const allowedQueryParameters = snapshotExactArray(
|
||||
outer["allowedQueryParameters"],
|
||||
);
|
||||
if (allowedQueryParameters === null) return null;
|
||||
const requestHeaders = snapshotHeaderBindings(outer["requestHeaders"]);
|
||||
const requiredResponseHeaders = snapshotHeaderBindings(
|
||||
outer["requiredResponseHeaders"],
|
||||
);
|
||||
if (requestHeaders === null || requiredResponseHeaders === null) return null;
|
||||
|
||||
return Object.freeze({
|
||||
...outer,
|
||||
binding,
|
||||
allowedQueryParameters: Object.freeze([...allowedQueryParameters]),
|
||||
requestHeaders,
|
||||
requiredResponseHeaders,
|
||||
}) as PresignedCapabilityRegistration;
|
||||
}
|
||||
|
||||
function snapshotBinding(source: unknown): PresignedTransferBinding | null {
|
||||
const kind = ownDataValue(source, "kind");
|
||||
if (kind !== "DOWNLOAD" && kind !== "UPLOAD_PART") return null;
|
||||
const keys =
|
||||
kind === "DOWNLOAD" ? DOWNLOAD_BINDING_KEYS : UPLOAD_BINDING_KEYS;
|
||||
const binding = snapshotExactObject(source, {
|
||||
allowed: keys,
|
||||
required: keys,
|
||||
});
|
||||
return binding === null
|
||||
? null
|
||||
: (binding as unknown as PresignedTransferBinding);
|
||||
}
|
||||
|
||||
function snapshotHeaderBindings(
|
||||
source: unknown,
|
||||
): readonly PresignedHeaderBinding[] | null {
|
||||
const rows = snapshotExactArray(source);
|
||||
if (rows === null) return null;
|
||||
const headers: PresignedHeaderBinding[] = [];
|
||||
for (const row of rows) {
|
||||
const header = snapshotExactObject(row, {
|
||||
allowed: ["name", "value"],
|
||||
required: ["name", "value"],
|
||||
});
|
||||
if (
|
||||
header === null ||
|
||||
typeof header["name"] !== "string" ||
|
||||
header["name"].length === 0 ||
|
||||
typeof header["value"] !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
headers.push(header as unknown as PresignedHeaderBinding);
|
||||
}
|
||||
return Object.freeze(headers);
|
||||
}
|
||||
|
||||
function freezeBinding(
|
||||
binding: PresignedTransferBinding,
|
||||
): PresignedTransferBinding {
|
||||
|
||||
Reference in New Issue
Block a user