fix: decode resumable control-plane responses against hostile objects
TR-RR-08. Object.keys sees only enumerable own string keys, so a symbol or non-enumerable extra field passed the exactness check unseen and the property reads that followed invoked whatever accessor the sender installed — escaping the Result contract as a native rejection out of a public method. Key exactness is now checked against own property descriptors inside a catch, and each decode runs within the adapter's failure boundary so a proxy trap becomes a typed CORRUPT_DATA result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fb5b449031
commit
69cb7e35ca
@@ -104,7 +104,7 @@ export function createResumableUploadHttpControlPlane(
|
|||||||
"UPLOAD_SESSION",
|
"UPLOAD_SESSION",
|
||||||
);
|
);
|
||||||
if (!response.ok) return response;
|
if (!response.ok) return response;
|
||||||
const session = decodeSession(response.value);
|
const session = decodeSafely(decodeSession, response.value);
|
||||||
return session
|
return session
|
||||||
? browserDataSuccess(session)
|
? browserDataSuccess(session)
|
||||||
: browserDataFailure(
|
: browserDataFailure(
|
||||||
@@ -140,7 +140,7 @@ export function createResumableUploadHttpControlPlane(
|
|||||||
"UPLOAD_RECONCILE",
|
"UPLOAD_RECONCILE",
|
||||||
);
|
);
|
||||||
if (!response.ok) return response;
|
if (!response.ok) return response;
|
||||||
const status = decodeStatus(response.value);
|
const status = decodeSafely(decodeStatus, response.value);
|
||||||
return status
|
return status
|
||||||
? browserDataSuccess(status)
|
? browserDataSuccess(status)
|
||||||
: browserDataFailure(
|
: browserDataFailure(
|
||||||
@@ -270,7 +270,7 @@ export function createResumableUploadHttpControlPlane(
|
|||||||
"UPLOAD_COMPLETE",
|
"UPLOAD_COMPLETE",
|
||||||
);
|
);
|
||||||
if (!response.ok) return response;
|
if (!response.ok) return response;
|
||||||
const completed = decodeCompletion(response.value);
|
const completed = decodeSafely(decodeCompletion, response.value);
|
||||||
return completed
|
return completed
|
||||||
? browserDataSuccess(completed)
|
? browserDataSuccess(completed)
|
||||||
: browserDataFailure(
|
: browserDataFailure(
|
||||||
@@ -579,6 +579,15 @@ function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt {
|
|||||||
return Object.freeze({ ...value });
|
return Object.freeze({ ...value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TR-RR-08. `Object.keys` sees only enumerable own string keys, so a symbol or
|
||||||
|
* non-enumerable extra field passed unseen and a later property read invoked
|
||||||
|
* whatever accessor the sender installed — escaping the Result contract as a
|
||||||
|
* rejection of the public method.
|
||||||
|
*
|
||||||
|
* Every key is checked against its own property descriptor, and the whole probe
|
||||||
|
* runs inside a catch so a proxy trap is a decode failure, not an exception.
|
||||||
|
*/
|
||||||
function exactKeys(
|
function exactKeys(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
keys: readonly string[],
|
keys: readonly string[],
|
||||||
@@ -586,12 +595,39 @@ function exactKeys(
|
|||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const actual = Object.keys(value).sort();
|
try {
|
||||||
const expected = [...keys].sort();
|
if (Object.getOwnPropertySymbols(value).length > 0) return false;
|
||||||
return (
|
const actual = Object.getOwnPropertyNames(value).sort();
|
||||||
actual.length === expected.length &&
|
const expected = [...keys].sort();
|
||||||
actual.every((key, index) => key === expected[index])
|
if (
|
||||||
);
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return actual.every((key) => {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||||
|
return Boolean(descriptor && "value" in descriptor);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TR-RR-08. Runs a decoder inside the adapter's failure boundary. A hostile
|
||||||
|
* object that still throws from a trap becomes a typed `CORRUPT_DATA` result
|
||||||
|
* rather than a native rejection out of a public method.
|
||||||
|
*/
|
||||||
|
function decodeSafely<Value>(
|
||||||
|
decode: (value: unknown) => Value | null,
|
||||||
|
value: unknown,
|
||||||
|
): Value | null {
|
||||||
|
try {
|
||||||
|
return decode(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeIdempotencyKey(value: string): boolean {
|
function safeIdempotencyKey(value: string): boolean {
|
||||||
|
|||||||
@@ -158,6 +158,111 @@ function rangeSource(bytes: Uint8Array) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resumable upload HTTP control plane", () => {
|
describe("resumable upload HTTP control plane", () => {
|
||||||
|
/**
|
||||||
|
* TR-RR-08. `Object.keys` sees only enumerable own string keys, so a symbol
|
||||||
|
* or non-enumerable extra passed unseen and a later property read invoked
|
||||||
|
* whatever accessor the sender installed — escaping the Result contract as a
|
||||||
|
* rejection of a public method.
|
||||||
|
*/
|
||||||
|
it("closes every hostile control-plane object as typed CORRUPT_DATA", async () => {
|
||||||
|
const fingerprint: UploadFileFingerprint = Object.freeze({
|
||||||
|
algorithm: "SHA-256-PARTS-V1",
|
||||||
|
digestHex: "a".repeat(64),
|
||||||
|
byteLength: 4,
|
||||||
|
partSizeBytes: 4,
|
||||||
|
partCount: 1,
|
||||||
|
});
|
||||||
|
const validSession = () => ({
|
||||||
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||||
|
sessionId: "session_01",
|
||||||
|
requestBindingSha256: "b".repeat(64),
|
||||||
|
fingerprint,
|
||||||
|
partSizeBytes: 4,
|
||||||
|
partCount: 1,
|
||||||
|
maxConcurrency: 1,
|
||||||
|
expiresAtEpochMs: NOW + 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostile: readonly (readonly [string, () => unknown])[] = [
|
||||||
|
[
|
||||||
|
"throwing getter",
|
||||||
|
() => {
|
||||||
|
const value = validSession() as Record<string, unknown>;
|
||||||
|
Object.defineProperty(value, "sessionId", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => {
|
||||||
|
throw new TypeError("hostile getter");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"symbol key",
|
||||||
|
() => ({ ...validSession(), [Symbol("injected")]: "leak" }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"non-enumerable extra",
|
||||||
|
() => {
|
||||||
|
const value = validSession() as Record<string, unknown>;
|
||||||
|
Object.defineProperty(value, "signedUrl", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false,
|
||||||
|
value: "https://objects.example/secret?signature=leak",
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ownKeys trap",
|
||||||
|
() =>
|
||||||
|
new Proxy(validSession() as Record<string, unknown>, {
|
||||||
|
ownKeys() {
|
||||||
|
throw new TypeError("hostile ownKeys");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"getOwnPropertyDescriptor trap",
|
||||||
|
() =>
|
||||||
|
new Proxy(validSession() as Record<string, unknown>, {
|
||||||
|
getOwnPropertyDescriptor() {
|
||||||
|
throw new TypeError("hostile descriptor");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, build] of hostile) {
|
||||||
|
const control = createResumableUploadHttpControlPlane({
|
||||||
|
transport: {
|
||||||
|
async execute() {
|
||||||
|
return browserDataSuccess(build());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
partCapabilities: { issueUploadPart: vi.fn() },
|
||||||
|
});
|
||||||
|
const result = await control.createSession({
|
||||||
|
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||||
|
uploadKey: "upload_key_strict",
|
||||||
|
purpose: "attachment",
|
||||||
|
mediaType: "application/octet-stream",
|
||||||
|
requestBindingSha256: "b".repeat(64),
|
||||||
|
fingerprint,
|
||||||
|
requestedPartSizeBytes: 4,
|
||||||
|
requestedMaxConcurrency: 1,
|
||||||
|
idempotencyKey: "upload-create-idempotency-01",
|
||||||
|
signal: activeSignal,
|
||||||
|
});
|
||||||
|
expect(result, label).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "CORRUPT_DATA", operation: "UPLOAD_SESSION" },
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result)).not.toContain("signature=leak");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unknown response fields so URLs cannot cross the DTO boundary", async () => {
|
it("rejects unknown response fields so URLs cannot cross the DTO boundary", async () => {
|
||||||
const fingerprint: UploadFileFingerprint = Object.freeze({
|
const fingerprint: UploadFileFingerprint = Object.freeze({
|
||||||
algorithm: "SHA-256-PARTS-V1",
|
algorithm: "SHA-256-PARTS-V1",
|
||||||
|
|||||||
Reference in New Issue
Block a user