fix: secure document upload transport #412
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user