@abyss-project/main 16.0.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/api/organization.api.d.ts +4 -2
  2. package/dist/api/organization.api.js +9 -1
  3. package/dist/api/organization.api.js.map +1 -1
  4. package/dist/api/project.api.d.ts +2 -1
  5. package/dist/api/project.api.js +5 -1
  6. package/dist/api/project.api.js.map +1 -1
  7. package/dist/api/user.api.d.ts +2 -1
  8. package/dist/api/user.api.js +5 -1
  9. package/dist/api/user.api.js.map +1 -1
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.js +4 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/server/safe-url.util.d.ts +58 -10
  14. package/dist/server/safe-url.util.js +274 -47
  15. package/dist/server/safe-url.util.js.map +1 -1
  16. package/dist/server/tests/safe-url.util.spec.d.ts +1 -0
  17. package/dist/server/tests/safe-url.util.spec.js +848 -0
  18. package/dist/server/tests/safe-url.util.spec.js.map +1 -0
  19. package/dist/types/enum/company-verification-status.enum.d.ts +7 -0
  20. package/dist/types/enum/company-verification-status.enum.js +12 -0
  21. package/dist/types/enum/company-verification-status.enum.js.map +1 -0
  22. package/dist/types/enum/index.d.ts +2 -0
  23. package/dist/types/enum/index.js +2 -0
  24. package/dist/types/enum/index.js.map +1 -1
  25. package/dist/types/enum/onboarding-checklist.enum.d.ts +24 -0
  26. package/dist/types/enum/onboarding-checklist.enum.js +32 -0
  27. package/dist/types/enum/onboarding-checklist.enum.js.map +1 -0
  28. package/dist/types/enum/onboarding-steps.enum.d.ts +0 -14
  29. package/dist/types/enum/onboarding-steps.enum.js +0 -14
  30. package/dist/types/enum/onboarding-steps.enum.js.map +1 -1
  31. package/dist/types/interface/api/requests/organization.request.d.ts +6 -0
  32. package/dist/types/interface/api/requests/project.request.d.ts +3 -0
  33. package/dist/types/interface/api/requests/user.request.d.ts +4 -2
  34. package/dist/types/interface/api/responses/index.d.ts +1 -0
  35. package/dist/types/interface/api/responses/index.js +1 -0
  36. package/dist/types/interface/api/responses/index.js.map +1 -1
  37. package/dist/types/interface/api/responses/onboarding-checklist.response.d.ts +6 -0
  38. package/dist/types/interface/api/responses/onboarding-checklist.response.js +3 -0
  39. package/dist/types/interface/api/responses/onboarding-checklist.response.js.map +1 -0
  40. package/dist/types/interface/api/responses/organization.response.d.ts +10 -0
  41. package/dist/types/interface/api/responses/project.response.d.ts +7 -1
  42. package/dist/types/interface/api/responses/user.response.d.ts +7 -1
  43. package/dist/types/interface/models/organization.model.d.ts +10 -1
  44. package/dist/types/interface/models/user.model.d.ts +8 -1
  45. package/dist/types/utils/onboarding.utils.d.ts +5 -12
  46. package/dist/types/utils/onboarding.utils.js +13 -43
  47. package/dist/types/utils/onboarding.utils.js.map +1 -1
  48. package/dist/types/utils/tests/onboarding.utils.spec.d.ts +1 -0
  49. package/dist/types/utils/tests/onboarding.utils.spec.js +54 -0
  50. package/dist/types/utils/tests/onboarding.utils.spec.js.map +1 -0
  51. package/package.json +1 -1
@@ -0,0 +1,848 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const axios_1 = __importStar(require("axios"));
37
+ const http = __importStar(require("http"));
38
+ const safe_url_util_1 = require("../safe-url.util");
39
+ const test_env_util_1 = require("./utils/test-env.util");
40
+ const FORM_SIGNATURE_HEADER = 'abyss-form-webhook-signature';
41
+ const MONITOR_SIGNATURE_HEADER = 'abyss-monitor-webhook-signature';
42
+ const resolverReturning = (...addresses) => jest.fn(async () => addresses.map((address) => ({ address })));
43
+ const hangingResolver = () => jest.fn(() => new Promise(() => undefined));
44
+ const redirectError = (location, status = 302) => new axios_1.AxiosError('redirect', 'ERR_BAD_RESPONSE', undefined, undefined, {
45
+ status,
46
+ headers: { location },
47
+ });
48
+ const okResponse = (data = 'ok') => ({ status: 200, data });
49
+ const captureError = async (pending) => {
50
+ try {
51
+ await pending;
52
+ }
53
+ catch (error) {
54
+ return error;
55
+ }
56
+ throw new Error('the call was expected to fail');
57
+ };
58
+ const lookupThrough = (lookup, hostname) => new Promise((resolve, reject) => lookup(hostname, { all: true }, (error, addresses) => error ? reject(error) : resolve(addresses)));
59
+ describe('safe-url.util', () => {
60
+ beforeEach(() => {
61
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, undefined);
62
+ (0, test_env_util_1.setTestEnv)('NODE_ENV', 'test');
63
+ });
64
+ afterEach(() => {
65
+ jest.restoreAllMocks();
66
+ });
67
+ describe('isBlockedIp', () => {
68
+ it.each([
69
+ ['0.0.0.0/8', '0.1.2.3'],
70
+ ['10.0.0.0/8', '10.20.30.40'],
71
+ ['100.64.0.0/10', '100.127.255.254'],
72
+ ['127.0.0.0/8', '127.5.5.5'],
73
+ ['169.254.0.0/16 (cloud metadata)', '169.254.169.254'],
74
+ ['172.16.0.0/12', '172.31.255.255'],
75
+ ['192.0.0.0/24', '192.0.0.8'],
76
+ ['192.168.0.0/16', '192.168.1.1'],
77
+ ['198.18.0.0/15', '198.19.0.1'],
78
+ ['224.0.0.0/4 (multicast)', '239.255.255.250'],
79
+ ['240.0.0.0/4 (reserved, broadcast included)', '255.255.255.255'],
80
+ ['192.0.2.0/24 (TEST-NET-1)', '192.0.2.1'],
81
+ ['198.51.100.0/24 (TEST-NET-2)', '198.51.100.1'],
82
+ ['203.0.113.0/24 (TEST-NET-3)', '203.0.113.1'],
83
+ ])('blocks IPv4 %s (%s)', (_family, ip) => {
84
+ expect((0, safe_url_util_1.isBlockedIp)(ip)).toBe(true);
85
+ });
86
+ it.each([
87
+ ['unspecified', '::'],
88
+ ['loopback', '::1'],
89
+ ['IPv4-mapped loopback', '::ffff:127.0.0.1'],
90
+ ['IPv4-mapped metadata', '::ffff:169.254.169.254'],
91
+ ['IPv4-compatible loopback', '::127.0.0.1'],
92
+ ['link-local fe80::/10', 'fe80::'],
93
+ ['unique-local fc00::/7 (fc)', 'fc00::'],
94
+ ['unique-local fc00::/7 (fd)', 'fd00::'],
95
+ ['multicast ff00::/8', 'ff02::'],
96
+ ['NAT64 of loopback', '64:ff9b::127.0.0.1'],
97
+ ['NAT64 of metadata', '64:ff9b::a9fe:a9fe'],
98
+ ['NAT64 local-use 64:ff9b:1::/48', '64:ff9b:1::8.8.8.8'],
99
+ ['6to4 of a private network', '2002:a00:1::'],
100
+ ['6to4 of metadata', '2002:a9fe:a9fe::1'],
101
+ ])('blocks IPv6 %s (%s)', (_family, ip) => {
102
+ expect((0, safe_url_util_1.isBlockedIp)(ip)).toBe(true);
103
+ });
104
+ it.each([
105
+ '8.8.8.8',
106
+ '93.184.216.34',
107
+ '100.128.0.1',
108
+ '172.32.0.1',
109
+ '192.0.1.1',
110
+ '198.20.0.1',
111
+ '223.255.255.255',
112
+ '2606:4700:4700::1111',
113
+ '::ffff:8.8.8.8',
114
+ '64:ff9b::8.8.8.8',
115
+ '2002:808:808::1',
116
+ ])('accepts the public address %s', (ip) => {
117
+ expect((0, safe_url_util_1.isBlockedIp)(ip)).toBe(false);
118
+ });
119
+ it.each(['not-an-ip', '', '999.1.1.1'])('treats %p as blocked', (value) => {
120
+ expect((0, safe_url_util_1.isBlockedIp)(value)).toBe(true);
121
+ });
122
+ });
123
+ describe('assertSafeHost', () => {
124
+ it('returns every address of a host that resolves only to public addresses', async () => {
125
+ await expect((0, safe_url_util_1.assertSafeHost)('monitored.example', resolverReturning('93.184.216.34', '2606:4700:4700::1111'))).resolves.toEqual([
126
+ { address: '93.184.216.34', family: 4 },
127
+ { address: '2606:4700:4700::1111', family: 6 },
128
+ ]);
129
+ });
130
+ it('returns a public literal IP without resolving', async () => {
131
+ const resolver = resolverReturning('10.0.0.1');
132
+ await expect((0, safe_url_util_1.assertSafeHost)('[2606:4700:4700::1111]', resolver)).resolves.toEqual([
133
+ { address: '2606:4700:4700::1111', family: 6 },
134
+ ]);
135
+ expect(resolver).not.toHaveBeenCalled();
136
+ });
137
+ it('refuses a host when ONE of its resolved addresses is private', async () => {
138
+ const error = await captureError((0, safe_url_util_1.assertSafeHost)('rebind.example', resolverReturning('93.184.216.34', '10.0.0.5')));
139
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
140
+ });
141
+ it('refuses a host that resolves to no address at all', async () => {
142
+ const error = await captureError((0, safe_url_util_1.assertSafeHost)('empty.example', resolverReturning()));
143
+ expect(error).toBeInstanceOf(safe_url_util_1.HostResolutionError);
144
+ expect(error.resolverCode).toBeNull();
145
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.UNRESOLVABLE_HOST);
146
+ });
147
+ it('refuses an empty bracketed host as an invalid URL', async () => {
148
+ const error = await captureError((0, safe_url_util_1.assertSafeHost)('[]'));
149
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.INVALID_URL);
150
+ });
151
+ });
152
+ describe('assertSafeRequestUrl', () => {
153
+ it('accepts a public hostname and returns its validated addresses', async () => {
154
+ const target = await (0, safe_url_util_1.assertSafeRequestUrl)('https://cdn.example.com/avatar.png', resolverReturning('93.184.216.34'));
155
+ expect(target.url.hostname).toBe('cdn.example.com');
156
+ expect(target.addresses).toEqual([{ address: '93.184.216.34', family: 4 }]);
157
+ });
158
+ it('accepts a raw public IP host without resolving', async () => {
159
+ const resolver = resolverReturning();
160
+ const target = await (0, safe_url_util_1.assertSafeRequestUrl)('https://8.8.8.8/x', resolver);
161
+ expect(target.addresses).toEqual([{ address: '8.8.8.8', family: 4 }]);
162
+ expect(resolver).not.toHaveBeenCalled();
163
+ });
164
+ it.each(['ftp://example.com/x', 'file:///etc/passwd', 'gopher://127.0.0.1:11211/_stats'])('refuses the scheme of %s as unsupported_scheme', async (url) => {
165
+ const resolver = resolverReturning('8.8.8.8');
166
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)(url, resolver));
167
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.UNSUPPORTED_SCHEME);
168
+ expect(resolver).not.toHaveBeenCalled();
169
+ });
170
+ it('refuses an unparseable URL as invalid_url', async () => {
171
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)('not a url'));
172
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.INVALID_URL);
173
+ });
174
+ it.each(['http://127.0.0.1/x', 'http://169.254.169.254/latest/meta-data/', 'http://[::1]/x'])('refuses the raw private host of %s without resolving', async (url) => {
175
+ const resolver = resolverReturning('8.8.8.8');
176
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)(url, resolver));
177
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
178
+ expect(resolver).not.toHaveBeenCalled();
179
+ });
180
+ it('refuses a hostname when ONE of its resolved addresses is private (private_address)', async () => {
181
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)('https://evil.example.com/x', resolverReturning('8.8.8.8', '169.254.169.254')));
182
+ expect(error).toBeInstanceOf(safe_url_util_1.UnsafeDestinationError);
183
+ expect(error).not.toBeInstanceOf(safe_url_util_1.HostResolutionError);
184
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
185
+ expect(error.hostname).toBe('evil.example.com');
186
+ });
187
+ it.each(['EAI_AGAIN', 'ENOTFOUND', 'ESERVFAIL'])('turns a DNS failure (%s) into a HostResolutionError carrying the resolver code', async (code) => {
188
+ const resolver = jest.fn(async () => {
189
+ throw Object.assign(new Error(`getaddrinfo ${code} nope.example.com`), { code });
190
+ });
191
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)('https://nope.example.com/x', resolver));
192
+ expect(error).toBeInstanceOf(safe_url_util_1.HostResolutionError);
193
+ expect(error.resolverCode).toBe(code);
194
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.UNRESOLVABLE_HOST);
195
+ expect(error.message).toBe(`Could not resolve host nope.example.com (${code})`);
196
+ });
197
+ it('keeps a null resolverCode when the resolver gives none', async () => {
198
+ const resolver = jest.fn(async () => {
199
+ throw new Error('resolution failed');
200
+ });
201
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)('https://nope.example.com/x', resolver));
202
+ expect(error.resolverCode).toBeNull();
203
+ expect(error.message).toBe('Could not resolve host nope.example.com');
204
+ });
205
+ it('refuses a hostname that resolves to an empty list with a HostResolutionError', async () => {
206
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)('https://empty.example.com/x', resolverReturning()));
207
+ expect(error).toBeInstanceOf(safe_url_util_1.HostResolutionError);
208
+ });
209
+ it('never puts the path or the query of the URL in its messages', async () => {
210
+ const messages = await Promise.all([
211
+ captureError((0, safe_url_util_1.assertSafeRequestUrl)('https://evil.example.com/hook/s3cr3t?token=s3cr3t', resolverReturning('10.0.0.1'))),
212
+ captureError((0, safe_url_util_1.assertSafeRequestUrl)('ftp://example.com/s3cr3t?token=s3cr3t')),
213
+ captureError((0, safe_url_util_1.assertSafeRequestUrl)('https://nope.example.com/s3cr3t?token=s3cr3t', async () => {
214
+ throw new Error('down');
215
+ })),
216
+ ]);
217
+ for (const error of messages) {
218
+ expect(error.message).not.toContain('s3cr3t');
219
+ }
220
+ });
221
+ });
222
+ describe('development exception', () => {
223
+ it('validates nothing and returns addresses: null when set to true outside production', async () => {
224
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
225
+ (0, test_env_util_1.setTestEnv)('NODE_ENV', 'development');
226
+ const target = await (0, safe_url_util_1.assertSafeRequestUrl)('http://127.0.0.1:3000/hook');
227
+ expect(target.addresses).toBeNull();
228
+ });
229
+ it('is ignored when NODE_ENV is PRODUCTION', async () => {
230
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
231
+ (0, test_env_util_1.setTestEnv)('NODE_ENV', 'PRODUCTION');
232
+ const error = await captureError((0, safe_url_util_1.assertSafeRequestUrl)('http://127.0.0.1:3000/hook'));
233
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
234
+ });
235
+ it('is ignored when NODE_ENV is production', async () => {
236
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
237
+ (0, test_env_util_1.setTestEnv)('NODE_ENV', 'production');
238
+ const error = await captureError((0, safe_url_util_1.assertSafeHost)('127.0.0.1'));
239
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
240
+ });
241
+ it("is ignored when its value is not exactly 'true'", async () => {
242
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, '1');
243
+ const error = await captureError((0, safe_url_util_1.assertSafeHost)('127.0.0.1'));
244
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
245
+ });
246
+ it('sets no pin: pinnedLookupConfig is empty for an unvalidated target', () => {
247
+ expect((0, safe_url_util_1.pinnedLookupConfig)({ url: new URL('http://127.0.0.1/'), addresses: null })).toEqual({});
248
+ });
249
+ });
250
+ describe('assertOutboundGuardEnvironment', () => {
251
+ it.each(['production', 'PRODUCTION'])('throws when the exception is set and NODE_ENV is %s', (nodeEnv) => {
252
+ expect(() => (0, safe_url_util_1.assertOutboundGuardEnvironment)({
253
+ NODE_ENV: nodeEnv,
254
+ [safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV]: 'true',
255
+ })).toThrow(`${safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV}=true`);
256
+ });
257
+ it.each([
258
+ ['production without the exception', { NODE_ENV: 'production' }],
259
+ [
260
+ 'development with the exception',
261
+ { NODE_ENV: 'development', [safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV]: 'true' },
262
+ ],
263
+ [
264
+ "production with a value other than 'true'",
265
+ { NODE_ENV: 'production', [safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV]: 'false' },
266
+ ],
267
+ ])('does not throw for %s', (_case, env) => {
268
+ expect(() => (0, safe_url_util_1.assertOutboundGuardEnvironment)(env)).not.toThrow();
269
+ });
270
+ it('reads process.env by default', () => {
271
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
272
+ (0, test_env_util_1.setTestEnv)('NODE_ENV', 'production');
273
+ expect(() => (0, safe_url_util_1.assertOutboundGuardEnvironment)()).toThrow();
274
+ });
275
+ });
276
+ describe('isUnsafeDestinationError', () => {
277
+ it('is true for an UnsafeDestinationError', () => {
278
+ expect((0, safe_url_util_1.isUnsafeDestinationError)(new safe_url_util_1.UnsafeDestinationError(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS, 'h', 'refused'))).toBe(true);
279
+ });
280
+ it('is true for a HostResolutionError', () => {
281
+ expect((0, safe_url_util_1.isUnsafeDestinationError)(new safe_url_util_1.HostResolutionError('h', 'ENOTFOUND'))).toBe(true);
282
+ });
283
+ it('is false for a plain Error', () => {
284
+ expect((0, safe_url_util_1.isUnsafeDestinationError)(new Error('boom'))).toBe(false);
285
+ });
286
+ it('is false for an Error borrowing the name without a known reason', () => {
287
+ const impostor = Object.assign(new Error('x'), {
288
+ name: 'UnsafeDestinationError',
289
+ reason: 'other',
290
+ });
291
+ expect((0, safe_url_util_1.isUnsafeDestinationError)(impostor)).toBe(false);
292
+ });
293
+ it('recognises an error from another copy of the class by its name and reason', () => {
294
+ const foreign = Object.assign(new Error('x'), {
295
+ name: 'UnsafeDestinationError',
296
+ reason: safe_url_util_1.UnsafeDestinationReason.TOO_MANY_REDIRECTS,
297
+ });
298
+ expect((0, safe_url_util_1.isUnsafeDestinationError)(foreign)).toBe(true);
299
+ });
300
+ });
301
+ describe('safeAxiosRequest (transport stubbed)', () => {
302
+ it('sends nothing when the URL is refused', async () => {
303
+ const request = jest.spyOn(axios_1.default, 'request');
304
+ await expect((0, safe_url_util_1.safeAxiosRequest)({ url: 'http://169.254.169.254/latest/meta-data/', method: 'GET' }, { resolver: resolverReturning('8.8.8.8') })).rejects.toBeInstanceOf(safe_url_util_1.UnsafeDestinationError);
305
+ expect(request).not.toHaveBeenCalled();
306
+ });
307
+ it('sends a public URL once, with maxRedirects 0, a pinned lookup and no proxy', async () => {
308
+ const request = jest.spyOn(axios_1.default, 'request').mockResolvedValueOnce(okResponse());
309
+ const response = await (0, safe_url_util_1.safeAxiosRequest)({ url: 'https://cdn.example.com/avatar.png', method: 'GET' }, { resolver: resolverReturning('93.184.216.34') });
310
+ expect(response.status).toBe(200);
311
+ expect(request).toHaveBeenCalledTimes(1);
312
+ expect(request.mock.calls[0][0]).toMatchObject({ maxRedirects: 0, proxy: false });
313
+ expect(typeof request.mock.calls[0][0].lookup).toBe('function');
314
+ });
315
+ it('safeAxiosGet sends a GET whatever method the config carries', async () => {
316
+ const request = jest.spyOn(axios_1.default, 'request').mockResolvedValueOnce(okResponse());
317
+ await (0, safe_url_util_1.safeAxiosGet)('https://cdn.example.com/x', { method: 'POST' }, { resolver: resolverReturning('93.184.216.34') });
318
+ expect(request.mock.calls[0][0]).toMatchObject({
319
+ method: 'GET',
320
+ url: 'https://cdn.example.com/x',
321
+ });
322
+ });
323
+ it('refuses a redirect to a private address without calling the second host', async () => {
324
+ const request = jest
325
+ .spyOn(axios_1.default, 'request')
326
+ .mockRejectedValueOnce(redirectError('http://169.254.169.254/latest/'));
327
+ const error = await captureError((0, safe_url_util_1.safeAxiosGet)('https://cdn.example.com/x', {}, { resolver: resolverReturning('93.184.216.34') }));
328
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
329
+ expect(request).toHaveBeenCalledTimes(1);
330
+ });
331
+ it('refuses a redirect to a name resolving to a private address without calling it', async () => {
332
+ const resolver = jest.fn(async (hostname) => hostname === 'cdn.example.com' ? [{ address: '93.184.216.34' }] : [{ address: '10.0.0.7' }]);
333
+ const request = jest
334
+ .spyOn(axios_1.default, 'request')
335
+ .mockRejectedValueOnce(redirectError('https://internal.example.com/admin'));
336
+ const error = await captureError((0, safe_url_util_1.safeAxiosGet)('https://cdn.example.com/x', {}, { resolver }));
337
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
338
+ expect(resolver).toHaveBeenCalledWith('internal.example.com');
339
+ expect(request).toHaveBeenCalledTimes(1);
340
+ });
341
+ it('follows a redirect to another public URL after re-validating it', async () => {
342
+ const request = jest
343
+ .spyOn(axios_1.default, 'request')
344
+ .mockRejectedValueOnce(redirectError('https://other.example.com/final'))
345
+ .mockResolvedValueOnce(okResponse('final'));
346
+ const response = await (0, safe_url_util_1.safeAxiosRequest)({ url: 'https://cdn.example.com/x', method: 'GET' }, { resolver: resolverReturning('93.184.216.34') });
347
+ expect(response.data).toBe('final');
348
+ expect(request).toHaveBeenCalledTimes(2);
349
+ expect(request.mock.calls[1][0]).toMatchObject({ url: 'https://other.example.com/final' });
350
+ });
351
+ it('stops after 5 redirects with too_many_redirects', async () => {
352
+ const request = jest.spyOn(axios_1.default, 'request').mockImplementation(async () => {
353
+ throw redirectError('https://cdn.example.com/again');
354
+ });
355
+ const onSettled = jest.fn();
356
+ const error = await captureError((0, safe_url_util_1.safeAxiosGet)('https://cdn.example.com/x', {}, { resolver: resolverReturning('93.184.216.34'), onSettled }));
357
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.TOO_MANY_REDIRECTS);
358
+ expect(error.message).toBe('Too many redirects (more than 5)');
359
+ expect(request).toHaveBeenCalledTimes(6);
360
+ expect(onSettled).toHaveBeenCalledWith({ hops: 5, durationMs: expect.any(Number) });
361
+ });
362
+ it('rethrows a non-redirect HTTP error as is', async () => {
363
+ const failure = new axios_1.AxiosError('boom', 'ERR_BAD_RESPONSE', undefined, undefined, {
364
+ status: 500,
365
+ headers: {},
366
+ });
367
+ jest.spyOn(axios_1.default, 'request').mockRejectedValueOnce(failure);
368
+ const error = await captureError((0, safe_url_util_1.safeAxiosGet)('https://cdn.example.com/x', {}, { resolver: resolverReturning('93.184.216.34') }));
369
+ expect(error).toBe(failure);
370
+ });
371
+ });
372
+ describe('safeAxiosInstanceRequest', () => {
373
+ it('drives the provided instance, never the global axios', async () => {
374
+ const globalRequest = jest.spyOn(axios_1.default, 'request');
375
+ const instanceRequest = jest.fn().mockResolvedValue(okResponse());
376
+ const instance = { request: instanceRequest };
377
+ const response = await (0, safe_url_util_1.safeAxiosInstanceRequest)(instance, { url: 'https://cdn.example.com/hook', method: 'POST', data: { a: 1 } }, { resolver: resolverReturning('93.184.216.34') });
378
+ expect(response.status).toBe(200);
379
+ expect(instanceRequest).toHaveBeenCalledTimes(1);
380
+ expect(instanceRequest.mock.calls[0][0]).toMatchObject({
381
+ url: 'https://cdn.example.com/hook',
382
+ method: 'POST',
383
+ data: { a: 1 },
384
+ maxRedirects: 0,
385
+ proxy: false,
386
+ });
387
+ expect(globalRequest).not.toHaveBeenCalled();
388
+ });
389
+ it('does not call the instance when the URL is refused', async () => {
390
+ const instanceRequest = jest.fn();
391
+ const instance = { request: instanceRequest };
392
+ await expect((0, safe_url_util_1.safeAxiosInstanceRequest)(instance, { url: 'http://169.254.169.254/latest/', method: 'POST' }, { resolver: resolverReturning('8.8.8.8') })).rejects.toBeInstanceOf(safe_url_util_1.UnsafeDestinationError);
393
+ expect(instanceRequest).not.toHaveBeenCalled();
394
+ });
395
+ it('follows redirects through the instance too', async () => {
396
+ const instanceRequest = jest
397
+ .fn()
398
+ .mockRejectedValueOnce(redirectError('https://other.example.com/final'))
399
+ .mockResolvedValueOnce(okResponse('final'));
400
+ const instance = { request: instanceRequest };
401
+ const response = await (0, safe_url_util_1.safeAxiosInstanceRequest)(instance, { url: 'https://cdn.example.com/x' }, { resolver: resolverReturning('93.184.216.34') });
402
+ expect(response.data).toBe('final');
403
+ expect(instanceRequest).toHaveBeenCalledTimes(2);
404
+ });
405
+ });
406
+ describe('one resolution, one pinned address (ADR-0168)', () => {
407
+ const connectThroughLookup = (resolver) => {
408
+ const connectedTo = [];
409
+ jest.spyOn(axios_1.default, 'request').mockImplementation((async (config) => {
410
+ const hostname = new URL(config.url).hostname;
411
+ const addresses = config.lookup
412
+ ? await lookupThrough(config.lookup, hostname)
413
+ : await resolver(hostname);
414
+ connectedTo.push(addresses[0].address);
415
+ return okResponse();
416
+ }));
417
+ return connectedTo;
418
+ };
419
+ it('resolves the name once per attempt', async () => {
420
+ const resolver = resolverReturning('93.184.216.34');
421
+ const connectedTo = connectThroughLookup(resolver);
422
+ await (0, safe_url_util_1.safeAxiosRequest)({ url: 'https://cdn.example.com/hook', method: 'POST' }, { resolver });
423
+ expect(resolver).toHaveBeenCalledTimes(1);
424
+ expect(connectedTo).toEqual(['93.184.216.34']);
425
+ });
426
+ it('connects to the validated address even if the name then rebinds to a private one', async () => {
427
+ const resolver = jest
428
+ .fn()
429
+ .mockResolvedValueOnce([{ address: '93.184.216.34' }])
430
+ .mockResolvedValue([{ address: '10.0.0.7' }]);
431
+ const connectedTo = connectThroughLookup(resolver);
432
+ await (0, safe_url_util_1.safeAxiosRequest)({ url: 'https://rebind.example/hook', method: 'POST' }, { resolver });
433
+ expect(connectedTo).toEqual(['93.184.216.34']);
434
+ });
435
+ it('pins each hop to the addresses validated for that hop', async () => {
436
+ const resolver = jest.fn(async (hostname) => hostname === 'cdn.example.com' ? [{ address: '93.184.216.34' }] : [{ address: '1.1.1.1' }]);
437
+ const lookups = [];
438
+ jest.spyOn(axios_1.default, 'request').mockImplementation((async (config) => {
439
+ const hostname = new URL(config.url).hostname;
440
+ lookups.push({ hostname, addresses: await lookupThrough(config.lookup, hostname) });
441
+ if (hostname === 'cdn.example.com') {
442
+ throw redirectError('https://other.example.com/final');
443
+ }
444
+ return okResponse('final');
445
+ }));
446
+ await (0, safe_url_util_1.safeAxiosRequest)({ url: 'https://cdn.example.com/x', method: 'GET' }, { resolver });
447
+ expect(resolver).toHaveBeenCalledTimes(2);
448
+ expect(lookups).toEqual([
449
+ { hostname: 'cdn.example.com', addresses: [{ address: '93.184.216.34', family: 4 }] },
450
+ { hostname: 'other.example.com', addresses: [{ address: '1.1.1.1', family: 4 }] },
451
+ ]);
452
+ });
453
+ it("does not let a caller's lookup replace the guard's pin", async () => {
454
+ const resolver = resolverReturning('93.184.216.34');
455
+ const connectedTo = connectThroughLookup(resolver);
456
+ const callerLookup = jest.fn((_hostname, _options, cb) => cb(null, [{ address: '10.0.0.7' }]));
457
+ await (0, safe_url_util_1.safeAxiosRequest)({
458
+ url: 'https://cdn.example.com/hook',
459
+ method: 'POST',
460
+ lookup: callerLookup,
461
+ }, { resolver });
462
+ expect(callerLookup).not.toHaveBeenCalled();
463
+ expect(connectedTo).toEqual(['93.184.216.34']);
464
+ });
465
+ it('disables any proxy along with the pin, even one passed by the caller', async () => {
466
+ const resolver = resolverReturning('93.184.216.34');
467
+ connectThroughLookup(resolver);
468
+ await (0, safe_url_util_1.safeAxiosRequest)({
469
+ url: 'https://cdn.example.com/hook',
470
+ method: 'POST',
471
+ proxy: { protocol: 'http', host: 'proxy.internal', port: 3128 },
472
+ }, { resolver });
473
+ expect(axios_1.default.request.mock.calls[0][0].proxy).toBe(false);
474
+ });
475
+ it('sets no pin under the development exception, which validates nothing', async () => {
476
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
477
+ const request = jest.spyOn(axios_1.default, 'request').mockResolvedValueOnce(okResponse());
478
+ await (0, safe_url_util_1.safeAxiosRequest)({ url: 'http://127.0.0.1:3000/hook' });
479
+ expect(request.mock.calls[0][0].lookup).toBeUndefined();
480
+ expect(request.mock.calls[0][0].proxy).toBeUndefined();
481
+ });
482
+ });
483
+ describe('pinnedLookup', () => {
484
+ it('answers with the validated addresses for their host', async () => {
485
+ const lookup = (0, safe_url_util_1.pinnedLookup)('cdn.example.com', [{ address: '93.184.216.34', family: 4 }]);
486
+ await expect(lookupThrough(lookup, 'cdn.example.com')).resolves.toEqual([
487
+ { address: '93.184.216.34', family: 4 },
488
+ ]);
489
+ });
490
+ it('refuses to resolve any other name', async () => {
491
+ const lookup = (0, safe_url_util_1.pinnedLookup)('cdn.example.com', [{ address: '93.184.216.34', family: 4 }]);
492
+ await expect(lookupThrough(lookup, 'internal.example')).rejects.toThrow('Refusing to resolve internal.example: only cdn.example.com was validated');
493
+ });
494
+ it('pinnedLookupConfig strips the brackets of an IPv6 host', async () => {
495
+ const config = (0, safe_url_util_1.pinnedLookupConfig)({
496
+ url: new URL('https://[2606:4700:4700::1111]/x'),
497
+ addresses: [{ address: '2606:4700:4700::1111', family: 6 }],
498
+ });
499
+ expect(config.proxy).toBe(false);
500
+ await expect(lookupThrough(config.lookup, '2606:4700:4700::1111')).resolves.toEqual([{ address: '2606:4700:4700::1111', family: 6 }]);
501
+ });
502
+ });
503
+ describe('redirect policy', () => {
504
+ const SIGNATURE = 'hmac-of-the-body';
505
+ const CUSTOM_SIGNATURE_HEADER = 'X-Customer-Signature';
506
+ const CLIENT_HOST_HEADER = 'internal.client.example';
507
+ const DELIVERY_TAG = 'delivery-4b7e';
508
+ const callerHeaders = () => ({
509
+ 'Content-Type': 'application/json',
510
+ 'Content-Length': '42',
511
+ Authorization: 'Bearer recipient-secret',
512
+ Cookie: 'session=recipient',
513
+ [FORM_SIGNATURE_HEADER]: SIGNATURE,
514
+ [MONITOR_SIGNATURE_HEADER]: SIGNATURE,
515
+ [CUSTOM_SIGNATURE_HEADER]: SIGNATURE,
516
+ Host: CLIENT_HOST_HEADER,
517
+ 'X-Delivery-Tag': DELIVERY_TAG,
518
+ });
519
+ const sentConfig = (index) => axios_1.default.request.mock.calls[index][0];
520
+ const deliverThroughRedirect = async (location, status = 302, method = 'POST', options = {}) => {
521
+ jest
522
+ .spyOn(axios_1.default, 'request')
523
+ .mockRejectedValueOnce(redirectError(location, status))
524
+ .mockResolvedValueOnce(okResponse('delivered'));
525
+ await (0, safe_url_util_1.safeAxiosRequest)({
526
+ url: 'https://client.example/hook',
527
+ method,
528
+ data: { deliveryId: 'delivery-id' },
529
+ headers: callerHeaders(),
530
+ }, {
531
+ resolver: resolverReturning('93.184.216.34'),
532
+ confidentialHeaders: [CUSTOM_SIGNATURE_HEADER.toLowerCase()],
533
+ ...options,
534
+ });
535
+ };
536
+ it('sends the first request with the caller headers untouched', async () => {
537
+ await deliverThroughRedirect('https://attacker.example/collect', 307);
538
+ expect(sentConfig(0).headers).toEqual(callerHeaders());
539
+ });
540
+ it('drops Authorization, Cookie, every signature and the caller confidential header on a hop to another host', async () => {
541
+ await deliverThroughRedirect('https://attacker.example/collect', 307);
542
+ const headers = sentConfig(1).headers;
543
+ expect(headers.Authorization).toBeUndefined();
544
+ expect(headers.Cookie).toBeUndefined();
545
+ expect(headers[FORM_SIGNATURE_HEADER]).toBeUndefined();
546
+ expect(headers[MONITOR_SIGNATURE_HEADER]).toBeUndefined();
547
+ expect(headers[CUSTOM_SIGNATURE_HEADER]).toBeUndefined();
548
+ expect(headers.Host).toBeUndefined();
549
+ expect(headers['X-Delivery-Tag']).toBe(DELIVERY_TAG);
550
+ });
551
+ it('keeps the confidential headers on a hop to a subdomain, but still drops Host', async () => {
552
+ await deliverThroughRedirect('https://api.client.example/hook', 307);
553
+ const headers = sentConfig(1).headers;
554
+ expect(headers.Authorization).toBe('Bearer recipient-secret');
555
+ expect(headers.Cookie).toBe('session=recipient');
556
+ expect(headers[FORM_SIGNATURE_HEADER]).toBe(SIGNATURE);
557
+ expect(headers[CUSTOM_SIGNATURE_HEADER]).toBe(SIGNATURE);
558
+ expect(headers.Host).toBeUndefined();
559
+ });
560
+ it('drops Host on a hop to the same host and keeps the signature', async () => {
561
+ await deliverThroughRedirect('/hook-v2', 307);
562
+ expect(sentConfig(1).headers.Host).toBeUndefined();
563
+ expect(sentConfig(1).headers[FORM_SIGNATURE_HEADER]).toBe(SIGNATURE);
564
+ });
565
+ it('drops the confidential headers on a protocol downgrade to the same host', async () => {
566
+ await deliverThroughRedirect('http://client.example/hook', 307);
567
+ expect(sentConfig(1).headers.Authorization).toBeUndefined();
568
+ expect(sentConfig(1).headers[FORM_SIGNATURE_HEADER]).toBeUndefined();
569
+ });
570
+ it('does not treat a lookalike suffix as a subdomain', async () => {
571
+ await deliverThroughRedirect('https://evilclient.example/hook', 307);
572
+ expect(sentConfig(1).headers[FORM_SIGNATURE_HEADER]).toBeUndefined();
573
+ });
574
+ it.each([303, 302])('turns a POST into a GET without body nor content-* on a %s', async (status) => {
575
+ await deliverThroughRedirect('/hook-v2', status);
576
+ expect(sentConfig(0).method).toBe('POST');
577
+ expect(sentConfig(0).data).toEqual({ deliveryId: 'delivery-id' });
578
+ const next = sentConfig(1);
579
+ expect(next.method).toBe('GET');
580
+ expect(next.data).toBeUndefined();
581
+ expect(next.headers['Content-Type']).toBeUndefined();
582
+ expect(next.headers['Content-Length']).toBeUndefined();
583
+ expect(next.headers['X-Delivery-Tag']).toBe(DELIVERY_TAG);
584
+ });
585
+ it('keeps the method, the body and its Content-Type on a 307', async () => {
586
+ await deliverThroughRedirect('/hook-v2', 307);
587
+ const next = sentConfig(1);
588
+ expect(next.method).toBe('POST');
589
+ expect(next.data).toEqual({ deliveryId: 'delivery-id' });
590
+ expect(next.headers['Content-Type']).toBe('application/json');
591
+ });
592
+ it('never brings a dropped body back at a later hop', async () => {
593
+ jest
594
+ .spyOn(axios_1.default, 'request')
595
+ .mockRejectedValueOnce(redirectError('/second', 303))
596
+ .mockRejectedValueOnce(redirectError('/third', 307))
597
+ .mockResolvedValueOnce(okResponse());
598
+ await (0, safe_url_util_1.safeAxiosRequest)({ url: 'https://client.example/hook', method: 'POST', data: { a: 1 } }, { resolver: resolverReturning('93.184.216.34') });
599
+ expect(sentConfig(2).method).toBe('GET');
600
+ expect(sentConfig(2).data).toBeUndefined();
601
+ });
602
+ describe('requestForRedirect', () => {
603
+ const from = new URL('https://client.example/hook');
604
+ it('turns a POST into a GET on a 301', () => {
605
+ expect((0, safe_url_util_1.requestForRedirect)({ headers: {}, method: 'POST' }, from, from, 301)).toEqual({
606
+ headers: {},
607
+ method: 'GET',
608
+ sendsBody: false,
609
+ });
610
+ });
611
+ it('keeps a PUT on a 302, which only normalises a POST', () => {
612
+ expect((0, safe_url_util_1.requestForRedirect)({ headers: {}, method: 'PUT' }, from, from, 302).method).toBe('PUT');
613
+ });
614
+ it('turns a PUT into a GET on a 303', () => {
615
+ expect((0, safe_url_util_1.requestForRedirect)({ headers: {}, method: 'PUT' }, from, from, 303).method).toBe('GET');
616
+ });
617
+ it('matches the caller confidential headers case-insensitively', () => {
618
+ const next = (0, safe_url_util_1.requestForRedirect)({ headers: { 'x-customer-signature': 's', Accept: 'json' }, method: 'GET' }, from, new URL('https://attacker.example/'), 307, ['X-Customer-Signature']);
619
+ expect(next.headers).toEqual({ Accept: 'json' });
620
+ });
621
+ });
622
+ });
623
+ describe('isOutboundDeadlineError', () => {
624
+ it.each(['ECONNABORTED', 'ETIMEDOUT', 'ERR_CANCELED'])('is true for an axios error %s without response', (code) => {
625
+ expect((0, safe_url_util_1.isOutboundDeadlineError)(new axios_1.AxiosError('x', code))).toBe(true);
626
+ });
627
+ it('is false with a response, another code, or a non-axios error', () => {
628
+ const answered = new axios_1.AxiosError('x', 'ETIMEDOUT', undefined, undefined, {
629
+ status: 504,
630
+ headers: {},
631
+ });
632
+ expect((0, safe_url_util_1.isOutboundDeadlineError)(answered)).toBe(false);
633
+ expect((0, safe_url_util_1.isOutboundDeadlineError)(new axios_1.AxiosError('x', 'ECONNREFUSED'))).toBe(false);
634
+ expect((0, safe_url_util_1.isOutboundDeadlineError)(new Error('ETIMEDOUT'))).toBe(false);
635
+ });
636
+ });
637
+ describe('real HTTP', () => {
638
+ let server;
639
+ let port;
640
+ let baseUrl;
641
+ let handler;
642
+ beforeAll(async () => {
643
+ server = http.createServer((req, res) => handler(req, res));
644
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
645
+ port = server.address().port;
646
+ baseUrl = `http://127.0.0.1:${port}`;
647
+ });
648
+ afterAll(async () => {
649
+ server.closeAllConnections();
650
+ await new Promise((resolve) => server.close(() => resolve()));
651
+ });
652
+ describe('address pinning with the exception disarmed', () => {
653
+ it('connects to the pinned address while Host keeps the name', async () => {
654
+ let receivedHost;
655
+ handler = (req, res) => {
656
+ receivedHost = req.headers.host;
657
+ res.writeHead(200, { 'content-type': 'text/plain' }).end('pinned');
658
+ };
659
+ const response = await axios_1.default.get(`http://pinned.invalid:${port}/ok`, {
660
+ lookup: (0, safe_url_util_1.pinnedLookup)('pinned.invalid', [{ address: '127.0.0.1', family: 4 }]),
661
+ proxy: false,
662
+ });
663
+ expect(response.data).toBe('pinned');
664
+ expect(receivedHost).toBe(`pinned.invalid:${port}`);
665
+ });
666
+ it('refuses a connection to a name the pin did not validate', async () => {
667
+ let reached = false;
668
+ handler = (_req, res) => {
669
+ reached = true;
670
+ res.writeHead(200).end();
671
+ };
672
+ await expect(axios_1.default.get(`http://other.invalid:${port}/ok`, {
673
+ lookup: (0, safe_url_util_1.pinnedLookup)('pinned.invalid', [{ address: '127.0.0.1', family: 4 }]),
674
+ proxy: false,
675
+ })).rejects.toThrow('Refusing to resolve other.invalid: only pinned.invalid was validated');
676
+ expect(reached).toBe(false);
677
+ });
678
+ it('refuses the local server itself through the guard', async () => {
679
+ let reached = false;
680
+ handler = (_req, res) => {
681
+ reached = true;
682
+ res.writeHead(200).end();
683
+ };
684
+ const error = await captureError((0, safe_url_util_1.safeAxiosGet)(`${baseUrl}/ok`));
685
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.PRIVATE_ADDRESS);
686
+ expect(reached).toBe(false);
687
+ });
688
+ });
689
+ const redirectChain = (delayMs, last) => {
690
+ handler = (req, res) => {
691
+ const n = Number((req.url ?? '').split('/')[2]);
692
+ setTimeout(() => {
693
+ if (n >= last) {
694
+ res.writeHead(200, { 'content-type': 'text/plain' }).end('final');
695
+ return;
696
+ }
697
+ res.writeHead(302, { location: `/hop/${n + 1}` }).end();
698
+ }, delayMs);
699
+ };
700
+ };
701
+ const variants = [
702
+ ['safeAxiosRequest', (config, options) => (0, safe_url_util_1.safeAxiosRequest)(config, options)],
703
+ [
704
+ 'safeAxiosInstanceRequest',
705
+ (config, options) => (0, safe_url_util_1.safeAxiosInstanceRequest)(axios_1.default.create(), config, options),
706
+ ],
707
+ ];
708
+ describe.each(variants)('total deadline through %s', (_name, send) => {
709
+ beforeEach(() => {
710
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
711
+ });
712
+ it('fails a slow redirect chain at the attempt deadline with ETIMEDOUT', async () => {
713
+ redirectChain(150, 5);
714
+ const onSettled = jest.fn();
715
+ const startedAt = Date.now();
716
+ const error = (await captureError(send({ url: `${baseUrl}/hop/0`, timeout: 400 }, { onSettled })));
717
+ expect(error.code).toBe('ETIMEDOUT');
718
+ expect(error.response).toBeUndefined();
719
+ expect(error.message).toBe('Outbound deadline of 400ms exceeded');
720
+ expect((0, safe_url_util_1.isOutboundDeadlineError)(error)).toBe(true);
721
+ expect(Date.now() - startedAt).toBeLessThan(400 + 300);
722
+ expect(onSettled).toHaveBeenCalledTimes(1);
723
+ expect(onSettled.mock.calls[0][0].hops).toBeGreaterThanOrEqual(1);
724
+ });
725
+ it('cuts a drip-fed response at the deadline', async () => {
726
+ handler = (_req, res) => {
727
+ res.writeHead(200, { 'content-type': 'text/plain' });
728
+ const drip = setInterval(() => res.write('.'), 50);
729
+ res.on('close', () => clearInterval(drip));
730
+ };
731
+ const startedAt = Date.now();
732
+ const error = (await captureError(send({ url: `${baseUrl}/drip`, timeout: 300 })));
733
+ expect(error.code).toBe('ETIMEDOUT');
734
+ expect(Date.now() - startedAt).toBeLessThan(300 + 300);
735
+ });
736
+ it('cuts a DNS resolution that never answers at the deadline', async () => {
737
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, undefined);
738
+ const resolver = hangingResolver();
739
+ const onSettled = jest.fn();
740
+ const startedAt = Date.now();
741
+ const error = (await captureError(send({ url: 'https://silent-dns.example/cron', timeout: 200 }, { resolver, onSettled })));
742
+ expect(resolver).toHaveBeenCalledWith('silent-dns.example');
743
+ expect(error.code).toBe('ETIMEDOUT');
744
+ expect(error.message).toBe('Outbound deadline of 200ms exceeded');
745
+ expect(Date.now() - startedAt).toBeLessThan(200 + 300);
746
+ expect(onSettled).toHaveBeenCalledWith({ hops: 0, durationMs: expect.any(Number) });
747
+ });
748
+ it('succeeds in one healthy hop', async () => {
749
+ handler = (_req, res) => res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
750
+ const onSettled = jest.fn();
751
+ const response = await send({ url: `${baseUrl}/ok` }, { onSettled });
752
+ expect(response.data).toBe('ok');
753
+ expect(onSettled).toHaveBeenCalledWith({ hops: 0, durationMs: expect.any(Number) });
754
+ });
755
+ it('succeeds through redirects under the deadline and counts its hops', async () => {
756
+ redirectChain(20, 2);
757
+ const onSettled = jest.fn();
758
+ const response = await send({ url: `${baseUrl}/hop/0`, timeout: 2000 }, { onSettled });
759
+ expect(response.data).toBe('final');
760
+ expect(onSettled.mock.calls[0][0].hops).toBe(2);
761
+ });
762
+ it('refuses beyond 5 real redirects', async () => {
763
+ handler = (req, res) => res.writeHead(302, { location: `${req.url}/x` }).end();
764
+ const error = await captureError(send({ url: `${baseUrl}/loop`, timeout: 2000 }));
765
+ expect(error.reason).toBe(safe_url_util_1.UnsafeDestinationReason.TOO_MANY_REDIRECTS);
766
+ });
767
+ it('keeps a cancellation by the caller signal an ERR_CANCELED', async () => {
768
+ handler = () => undefined;
769
+ const controller = new AbortController();
770
+ setTimeout(() => controller.abort(), 50);
771
+ const error = (await captureError(send({ url: `${baseUrl}/pending`, timeout: 2000, signal: controller.signal })));
772
+ expect(error.code).toBe('ERR_CANCELED');
773
+ expect(error.message).not.toMatch(/Outbound deadline/);
774
+ });
775
+ it('lets the caller signal interrupt a DNS resolution that never answers', async () => {
776
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, undefined);
777
+ const controller = new AbortController();
778
+ setTimeout(() => controller.abort(), 50);
779
+ const startedAt = Date.now();
780
+ const error = (await captureError(send({ url: 'https://silent-dns.example/cron', timeout: 2000, signal: controller.signal }, { resolver: hangingResolver() })));
781
+ expect(error.code).toBe('ERR_CANCELED');
782
+ expect(Date.now() - startedAt).toBeLessThan(50 + 300);
783
+ });
784
+ it('removes its listener from the caller signal once the attempt is over', async () => {
785
+ handler = (_req, res) => res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
786
+ const controller = new AbortController();
787
+ const add = jest.spyOn(controller.signal, 'addEventListener');
788
+ const remove = jest.spyOn(controller.signal, 'removeEventListener');
789
+ await send({ url: `${baseUrl}/ok`, signal: controller.signal });
790
+ expect(add).toHaveBeenCalledTimes(1);
791
+ expect(remove).toHaveBeenCalledWith('abort', add.mock.calls[0][1]);
792
+ });
793
+ });
794
+ describe('deadline details', () => {
795
+ beforeEach(() => {
796
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, 'true');
797
+ });
798
+ it('does not cut a stream after its headers: the deadline stops at the response', async () => {
799
+ handler = (_req, res) => {
800
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
801
+ let sent = 0;
802
+ const chunks = setInterval(() => {
803
+ sent += 1;
804
+ res.write(`chunk${sent};`);
805
+ if (sent === 8) {
806
+ clearInterval(chunks);
807
+ res.end();
808
+ }
809
+ }, 100);
810
+ res.on('close', () => clearInterval(chunks));
811
+ };
812
+ const response = await (0, safe_url_util_1.safeAxiosRequest)({
813
+ url: `${baseUrl}/stream`,
814
+ responseType: 'stream',
815
+ timeout: 300,
816
+ });
817
+ let body = '';
818
+ for await (const chunk of response.data) {
819
+ body += chunk.toString();
820
+ }
821
+ expect(body).toBe(Array.from({ length: 8 }, (_, index) => `chunk${index + 1};`).join(''));
822
+ });
823
+ it.each(['ECONNABORTED', 'ETIMEDOUT'])('turns a hop inactivity timeout (%s, no response) into the deadline error, without the headers', async (code) => {
824
+ jest
825
+ .spyOn(axios_1.default, 'request')
826
+ .mockRejectedValueOnce(new axios_1.AxiosError('timeout of 2000ms exceeded', code));
827
+ const error = (await captureError((0, safe_url_util_1.safeAxiosRequest)({
828
+ url: `${baseUrl}/idle`,
829
+ timeout: 2000,
830
+ headers: { Authorization: 'Bearer secret' },
831
+ })));
832
+ expect(error.code).toBe('ETIMEDOUT');
833
+ expect(error.message).toBe('Outbound deadline of 2000ms exceeded');
834
+ expect(error.config?.url).toBe(`${baseUrl}/idle`);
835
+ expect(error.config?.headers.Authorization).toBeUndefined();
836
+ });
837
+ it('honours the deadline passed by the caller', async () => {
838
+ (0, test_env_util_1.setTestEnv)(safe_url_util_1.ALLOW_PRIVATE_OUTBOUND_TARGETS_ENV, undefined);
839
+ const startedAt = Date.now();
840
+ const error = (await captureError((0, safe_url_util_1.safeAxiosRequest)({ url: 'https://silent-dns.example/cron', timeout: 1000 }, { resolver: hangingResolver() })));
841
+ expect(error.message).toBe('Outbound deadline of 1000ms exceeded');
842
+ expect(Date.now() - startedAt).toBeGreaterThanOrEqual(950);
843
+ expect(Date.now() - startedAt).toBeLessThan(1000 + 500);
844
+ });
845
+ });
846
+ });
847
+ });
848
+ //# sourceMappingURL=safe-url.util.spec.js.map