diff --git a/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts b/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts index ced99e3..99cb96e 100644 --- a/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts +++ b/src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts @@ -104,7 +104,7 @@ export function createResumableUploadHttpControlPlane( "UPLOAD_SESSION", ); if (!response.ok) return response; - const session = decodeSession(response.value); + const session = decodeSafely(decodeSession, response.value); return session ? browserDataSuccess(session) : browserDataFailure( @@ -140,7 +140,7 @@ export function createResumableUploadHttpControlPlane( "UPLOAD_RECONCILE", ); if (!response.ok) return response; - const status = decodeStatus(response.value); + const status = decodeSafely(decodeStatus, response.value); return status ? browserDataSuccess(status) : browserDataFailure( @@ -270,7 +270,7 @@ export function createResumableUploadHttpControlPlane( "UPLOAD_COMPLETE", ); if (!response.ok) return response; - const completed = decodeCompletion(response.value); + const completed = decodeSafely(decodeCompletion, response.value); return completed ? browserDataSuccess(completed) : browserDataFailure( @@ -579,6 +579,15 @@ function snapshotReceipt(value: UploadPartReceipt): UploadPartReceipt { 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( value: unknown, keys: readonly string[], @@ -586,12 +595,39 @@ function exactKeys( if (!value || typeof value !== "object" || Array.isArray(value)) { return false; } - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return ( - actual.length === expected.length && - actual.every((key, index) => key === expected[index]) - ); + try { + if (Object.getOwnPropertySymbols(value).length > 0) return false; + const actual = Object.getOwnPropertyNames(value).sort(); + const expected = [...keys].sort(); + 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( + decode: (value: unknown) => Value | null, + value: unknown, +): Value | null { + try { + return decode(value); + } catch { + return null; + } } function safeIdempotencyKey(value: string): boolean { diff --git a/tests/unit/resumable-upload-http-control-plane.test.ts b/tests/unit/resumable-upload-http-control-plane.test.ts index e34709f..7647f5b 100644 --- a/tests/unit/resumable-upload-http-control-plane.test.ts +++ b/tests/unit/resumable-upload-http-control-plane.test.ts @@ -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; + 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; + Object.defineProperty(value, "signedUrl", { + configurable: true, + enumerable: false, + value: "https://objects.example/secret?signature=leak", + }); + return value; + }, + ], + [ + "ownKeys trap", + () => + new Proxy(validSession() as Record, { + ownKeys() { + throw new TypeError("hostile ownKeys"); + }, + }), + ], + [ + "getOwnPropertyDescriptor trap", + () => + new Proxy(validSession() as Record, { + 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",