From c1244a298cb3dd7c05549f22faff3d10fed1db01 Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 2 Nov 2026 08:24:18 +0100 Subject: [PATCH] fix: bind DOC0 upload receipts to original request --- src/document-workbench/doc0-upload-client.ts | 7 ++++ .../doc0-upload-client.test.ts | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/document-workbench/doc0-upload-client.ts b/src/document-workbench/doc0-upload-client.ts index 05d1673..df5668f 100644 --- a/src/document-workbench/doc0-upload-client.ts +++ b/src/document-workbench/doc0-upload-client.ts @@ -142,11 +142,18 @@ export class Doc0UploadClient { ) { throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required'); } + // Keep the validated values even if the caller later changes its options object. + this.options = { ...options }; this.baseUrl = url.toString().replace(/\/+$/, ''); this.fetchImpl = options.fetchImpl ?? fetch; } async upload(input: Doc0UploadInput): Promise { + if (!(input.bytes instanceof Uint8Array) || input.bytes.byteLength > this.options.maxBytes) { + throw new Error('Invalid DOC0 upload input'); + } + // Bind the outbound request and receipt to the same immutable-by-caller input. + input = { ...input, bytes: new Uint8Array(input.bytes) }; const identity = prepareDoc0UploadIdentity(input); if (identity.sourceSizeBytes > this.options.maxBytes) throw new Error('Invalid DOC0 upload input'); const expectedHash = identity.sourceSha256; diff --git a/tests/document-workbench/doc0-upload-client.test.ts b/tests/document-workbench/doc0-upload-client.test.ts index 36a04ca..d8b8f56 100644 --- a/tests/document-workbench/doc0-upload-client.test.ts +++ b/tests/document-workbench/doc0-upload-client.test.ts @@ -27,6 +27,43 @@ const receipt = { }; describe('DOC0 upload client', () => { + it('validates the receipt against the original scope even if the caller mutates its input during dispatch', async () => { + const mutable = { ...input, bytes: new Uint8Array(input.bytes) }; + const fetchImpl = async () => { + mutable.tenantId = 'tenant-b'; + mutable.projectId = 'project-b'; + return Response.json({ ...receipt, + document: { ...receipt.document, tenantId: 'tenant-b', projectId: 'project-b' }, + currentVersion: { ...receipt.currentVersion, tenantId: 'tenant-b' }, + }, { status: 201 }); + }; + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); + await expect(client.upload(mutable)).rejects.toBeInstanceOf(Doc0UploadOutcomeUnknownError); + }); + it('accepts a matching receipt for the bytes and scope actually dispatched', async () => { + const mutable = { ...input, bytes: new Uint8Array(input.bytes) }; + const fetchImpl = async (_url: string | URL | Request, init?: RequestInit) => { + mutable.tenantId = 'tenant-b'; + mutable.bytes.fill(0); + const sentFile = (init?.body as FormData).get('file') as Blob; + expect(await sentFile.text()).toBe('source bytes'); + return Response.json(receipt, { status: 201 }); + }; + const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); + await expect(client.upload(mutable)).resolves.toMatchObject({ documentId: 'doc-a' }); + }); + it('pins validated client configuration rather than trusting later mutations', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer test'); + return Response.json(receipt, { status: 201 }); + }); + const config = { baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, timeoutMs: 1000, fetchImpl }; + const client = new Doc0UploadClient(config); + config.serviceToken = 'changed'; + config.maxBytes = 1; + config.timeoutMs = 0; + await expect(client.upload(input)).resolves.toMatchObject({ documentId: 'doc-a' }); + }); it('sends real bytes and validates a tenant-scoped checksum-bound receipt', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { expect(new Headers(init?.headers).get('x-tenant-id')).toBe('tenant-a'); -- 2.54.0