From 70b92e2fea08d9676bf8c5e58289d16674783785 Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 2 Nov 2026 02:09:45 +0100 Subject: [PATCH] fix: secure document upload transport --- src/document-workbench/doc0-upload-client.ts | 15 +++++++++++---- .../document-workbench/doc0-upload-client.test.ts | 12 ++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/document-workbench/doc0-upload-client.ts b/src/document-workbench/doc0-upload-client.ts index 2b3c5cc..f69b438 100644 --- a/src/document-workbench/doc0-upload-client.ts +++ b/src/document-workbench/doc0-upload-client.ts @@ -29,6 +29,7 @@ export interface Doc0UploadReceipt { */ export class Doc0UploadClient { private readonly fetchImpl: typeof fetch; + private readonly baseUrl: string; constructor( private readonly options: { baseUrl: string; @@ -38,14 +39,19 @@ export class Doc0UploadClient { fetchImpl?: typeof fetch; }, ) { + let url: URL; + try { url = new URL(options.baseUrl); } + catch { throw new Error('A trusted HTTPS DOC0 endpoint is required'); } if ( - !options.baseUrl.trim() || + url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !options.serviceToken.trim() || !Number.isSafeInteger(options.maxBytes) || - options.maxBytes <= 0 + options.maxBytes <= 0 || + (options.timeoutMs !== undefined && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 300_000)) ) { - throw new Error('DOC0 endpoint, service credential and upload limit are required'); + throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required'); } + this.baseUrl = url.toString().replace(/\/+$/, ''); this.fetchImpl = options.fetchImpl ?? fetch; } @@ -81,8 +87,9 @@ export class Doc0UploadClient { }), ); body.set('file', new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }), input.filename); - const response = await this.fetchImpl(`${this.options.baseUrl.replace(/\/+$/, '')}/v1/doc0/documents`, { + const response = await this.fetchImpl(`${this.baseUrl}/v1/doc0/documents`, { method: 'POST', + redirect: 'error', headers: { authorization: `Bearer ${this.options.serviceToken}`, 'x-tenant-id': input.tenantId, diff --git a/tests/document-workbench/doc0-upload-client.test.ts b/tests/document-workbench/doc0-upload-client.test.ts index 7e39655..a1230ba 100644 --- a/tests/document-workbench/doc0-upload-client.test.ts +++ b/tests/document-workbench/doc0-upload-client.test.ts @@ -31,6 +31,7 @@ describe('DOC0 upload client', () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { expect(new Headers(init?.headers).get('x-tenant-id')).toBe('tenant-a'); expect(new Headers(init?.headers).get('x-idempotency-key')).toBe('idem-a'); + expect(init?.redirect).toBe('error'); const form = init?.body as FormData; expect(await (form.get('file') as Blob).text()).toBe('source bytes'); return new Response(JSON.stringify(receipt), { status: 201 }); @@ -73,4 +74,15 @@ describe('DOC0 upload client', () => { await expect(client.upload({ ...input, bytes: new Uint8Array() })).rejects.toThrow(/Invalid/); expect(fetchImpl).not.toHaveBeenCalled(); }); + it('rejects insecure endpoints, embedded credentials and invalid timeouts', () => { + const base = { serviceToken: 'test', maxBytes: 100 }; + for (const baseUrl of ['http://doc0.test', 'https://doc0.test?token=x', 'file:///tmp/doc0', 'not a URL']) { + expect(() => new Doc0UploadClient({ ...base, baseUrl })).toThrow(/HTTPS/); + } + const credentialUrl = new URL('https://doc0.test'); + credentialUrl.username = 'fixture'; + credentialUrl.password = 'fixture'; + expect(() => new Doc0UploadClient({ ...base, baseUrl: credentialUrl.toString() })).toThrow(/HTTPS/); + expect(() => new Doc0UploadClient({ ...base, baseUrl: 'https://doc0.test', timeoutMs: 0 })).toThrow(/limits/); + }); }); -- 2.54.0