Skip to content

Commit 23457d3

Browse files
committed
Update CHANGELOG
1 parent 482dd89 commit 23457d3

4 files changed

Lines changed: 79 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
### Pending Fixed
1414

15+
### v1.7.0 - 2026-07-09
16+
17+
- :tada: Add a default 20s request `timeout` to `fetch`, applied across all redirect hops and combinable with a caller-supplied `signal`
18+
1519
### v1.6.0 - 2026-07-04
1620

1721
- :rocket: Add Type Coercion to TypedResponse to allow for more flexible response handling

lib/fetch.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { isSafeUrl } from './safeurl.js';
88

99
const cache = new WeakMap<TSchema, ReturnType<typeof TypeCompiler.Compile>>();
1010

11+
/** Default request timeout (ms) applied when the caller does not override it. */
12+
export const DEFAULT_TIMEOUT_MS = 20_000;
13+
1114
export interface FetchInit extends RequestInit {
1215
/** Set to false to disable SSRF-safe URL validation. Defaults to true. */
1316
safeUrl?: boolean;
@@ -19,6 +22,14 @@ export interface FetchInit extends RequestInit {
1922
* endpoints such as the CloudTAK API when it runs inside a VPC.
2023
*/
2124
safeUrlAllow?: string[];
25+
/**
26+
* Abort the request after this many milliseconds. The timeout covers the
27+
* whole operation, including any redirect hops. Defaults to
28+
* {@link DEFAULT_TIMEOUT_MS} (20s). Set to `0` (or a negative value) to
29+
* disable the timeout. When the caller also supplies a `signal`, both it
30+
* and the timeout are honoured — whichever aborts first wins.
31+
*/
32+
timeout?: number;
2233
}
2334

2435
export class TypedResponse extends Response {
@@ -81,7 +92,18 @@ export default async function (
8192
input: RequestInfo,
8293
init?: FetchInit,
8394
): Promise<TypedResponse> {
84-
const { safeUrl = true, safeUrlAllow: allow, ...fetchInit } = init ?? {};
95+
const { safeUrl = true, safeUrlAllow: allow, timeout = DEFAULT_TIMEOUT_MS, ...fetchInit } = init ?? {};
96+
97+
// Attach a timeout AbortSignal covering the whole operation (all redirect
98+
// hops share the same deadline). If the caller passed their own signal,
99+
// combine the two so either can abort the request.
100+
if (timeout > 0) {
101+
const timeoutSignal = AbortSignal.timeout(timeout);
102+
const callerSignal = fetchInit.signal ?? undefined;
103+
fetchInit.signal = callerSignal
104+
? AbortSignal.any([callerSignal, timeoutSignal])
105+
: timeoutSignal;
106+
}
85107

86108
if (safeUrl) {
87109
// Reject custom dispatchers: they can route requests to arbitrary internal

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@tak-ps/node-safeurl",
33
"type": "module",
4-
"version": "1.6.0",
4+
"version": "1.7.0",
55
"description": "SSRF-safe URL validation library for Node.js",
66
"author": "Nick Ingalls <nick@ingalls.ca>",
77
"types": "./dist/index.d.ts",

test/fetch.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,57 @@ test('safeFetch — allows custom dispatcher when safeUrl is disabled', async ()
7777
await agent.close();
7878
});
7979

80+
test('safeFetch — aborts a slow request after the timeout', async () => {
81+
const { MockAgent } = await import('undici');
82+
const agent = new MockAgent();
83+
agent.disableNetConnect();
84+
const client = agent.get('http://example.com');
85+
client.intercept({ path: '/', method: 'GET' }).reply(200, '{}').delay(1000);
86+
87+
await assert.rejects(
88+
() => safeFetch('http://example.com/', { safeUrl: false, dispatcher: agent, timeout: 50 }),
89+
(err: Error) => {
90+
assert.strictEqual(err.name, 'TimeoutError');
91+
return true;
92+
},
93+
);
94+
await agent.close();
95+
});
96+
97+
test('safeFetch — timeout: 0 disables the timeout', async () => {
98+
const { MockAgent } = await import('undici');
99+
const agent = new MockAgent();
100+
agent.disableNetConnect();
101+
const client = agent.get('http://example.com');
102+
client.intercept({ path: '/', method: 'GET' }).reply(200, '{}').delay(50);
103+
104+
const res = await safeFetch('http://example.com/', { safeUrl: false, dispatcher: agent, timeout: 0 });
105+
assert.strictEqual(res.status, 200);
106+
await agent.close();
107+
});
108+
109+
test('safeFetch — caller signal still aborts alongside the timeout', async () => {
110+
const { MockAgent } = await import('undici');
111+
const agent = new MockAgent();
112+
agent.disableNetConnect();
113+
const client = agent.get('http://example.com');
114+
client.intercept({ path: '/', method: 'GET' }).reply(200, '{}').delay(1000);
115+
116+
const controller = new AbortController();
117+
const p = safeFetch('http://example.com/', {
118+
safeUrl: false,
119+
dispatcher: agent,
120+
signal: controller.signal,
121+
});
122+
controller.abort();
123+
124+
await assert.rejects(p, (err: Error) => {
125+
assert.strictEqual(err.name, 'AbortError');
126+
return true;
127+
});
128+
await agent.close();
129+
});
130+
80131
test('TypedResponse — preserves wrapped response url', () => {
81132
const raw = new Response('{}', { status: 200 });
82133
const typed = new TypedResponse(raw);

0 commit comments

Comments
 (0)