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
@@ -158,6 +158,111 @@ function rangeSource(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const fingerprint: UploadFileFingerprint = Object.freeze({
|
||||
algorithm: "SHA-256-PARTS-V1",
|
||||
|
||||
Reference in New Issue
Block a user