@memberjunction/network-utils 0.0.0 → 6.1.0-edge.4

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.
package/README.md CHANGED
@@ -1,45 +1,476 @@
1
1
  # @memberjunction/network-utils
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Low-level, **server-side only** network utilities for MemberJunction: an SSRF guard that stops the server being tricked into fetching internal addresses, and a dependency-free HTTP client built on Node's native `fetch`. This package has **no MemberJunction dependencies and no third-party dependencies** — only `node:dns` and `node:net` — so it sits below everything else and any server-side package can use it without risking a cycle.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ ## Installation
6
6
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
7
+ ```bash
8
+ npm install @memberjunction/network-utils
9
+ ```
8
10
 
9
- ## Purpose
11
+ No peer dependencies, no optional extras, no configuration. Node 18 or later, for native `fetch`.
10
12
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@memberjunction/network-utils`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
13
+ > **Node only.** This package imports `node:dns` and `node:net` and must never be pulled into a browser bundle. That constraint is precisely why the SSRF guard does not live in `@memberjunction/global`, which ships to the browser.
15
14
 
16
- ## What is OIDC Trusted Publishing?
15
+ ## Overview
17
16
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
17
+ Two concerns live here, and they belong together.
19
18
 
20
- ## Setup Instructions
19
+ **The SSRF guard.** Any server-side code that fetches a URL it did not hard-code is a read-SSRF risk. An attacker who can influence that URL — through an AI agent, an Action parameter, a workflow, or an API field — can make the server fetch an address only the server can reach, and read the response back. The cloud metadata endpoint (`http://169.254.169.254/`) is the classic target, because on an unhardened instance it hands out IAM credentials to anything that asks.
21
20
 
22
- To properly configure OIDC trusted publishing for this package:
21
+ **The HTTP client.** MJ previously reached for `axios` in eleven packages. Replacing it with one native-`fetch` client removes a third-party dependency from the supply chain, and — more importantly — creates a single place every outbound request passes through. That is what makes the guard *enforceable*: it is one option flag (`ValidateUrl`) away at every call site, which is impossible when each package brings its own HTTP library.
23
22
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
23
+ ```mermaid
24
+ flowchart TD
25
+ subgraph Callers["Server-side callers"]
26
+ Actions["Actions<br/>(agent-invokable)"]
27
+ Providers["BizApps providers<br/>(fixed endpoints)"]
28
+ Internal["MJServer / MetadataSync<br/>(may target internal hosts)"]
29
+ end
28
30
 
29
- ## DO NOT USE THIS PACKAGE
31
+ subgraph Pkg["@memberjunction/network-utils"]
32
+ Client["HttpClient / HttpRequest<br/>native fetch"]
33
+ Guard["SSRF guard<br/>AssertPublicUrl / SafeFetch"]
34
+ end
30
35
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
36
+ Public["Public internet"]
37
+ Blocked["Private / reserved space<br/>169.254.169.254, 10/8, 127/8, ::1 …"]
36
38
 
37
- ## More Information
39
+ Actions -->|"ValidateUrl: true"| Client
40
+ Providers -->|"fixed URL, guard off"| Client
41
+ Internal -->|"internal URL, guard off"| Client
38
42
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
43
+ Client -->|"when ValidateUrl"| Guard
44
+ Guard -->|allowed| Public
45
+ Guard -.->|"SSRFError"| Blocked
46
+
47
+ style Guard fill:#2d6a4f,color:#fff
48
+ style Blocked fill:#7f1d1d,color:#fff
49
+ ```
50
+
51
+ ## Key Features
52
+
53
+ - **Resolves before it trusts.** Checks every A/AAAA record a hostname maps to, not just the first, closing the multi-record DNS bypass.
54
+ - **Re-validates every redirect hop**, closing the redirect bypass that defeats one-time hostname checks.
55
+ - **Fails closed.** An unparseable address, an unresolvable host, or an unrecognized scheme is rejected rather than passed through.
56
+ - **Zero dependencies.** `node:dns` and `node:net` only — nothing to audit, nothing to keep patched.
57
+ - **A familiar HTTP client** with `BaseURL`, default headers, timeouts, typed responses, and hooks that replace axios interceptors.
58
+ - **Security errors are their own type.** `SSRFError` never masquerades as a transport failure, so a blocked URL is never retried or counted as an outage.
59
+
60
+ ## Quick Start
61
+
62
+ ### Fetching a URL you control
63
+
64
+ ```ts
65
+ import { HttpGet, HttpPost } from '@memberjunction/network-utils';
66
+
67
+ const response = await HttpGet<Payload>('https://api.example.com/things', {
68
+ Query: { page: 1, tag: ['a', 'b'] }, // -> ?page=1&tag=a&tag=b
69
+ Headers: { Authorization: `Bearer ${token}` },
70
+ Timeout: 30000,
71
+ });
72
+
73
+ response.Data; // parsed body, typed as Payload
74
+ response.Status; // 200
75
+ response.Headers; // lower-cased keys
76
+
77
+ await HttpPost('https://api.example.com/things', { name: 'Widget' }); // JSON-encoded
78
+ ```
79
+
80
+ ### Fetching a URL someone else controls
81
+
82
+ ```ts
83
+ import { SafeFetch, SSRFError } from '@memberjunction/network-utils';
84
+
85
+ try {
86
+ const response = await SafeFetch(userSuppliedUrl);
87
+ const html = await response.text();
88
+ } catch (error) {
89
+ if (error instanceof SSRFError) {
90
+ return { Success: false, ResultCode: 'SSRF_BLOCKED', Message: error.message };
91
+ }
92
+ throw error;
93
+ }
94
+ ```
95
+
96
+ ### A configured client for one upstream API
97
+
98
+ ```ts
99
+ import { HttpClient } from '@memberjunction/network-utils';
100
+
101
+ const client = new HttpClient({
102
+ BaseURL: 'https://graph.facebook.com/v18.0',
103
+ Timeout: 30000,
104
+ Headers: { Accept: 'application/json' },
105
+
106
+ OnRequest: (config) => ({ // replaces a request interceptor
107
+ ...config,
108
+ Query: { ...config.Query, access_token: token },
109
+ }),
110
+
111
+ OnRetry: async (error) => { // replaces an error interceptor
112
+ if (error.Status !== 429) return false;
113
+ await sleep(60_000);
114
+ return true; // bounded by MaxRetries (default 3)
115
+ },
116
+ });
117
+
118
+ const feed = await client.Get<Feed>('/me/feed');
119
+ ```
120
+
121
+ ## Choosing an entry point
122
+
123
+ | You have | Use | Why |
124
+ |---|---|---|
125
+ | A hard-coded provider URL, one call | `HttpGet` / `HttpPost` / … | Simplest thing that works |
126
+ | A hard-coded provider URL, many calls sharing auth | `new HttpClient({ … })` | Base URL, headers, and retry policy in one place |
127
+ | A URL from an agent, Action param, API caller, or stored data | `SafeFetch` | Guarded, and you get the raw `Response` |
128
+ | The same, but you want the body parsed for you | `SafeHttpRequest` | `HttpRequest` with `ValidateUrl` forced on |
129
+ | An address you already resolved | `IsBlockedIPAddress` | Pure, synchronous, no DNS |
130
+ | A URL to check but not fetch | `AssertPublicUrl` | Validation only |
131
+
132
+ ---
133
+
134
+ ## The SSRF guard
135
+
136
+ ### Why a scheme check is not enough
137
+
138
+ The obvious defence — "reject anything that isn't `http`/`https`, and reject hostnames that look internal" — fails against three independent bypasses:
139
+
140
+ | Bypass | What it looks like | Why a naive check misses it |
141
+ |---|---|---|
142
+ | **Literal addresses in disguise** | `http://0x7f.1/`, `http://2130706433/`, `http://[::ffff:127.0.0.1]/` | All are loopback; none of them string-match `127.0.0.1` |
143
+ | **DNS with a private answer** | `http://evil.example.com/` resolving to `10.0.0.5` | The hostname is a perfectly ordinary public name |
144
+ | **Redirects** | A public URL replying `302 Location: http://169.254.169.254/` | The URL you validated is not the URL you end up fetching |
145
+
146
+ A variation on the second — **DNS rebinding** — is subtler still: publish two A records, one public and one private, and let the resolver choose. Checking only the first address returned means the guard and the connection can disagree.
147
+
148
+ This package addresses all three: addresses are classified numerically after resolution rather than by string matching, *every* resolved address must be public, and every redirect hop is re-validated.
149
+
150
+ ### How `SafeFetch` works
151
+
152
+ ```mermaid
153
+ sequenceDiagram
154
+ participant C as Caller
155
+ participant S as SafeFetch
156
+ participant G as AssertPublicUrl
157
+ participant D as node:dns
158
+ participant U as Upstream
159
+
160
+ C->>S: SafeFetch(url)
161
+ loop each hop, up to MaxRedirects
162
+ S->>G: validate(currentUrl)
163
+ G->>G: parse + scheme check (http/https only)
164
+ G->>D: lookup(host, { all: true })
165
+ D-->>G: every A / AAAA record
166
+ G->>G: classify EVERY address
167
+ alt any address is private or reserved
168
+ G-->>C: throw SSRFError
169
+ end
170
+ G-->>S: parsed URL
171
+ S->>U: fetch(url, { redirect: 'manual' })
172
+ U-->>S: response
173
+ alt 3xx with Location
174
+ S->>S: cancel body, resolve next hop
175
+ else final response
176
+ S-->>C: Response
177
+ end
178
+ end
179
+ ```
180
+
181
+ ### Blocked ranges
182
+
183
+ **IPv4**
184
+
185
+ | Range | What it is |
186
+ |---|---|
187
+ | `0.0.0.0/8` | "This" network / unspecified |
188
+ | `10.0.0.0/8` | Private (RFC 1918) |
189
+ | `100.64.0.0/10` | Carrier-grade NAT |
190
+ | `127.0.0.0/8` | Loopback |
191
+ | `169.254.0.0/16` | Link-local — **includes `169.254.169.254`, the cloud metadata endpoint** |
192
+ | `172.16.0.0/12` | Private (RFC 1918) |
193
+ | `192.0.0.0/24` | IETF protocol assignments |
194
+ | `192.0.2.0/24` | TEST-NET-1 |
195
+ | `192.88.99.0/24` | 6to4 relay anycast |
196
+ | `192.168.0.0/16` | Private (RFC 1918) |
197
+ | `198.18.0.0/15` | Benchmarking |
198
+ | `198.51.100.0/24` | TEST-NET-2 |
199
+ | `203.0.113.0/24` | TEST-NET-3 |
200
+ | `224.0.0.0/4` | Multicast |
201
+ | `240.0.0.0/4` | Reserved, including the `255.255.255.255` broadcast address |
202
+
203
+ **IPv6**
204
+
205
+ | Range | What it is |
206
+ |---|---|
207
+ | `::1` | Loopback |
208
+ | `::` | Unspecified |
209
+ | `fc00::/7` | Unique local addresses |
210
+ | `fe80::/10` | Link-local |
211
+ | `ff00::/8` | Multicast |
212
+ | `::ffff:a.b.c.d` | IPv4-mapped — unwrapped, then run through the IPv4 rules |
213
+ | `::a.b.c.d` | IPv4-compatible — same treatment |
214
+
215
+ ### What this does *not* protect against
216
+
217
+ Being straight about the limits matters more than the feature list:
218
+
219
+ - **A TOCTOU rebinding race.** There is an unavoidable gap between validating an address and connecting to it, during which DNS can change. Closing it entirely requires pinning the resolved address at the socket layer (a custom agent that dials the validated IP with the original `Host` header). This package validates; it does not pin. For most MJ threat models — an agent coaxed into fetching a URL — that residual risk is acceptable, but it is real, and it is the right next step if this guard ever fronts something highly sensitive.
220
+ - **Write-SSRF side effects.** The guard stops the server *reaching* private space. It does not reason about what a request *does*. A `POST` to a permitted public URL is still a `POST`.
221
+ - **Data exfiltration to a public host.** A public URL is allowed by design. If the risk you care about is data leaving, you want an allowlist, not a denylist.
222
+ - **Anything you route around.** Calling `fetch` directly, or leaving `ValidateUrl` off on a caller-controlled URL, bypasses all of this.
223
+
224
+ ### Why the guard defaults to off
225
+
226
+ `ValidateUrl` defaults to **`false`** on `HttpRequest` and `HttpClient`. That is deliberate, and worth understanding before you change it:
227
+
228
+ - Most MJ call sites talk to **fixed, well-known provider endpoints** (`api.twitter.com`, `graph.facebook.com`). Guarding those buys nothing and costs a DNS lookup per request.
229
+ - Some legitimately talk to **internal hosts** — MJServer posting to its own CodeGen API on localhost, MetadataSync resolving an `@url:` reference on a developer's machine. Guarding those by default would break them.
230
+
231
+ So the rule is not "always on", it is **on wherever the URL is caller-controlled** — anything reachable by an AI agent, an Action parameter, an API caller, or data those can write. `SafeFetch` and `SafeHttpRequest` have it on and cannot be turned off.
42
232
 
43
233
  ---
44
234
 
45
- **Maintained for OIDC setup purposes only**
235
+ ## The HTTP client
236
+
237
+ ### Responses
238
+
239
+ Every request resolves to an `HttpResponse<T>`:
240
+
241
+ ```ts
242
+ interface HttpResponse<T> {
243
+ Data: T; // parsed per ResponseType
244
+ Status: number;
245
+ StatusText: string;
246
+ Headers: Record<string, string>; // keys are always lower-cased
247
+ Url: string; // final URL, after redirects
248
+ Ok: boolean; // Status is 2xx
249
+ }
250
+ ```
251
+
252
+ Supply the type parameter at the call site so `Data` is typed rather than `unknown`:
253
+
254
+ ```ts
255
+ const response = await HttpGet<{ items: Item[] }>(url);
256
+ response.Data.items; // typed
257
+ ```
258
+
259
+ ### Errors
260
+
261
+ Non-2xx responses throw `HttpError` — matching axios's default — but with everything flattened onto the error rather than nested under a `response` property that may or may not exist:
262
+
263
+ ```ts
264
+ try {
265
+ await client.Get('/thing');
266
+ } catch (error) {
267
+ if (!IsHttpError(error)) throw error;
268
+
269
+ if (error.IsCancelled) return; // the caller aborted; expected
270
+ if (error.IsTimeout) { /* our Timeout elapsed */ }
271
+ else if (error.Status === 404) { /* ... */ }
272
+ else if (error.Status === 429) await backOff(error.Headers['retry-after']);
273
+ }
274
+ ```
275
+
276
+ `Status` is `0` when the request never produced a response, so `error.Status === 404` is always safe to write. Pass `ThrowOnError: false` to get the response back instead — the equivalent of axios's `validateStatus: () => true`.
277
+
278
+ `SSRFError` is deliberately **not** an `HttpError` and is never wrapped in one, so a security decision can never be confused with an unreachable host, retried by a retry hook, or logged as an outage.
279
+
280
+ ### Request bodies
281
+
282
+ | You pass | What happens |
283
+ |---|---|
284
+ | A plain object or array | JSON-encoded, `Content-Type: application/json` added unless you set one |
285
+ | `URLSearchParams` | Passed through; `fetch` sets `application/x-www-form-urlencoded` |
286
+ | `FormData` | Passed through; `fetch` sets `multipart/form-data` **with the boundary** |
287
+ | `string`, `Blob`, `ArrayBuffer`, typed arrays, `ReadableStream` | Passed through untouched |
288
+ | Anything, on `GET`/`HEAD` | Ignored — those methods take no body |
289
+
290
+ > Use the global WHATWG `FormData` and `Blob`, not the `form-data` npm package. The latter produces a Node stream that native `fetch` cannot consume, and its `getHeaders()` has no equivalent — `fetch` derives the multipart boundary itself.
291
+
292
+ ### Response types
293
+
294
+ `ResponseType` accepts `json` (default), `text`, `arraybuffer`, `blob`, `stream`, and `none`. The `json` reader is forgiving in two ways that match axios: an empty body yields `null` rather than throwing, and a body that is not valid JSON is returned as raw text rather than throwing — which keeps you working when a server mislabels its `Content-Type`.
295
+
296
+ ### Query strings
297
+
298
+ `null` and `undefined` entries are omitted entirely rather than becoming the strings `"null"`/`"undefined"`; arrays expand to repeated keys (`?tag=a&tag=b`); `Date` values render as ISO 8601.
299
+
300
+ ### Defaults
301
+
302
+ | Option | Default |
303
+ |---|---|
304
+ | `Method` | `GET` |
305
+ | `ResponseType` | `json` (`none` for `HEAD`) |
306
+ | `Timeout` | `30000` ms (`0` disables) |
307
+ | `ThrowOnError` | `true` |
308
+ | `ValidateUrl` | `false` |
309
+ | `MaxRedirects` | `5` |
310
+ | `MaxRetries` (client) | `3` |
311
+
312
+ ---
313
+
314
+ ## Migrating from axios
315
+
316
+ The shape deliberately mirrors the parts of axios MJ actually used, so call sites port over mechanically.
317
+
318
+ | axios | here |
319
+ |---|---|
320
+ | `axios.get(url, { params, headers, timeout })` | `HttpGet(url, { Query, Headers, Timeout })` |
321
+ | `axios.post(url, body, config)` | `HttpPost(url, body, config)` |
322
+ | `axios.request({ url, method, data, params })` | `HttpRequest({ Url, Method, Body, Query })` |
323
+ | `axios.create({ baseURL, headers, timeout })` | `new HttpClient({ BaseURL, Headers, Timeout })` |
324
+ | `response.data` / `.status` / `.headers` | `response.Data` / `.Status` / `.Headers` |
325
+ | `axios.isAxiosError(e)` | `IsHttpError(e)` |
326
+ | `axios.isCancel(e)` | `IsCancellationError(e)` |
327
+ | `e.response?.status` / `e.response?.data` | `e.Status` / `e.Data` (always present) |
328
+ | `validateStatus: () => true` | `ThrowOnError: false` |
329
+ | `auth: { username, password }` | `BasicAuth: { Username, Password }` |
330
+ | `signal` | `Signal` |
331
+ | `responseType: 'arraybuffer'` | `ResponseType: 'arraybuffer'` |
332
+ | `transformRequest` for form encoding | Pass a `URLSearchParams` body |
333
+ | `interceptors.request.use(fn)` | `OnRequest` |
334
+ | `interceptors.response.use(onOk)` | `OnResponse` |
335
+ | `interceptors.response.use(_, onErr)` | `OnRetry` |
336
+
337
+ ### Interceptors become hooks
338
+
339
+ The one genuine difference. axios interceptors are entries appended to a mutable, instance-global chain; recovering from an error means re-issuing the request yourself. Here, the hooks are plain options — visible at the construction site, not modifiable from elsewhere — and retry is expressed as a boolean.
340
+
341
+ ```ts
342
+ // Before — axios
343
+ instance.interceptors.response.use(
344
+ (response) => response,
345
+ async (error) => {
346
+ if (error.response?.status === 429) {
347
+ await sleep(60_000);
348
+ return instance.request(error.config); // you re-issue it
349
+ }
350
+ return Promise.reject(error);
351
+ },
352
+ );
353
+
354
+ // After — network-utils
355
+ new HttpClient({
356
+ OnRetry: async (error) => {
357
+ if (error.Status !== 429) return false;
358
+ await sleep(60_000);
359
+ return true; // the client re-issues it, bounded
360
+ },
361
+ });
362
+ ```
363
+
364
+ `OnRequest` runs again before each retry, so a token refreshed inside `OnRetry` is picked up automatically:
365
+
366
+ ```ts
367
+ new HttpClient({
368
+ OnRequest: (config) => ({
369
+ ...config,
370
+ Headers: { ...config.Headers, Authorization: `Bearer ${this.getAccessToken()}` },
371
+ }),
372
+ OnRetry: async (error) => {
373
+ if (error.Status !== 401) return false;
374
+ await this.refreshAccessToken();
375
+ return true; // next attempt gets the new token
376
+ },
377
+ });
378
+ ```
379
+
380
+ ---
381
+
382
+ ## Testing against this package
383
+
384
+ Mock the module rather than the network. Two things catch people out:
385
+
386
+ 1. **`HttpClient` must be mocked with a `function`, not an arrow function** — arrows are not constructible, so `new HttpClient()` throws.
387
+ 2. Mock resolved values are `HttpResponse` shaped: `Data`, not `data`.
388
+
389
+ ```ts
390
+ const http = vi.hoisted(() => ({
391
+ instance: { Get: vi.fn(), Post: vi.fn(), Put: vi.fn(), Delete: vi.fn(), Request: vi.fn() },
392
+ standalone: { HttpGet: vi.fn(), HttpPost: vi.fn() },
393
+ }));
394
+
395
+ vi.mock('@memberjunction/network-utils', () => ({
396
+ HttpClient: vi.fn(function () { return http.instance; }), // NOT () => http.instance
397
+ HttpError: class HttpError extends Error {
398
+ Status = 0;
399
+ Data: unknown = undefined;
400
+ Headers: Record<string, string> = {};
401
+ },
402
+ IsHttpError: vi.fn((e: unknown) => typeof e === 'object' && e !== null && 'Status' in e),
403
+ ...http.standalone,
404
+ }));
405
+
406
+ // then, in a test:
407
+ http.instance.Get.mockResolvedValue({ Data: { id: 1 }, Headers: {}, Status: 200 });
408
+ ```
409
+
410
+ To test the guard itself, mock `node:dns` so a hostname resolves wherever you need it to — see `src/__tests__/SSRFGuard.test.ts` for a working harness covering rebinding, IPv4-mapped IPv6, and fail-closed behavior.
411
+
412
+ ## API Reference
413
+
414
+ ### SSRF guard
415
+
416
+ | Export | Signature | Purpose |
417
+ |---|---|---|
418
+ | `SafeFetch` | `(url: string, init?: SafeFetchInit) => Promise<Response>` | `fetch` with per-hop SSRF re-validation |
419
+ | `AssertPublicUrl` | `(url: string) => Promise<URL>` | Validate without fetching; throws `SSRFError` |
420
+ | `IsBlockedIPAddress` | `(address: string) => boolean` | Classify one IP literal; pure, no DNS |
421
+ | `SSRFError` | `class extends Error` | Thrown when a URL is blocked |
422
+ | `SafeFetchInit` | `RequestInit & { MaxRedirects?: number }` | `SafeFetch` options |
423
+
424
+ ### HTTP client
425
+
426
+ | Export | Signature | Purpose |
427
+ |---|---|---|
428
+ | `HttpRequest` | `<T>(config: HttpRequestConfig) => Promise<HttpResponse<T>>` | One request, full config |
429
+ | `HttpGet` / `HttpDelete` / `HttpHead` | `<T>(url, config?) => Promise<HttpResponse<T>>` | Method shorthands |
430
+ | `HttpPost` / `HttpPut` / `HttpPatch` | `<T>(url, body?, config?) => Promise<HttpResponse<T>>` | Method shorthands with a body |
431
+ | `SafeHttpRequest` | `<T>(config) => Promise<HttpResponse<T>>` | `HttpRequest` with `ValidateUrl` forced on |
432
+ | `HttpClient` | `class` | Configured client — replaces `axios.create(...)` |
433
+ | `HttpError` | `class extends Error` | Non-2xx, timeout, cancellation, or transport failure |
434
+ | `IsHttpError` | `(e: unknown) => e is HttpError` | Replaces `isAxiosError` |
435
+ | `IsCancellationError` | `(e: unknown) => boolean` | Replaces `isCancel` |
436
+ | `BuildQueryString` | `(query: Record<string, HttpQueryValue>) => string` | Query serialization |
437
+
438
+ ### Types
439
+
440
+ `HttpMethod`, `HttpResponseType`, `HttpQueryValue`, `HttpResponse<T>`, `HttpRequestConfig`, `HttpClientOptions`.
441
+
442
+ ## Security Considerations
443
+
444
+ - **Turn the guard on wherever the URL is caller-controlled.** It is off by default for the reasons above; that default is a convenience for fixed endpoints, not a judgement that guarding is optional.
445
+ - **Do not echo the blocked address back to the caller.** `SSRFError`'s message is deliberately non-specific. Reporting *which* address a hostname resolved to turns the guard into an internal network scanner for whoever supplied the URL.
446
+ - **Do not retry an `SSRFError`.** It is a decision, not a transient fault. The client already excludes it from `OnRetry`.
447
+ - **Denylists have an edge.** This blocks the documented private and reserved ranges. If your threat model needs certainty rather than good coverage, use an allowlist of permitted hosts instead — and note the TOCTOU limitation above.
448
+ - **The guard is not authorization.** It prevents reaching private space; it does not decide whether *this* user may fetch *that* public resource.
449
+
450
+ ## Troubleshooting
451
+
452
+ **`SSRFError: URL resolves to a private or reserved address and was blocked`**
453
+ The hostname resolved to something in a blocked range. On a developer machine this is usually intentional — `localhost` and Docker-internal hostnames are blocked by design. If the target is legitimately internal, it should not be going through the guard at all; use `HttpRequest` with `ValidateUrl` off.
454
+
455
+ **`SSRFError: Unable to resolve hostname '...'`**
456
+ The guard fails closed: a name that will not resolve is rejected rather than handed to `fetch`. Check DNS and the spelling of the host.
457
+
458
+ **`Exceeded maximum of N redirects`**
459
+ A redirect loop, or a chain longer than `MaxRedirects` (default 5). Raise it if the upstream genuinely needs more hops.
460
+
461
+ **`Request to ... timed out after 30000ms`**
462
+ The default timeout elapsed. Raise `Timeout`, or pass `0` to disable it for a genuinely long-running call.
463
+
464
+ **`HttpClient is not a constructor` in tests**
465
+ The mock used an arrow function. See [Testing against this package](#testing-against-this-package).
466
+
467
+ **A multipart upload is rejected by the upstream**
468
+ Check you are using the global `FormData`/`Blob` rather than the `form-data` npm package, and that you are not setting `Content-Type` by hand — doing so overwrites the boundary `fetch` generated.
469
+
470
+ ## Dependencies
471
+
472
+ None. This package depends on no MemberJunction package and no third-party package; it imports only `node:dns` and `node:net` from the Node standard library. That is a deliberate constraint — it is what lets the package sit at the bottom of the dependency graph and be safely consumed by anything server-side.
473
+
474
+ ## License
475
+
476
+ Business Source License 1.1 — see [LICENSE](../../LICENSE) for details.