Skip to content

Commit 3dd01ee

Browse files
committed
fix: API correctness issues (SSRF protection, docs alignment)
- Add allowUrlFetch option to client options (default: false) - Block automatic URL fetching by default for SSRF protection - Validate URL protocols (only http/https allowed) - Add helper method normalizeFileInput() to client - Update README.md with import statement in Quick Start - Add SSRF protection documentation section to README - Update and add tests for SSRF protection behavior Security: SSRF protection requires explicit opt-in for URL fetching. Users must set allowUrlFetch: true to enable client-side URL fetching.
1 parent 41bf142 commit 3dd01ee

File tree

7 files changed

+213
-63
lines changed

7 files changed

+213
-63
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,29 @@ The documentation for Nutrient DWS TypeScript Client is also available on [Conte
6060
## Quick Start
6161

6262
```typescript
63+
import { NutrientClient } from '@nutrient-sdk/dws-client-typescript';
64+
6365
const client = new NutrientClient({
6466
apiKey: 'nutr_sk_your_secret_key'
6567
});
6668
```
6769

70+
### URL Fetching (SSRF Protection)
71+
72+
By default, the SDK blocks automatic fetching of content from URLs to protect against Server-Side Request Forgery (SSRF) attacks. To enable URL fetching for trusted sources:
73+
74+
```typescript
75+
const client = new NutrientClient({
76+
apiKey: 'nutr_sk_your_secret_key',
77+
allowUrlFetch: true // Enable URL fetching (use with caution)
78+
});
79+
80+
// Now you can pass URLs directly
81+
const result = await client.convert('https://trusted-source.com/document.pdf', 'pdf');
82+
```
83+
84+
**⚠️ Security Warning:** Only enable `allowUrlFetch` if you control the URLs being processed. Never enable it when processing untrusted user input.
85+
6886
## Direct Methods
6987

7088
The client provides numerous methods for document processing:

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/unit/client.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,71 @@ describe('NutrientClient', () => {
239239
}),
240240
).toThrow('Base URL must be a string');
241241
});
242+
243+
it('should accept allowUrlFetch option', () => {
244+
const clientWithUrlFetch = new NutrientClient({
245+
apiKey: 'test-key',
246+
allowUrlFetch: true,
247+
});
248+
expect(clientWithUrlFetch).toBeDefined();
249+
250+
const clientWithoutUrlFetch = new NutrientClient({
251+
apiKey: 'test-key',
252+
allowUrlFetch: false,
253+
});
254+
expect(clientWithoutUrlFetch).toBeDefined();
255+
});
256+
});
257+
258+
describe('SSRF Protection', () => {
259+
let client: NutrientClient;
260+
261+
beforeEach(() => {
262+
jest.clearAllMocks();
263+
});
264+
265+
it('should pass allowUrlFetch=false to processRemoteFileInput by default', async () => {
266+
(inputsModule.isRemoteFileInput as jest.Mock).mockReturnValue(true);
267+
(inputsModule.processRemoteFileInput as jest.Mock).mockRejectedValue(
268+
new ValidationError('SSRF protection: URL fetching disabled'),
269+
);
270+
271+
client = new NutrientClient({
272+
apiKey: 'test-key',
273+
});
274+
275+
await expect(client.sign('https://example.com/test.pdf')).rejects.toThrow(
276+
/SSRF protection/,
277+
);
278+
expect(inputsModule.processRemoteFileInput).toHaveBeenCalledWith(
279+
'https://example.com/test.pdf',
280+
false,
281+
);
282+
});
283+
284+
it('should pass allowUrlFetch=true when configured', async () => {
285+
const mockBuffer = TestDocumentGenerator.generateSimplePdf('Test');
286+
(inputsModule.isRemoteFileInput as jest.Mock).mockReturnValue(true);
287+
(inputsModule.processRemoteFileInput as jest.Mock).mockResolvedValue({
288+
data: mockBuffer,
289+
filename: 'test.pdf',
290+
});
291+
(inputsModule.isValidPdf as jest.Mock).mockResolvedValue(true);
292+
(httpModule.sendRequest as jest.Mock).mockResolvedValue({
293+
data: mockBuffer,
294+
});
295+
296+
client = new NutrientClient({
297+
apiKey: 'test-key',
298+
allowUrlFetch: true,
299+
});
300+
301+
await client.sign('https://example.com/test.pdf');
302+
expect(inputsModule.processRemoteFileInput).toHaveBeenCalledWith(
303+
'https://example.com/test.pdf',
304+
true,
305+
);
306+
});
242307
});
243308

244309
describe('workflow()', () => {

src/__tests__/unit/inputs.test.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -235,14 +235,40 @@ describe('Input Processing (Node.js only)', () => {
235235
(fetch as jest.Mock).mockClear();
236236
});
237237

238-
it('should process URL string input', async () => {
238+
describe('SSRF Protection', () => {
239+
it('should throw ValidationError when allowUrlFetch is false (default)', async () => {
240+
await expect(processRemoteFileInput('https://example.com/test.pdf')).rejects.toThrow(
241+
ValidationError,
242+
);
243+
await expect(
244+
processRemoteFileInput('https://example.com/test.pdf'),
245+
).rejects.toThrow(/SSRF protection/);
246+
});
247+
248+
it('should throw ValidationError when allowUrlFetch is explicitly false', async () => {
249+
await expect(
250+
processRemoteFileInput('https://example.com/test.pdf', false),
251+
).rejects.toThrow(ValidationError);
252+
});
253+
254+
it('should throw ValidationError for non-http/https protocols', async () => {
255+
await expect(
256+
processRemoteFileInput('file:///etc/passwd', true),
257+
).rejects.toThrow(/Invalid URL protocol/);
258+
await expect(
259+
processRemoteFileInput('ftp://example.com/file.pdf', true),
260+
).rejects.toThrow(/Invalid URL protocol/);
261+
});
262+
});
263+
264+
it('should process URL string input when allowUrlFetch is true', async () => {
239265
const mockResponse = {
240266
ok: true,
241267
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
242268
};
243269
(fetch as jest.Mock).mockResolvedValue(mockResponse);
244270

245-
const result = await processRemoteFileInput('https://example.com/test.pdf');
271+
const result = await processRemoteFileInput('https://example.com/test.pdf', true);
246272

247273
expect(fetch).toHaveBeenCalledWith('https://example.com/test.pdf');
248274
expect(mockResponse.arrayBuffer).toHaveBeenCalled();
@@ -253,7 +279,7 @@ describe('Input Processing (Node.js only)', () => {
253279
});
254280
});
255281

256-
it('should process URL object input', async () => {
282+
it('should process URL object input when allowUrlFetch is true', async () => {
257283
const mockResponse = {
258284
ok: true,
259285
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)),
@@ -263,7 +289,7 @@ describe('Input Processing (Node.js only)', () => {
263289
const result = await processRemoteFileInput({
264290
type: 'url',
265291
url: 'https://example.com/test.pdf',
266-
});
292+
}, true);
267293

268294
expect(fetch).toHaveBeenCalledWith('https://example.com/test.pdf');
269295
expect(mockResponse.arrayBuffer).toHaveBeenCalled();
@@ -282,15 +308,15 @@ describe('Input Processing (Node.js only)', () => {
282308
};
283309
(fetch as jest.Mock).mockResolvedValue(mockResponse);
284310

285-
await expect(processRemoteFileInput('https://example.com/test.pdf')).rejects.toThrow(
311+
await expect(processRemoteFileInput('https://example.com/test.pdf', true)).rejects.toThrow(
286312
ValidationError,
287313
);
288314
});
289315

290316
it('should throw ValidationError when fetch fails', async () => {
291317
(fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
292318

293-
await expect(processRemoteFileInput('https://example.com/test.pdf')).rejects.toThrow(
319+
await expect(processRemoteFileInput('https://example.com/test.pdf', true)).rejects.toThrow(
294320
ValidationError,
295321
);
296322
});

0 commit comments

Comments
 (0)