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:
DongHyeonka
2026-08-14 14:17:00 +09:00
co-authored by Claude Opus 5
parent fb5b449031
commit 69cb7e35ca
2 changed files with 150 additions and 9 deletions
@@ -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",