Skip to content

Commit 2ffec73

Browse files
committed
refactor: do not depend on undici being part of the bundle
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
1 parent cc8cfcc commit 2ffec73

6 files changed

Lines changed: 431 additions & 53 deletions

File tree

lib/helpers/fetch_request.js

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable no-bitwise, no-plusplus */
2-
import * as undici from 'undici';
3-
2+
import * as attention from './attention.js';
43
import instance from './weak_cache.js';
54

65
// IANA IPv4 Special-Purpose Address Space
@@ -178,6 +177,20 @@ function isSpecialUseIP(address) {
178177
return isSpecialUseIPv6(address);
179178
}
180179

180+
// Returns the Agent constructor from the global dispatcher symbols that
181+
// node's built-in undici sets.
182+
function getAgent() {
183+
// Referencing Response triggers node's lazy undici initialization
184+
// that sets the global dispatcher symbols
185+
// eslint-disable-next-line no-unused-expressions
186+
Response;
187+
return (
188+
globalThis[Symbol.for('undici.globalDispatcher.2')]
189+
?? globalThis[Symbol.for('undici.globalDispatcher.1')]
190+
)?.constructor;
191+
}
192+
193+
// undefined = not yet attempted, null = unavailable, Agent instance = use
181194
let dispatcher;
182195

183196
export default async function fetchRequest(provider, url, options) {
@@ -190,23 +203,29 @@ export default async function fetchRequest(provider, url, options) {
190203
// resolving DNS upfront via dns/promises. An upfront lookup is vulnerable to
191204
// TOCTOU — the HTTP client resolves the hostname again independently, and the
192205
// result can differ (DNS rebinding, round-robin, short TTL). Checking
193-
// socket.remoteAddress in the connector inspects the actual IP the socket is
194-
// bound to, which is the only reliable enforcement point.
195-
dispatcher ??= new undici.Agent({
196-
connect(opts, cb) {
197-
undici.buildConnector({})(opts, (err, socket) => {
198-
if (err) {
199-
cb(err);
200-
} else if (isSpecialUseIP(socket.remoteAddress)) {
201-
socket.destroy();
202-
cb(new Error('hostname resolves to a special-use IP address'));
203-
} else {
204-
cb(null, socket);
206+
// socket.remoteAddress in the connect event inspects the actual IP the socket
207+
// is bound to, which is the only reliable enforcement point.
208+
if (dispatcher === undefined) {
209+
try {
210+
dispatcher = new (getAgent())();
211+
let kSocket;
212+
dispatcher.on('connect', (_origin, targets) => {
213+
// targets = [Agent, Pool, Client] — the socket lives on the Client
214+
const client = targets[2];
215+
kSocket ??= Object.getOwnPropertySymbols(client).find((s) => s.description === 'socket');
216+
const socket = client[kSocket];
217+
if (socket?.remoteAddress !== undefined && isSpecialUseIP(socket.remoteAddress)) {
218+
socket.destroy(new Error('hostname resolves to a special-use IP address'));
205219
}
206220
});
207-
},
208-
});
209-
options.dispatcher = dispatcher;
221+
} catch {
222+
attention.warn('failed to setup SSRF protection for fetch, outgoing requests will not be checked for special-use IP addresses');
223+
dispatcher = null;
224+
}
225+
}
226+
if (dispatcher) {
227+
options.dispatcher = dispatcher;
228+
}
210229
/* eslint-enable no-param-reassign */
211230

212231
return instance(provider).configuration.fetch(url, options);

package-lock.json

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

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@
6464
"koa": "^3.1.2",
6565
"nanoid": "^5.1.7",
6666
"quick-lru": "^7.3.0",
67-
"raw-body": "^3.0.2",
68-
"undici": "^7.24.4"
67+
"raw-body": "^3.0.2"
6968
},
7069
"devDependencies": {
7170
"@fastify/middie": "^9.3.1",
@@ -90,6 +89,7 @@
9089
"selfsigned": "^5.5.0",
9190
"sinon": "^21.0.3",
9291
"supertest": "^7.2.2",
93-
"timekeeper": "^2.3.1"
92+
"timekeeper": "^2.3.1",
93+
"undici": "^8.0.2"
9494
}
9595
}

test/backchannel_logout/backchannel_logout.test.js

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import bootstrap, { skipConsent, assertNoPendingInterceptors, mock } from '../te
1010

1111
const sinon = createSandbox();
1212

13+
async function consumeBody(body) {
14+
if (typeof body === 'string') return body;
15+
const chunks = [];
16+
for await (const chunk of body) chunks.push(chunk);
17+
return Buffer.concat(chunks).toString();
18+
}
19+
1320
describe('Back-Channel Logout 1.0', () => {
1421
before(bootstrap(import.meta.url));
1522

@@ -24,20 +31,20 @@ describe('Back-Channel Logout 1.0', () => {
2431
.intercept({
2532
path: '/backchannel_logout',
2633
method: 'POST',
27-
body(value) {
28-
expect(value).to.match(/^logout_token=(([\w-]+\.?){3})$/);
29-
const header = JSON.parse(base64url.decode(RegExp.$1.split('.')[0]));
30-
expect(header).to.have.property('typ', 'logout+jwt');
31-
const decoded = JSON.parse(base64url.decode(RegExp.$1.split('.')[1]));
32-
expect(decoded).to.have.all.keys('sub', 'events', 'iat', 'exp', 'aud', 'iss', 'jti', 'sid');
33-
expect(decoded).to.have.property('events').and.eql({ 'http://schemas.openid.net/event/backchannel-logout': {} });
34-
expect(decoded).to.have.property('aud', 'client');
35-
expect(decoded).to.have.property('sub', 'subject');
36-
expect(decoded).to.have.property('sid', 'foo');
37-
return true;
38-
},
3934
})
40-
.reply(200);
35+
.reply(200, async (opts) => {
36+
const value = await consumeBody(opts.body);
37+
expect(value).to.match(/^logout_token=(([\w-]+\.?){3})$/);
38+
const header = JSON.parse(base64url.decode(RegExp.$1.split('.')[0]));
39+
expect(header).to.have.property('typ', 'logout+jwt');
40+
const decoded = JSON.parse(base64url.decode(RegExp.$1.split('.')[1]));
41+
expect(decoded).to.have.all.keys('sub', 'events', 'iat', 'exp', 'aud', 'iss', 'jti', 'sid');
42+
expect(decoded).to.have.property('events').and.eql({ 'http://schemas.openid.net/event/backchannel-logout': {} });
43+
expect(decoded).to.have.property('aud', 'client');
44+
expect(decoded).to.have.property('sub', 'subject');
45+
expect(decoded).to.have.property('sid', 'foo');
46+
return '';
47+
});
4148

4249
return client.backchannelLogout('subject', 'foo');
4350
});
@@ -49,18 +56,18 @@ describe('Back-Channel Logout 1.0', () => {
4956
.intercept({
5057
path: '/backchannel_logout',
5158
method: 'POST',
52-
body(value) {
53-
expect(value).to.match(/^logout_token=(([\w-]+\.?){3})$/);
54-
const decoded = JSON.parse(base64url.decode(RegExp.$1.split('.')[1]));
55-
expect(decoded).to.have.all.keys('sub', 'events', 'iat', 'exp', 'aud', 'iss', 'jti');
56-
expect(decoded).to.have.property('events').and.eql({ 'http://schemas.openid.net/event/backchannel-logout': {} });
57-
expect(decoded).to.have.property('aud', 'no-sid');
58-
expect(decoded).to.have.property('sub', 'subject');
59-
expect(decoded).not.to.have.property('sid');
60-
return true;
61-
},
6259
})
63-
.reply(200);
60+
.reply(200, async (opts) => {
61+
const value = await consumeBody(opts.body);
62+
expect(value).to.match(/^logout_token=(([\w-]+\.?){3})$/);
63+
const decoded = JSON.parse(base64url.decode(RegExp.$1.split('.')[1]));
64+
expect(decoded).to.have.all.keys('sub', 'events', 'iat', 'exp', 'aud', 'iss', 'jti');
65+
expect(decoded).to.have.property('events').and.eql({ 'http://schemas.openid.net/event/backchannel-logout': {} });
66+
expect(decoded).to.have.property('aud', 'no-sid');
67+
expect(decoded).to.have.property('sub', 'subject');
68+
expect(decoded).not.to.have.property('sid');
69+
return '';
70+
});
6471

6572
return client.backchannelLogout('subject', 'foo');
6673
});

0 commit comments

Comments
 (0)