varfetch 0.1.0 → 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # varfetch
2
+
3
+ ## 0.3.0
4
+
5
+ ### Security
6
+ - **DNS pinning.** `fireRequest` now connects only to the addresses the SSRF guard validated (new `pinnedFetch`, built on `node:http(s)` with a pinned `lookup`). A resolver that answers differently on the second lookup (DNS rebinding) can no longer redirect the connection to an internal address. Host header, SNI and certificate verification still use the original host name. A custom `fetch` option replaces the pinned transport and is only checked, not pinned.
7
+ - **Allow rules apply per address.** Before, one matching address was enough to allow a host, so a name resolving to `10.1.1.1` and `169.254.169.254` passed an `allow: ['10.0.0.0/8']` rule. Now every resolved address must be public or covered by an allow rule. Hostname patterns still cover all addresses of that host.
8
+ - Deny rules match any resolved address and ignore a trailing dot in the host name (`x.example.` no longer slips past `*.example`).
9
+ - More blocked ranges: documentation ranges, 6to4 relay, and the IPv6 forms that embed IPv4 (NAT64 `64:ff9b::/96`, 6to4 `2002::/16`, Teredo `2001::/32`), discard `100::/64`, documentation `2001:db8::/32`.
10
+ - URLs with embedded credentials (`http://user:pw@host`) are refused.
11
+ - The DNS lookup is bound to the request timeout; a hung resolver can no longer stall a request.
12
+ - New `encodePathVariables` option encodes variable values inserted into the path.
13
+
14
+ ### Changed
15
+ - `checkUrlAllowed` also returns the validated `addresses` when allowed.
16
+ - The default transport no longer uses the global `fetch`; it sends `Accept-Encoding: identity` and decodes gzip, deflate and br itself (size limits count decompressed bytes). Environment proxies are not used (they were not by Node's fetch either).
17
+ - Redirect response bodies are cancelled instead of left open.
18
+
19
+ ## 0.2.0
20
+ - `responseType: "binary"` / `"image"` returns raw bytes and the content type.
21
+
22
+ ## 0.1.0
23
+ - First release, extracted from the LCYT connectors plugin.
package/README.md CHANGED
@@ -1,47 +1,102 @@
1
1
  # varfetch
2
2
 
3
- Fire a configured HTTP request, with `{{variable}}` values in its path, query, headers and body, and map the JSON response onto named variables. No dependencies, ESM, Node 18+.
3
+ Fire a configured HTTP request with `{{variable}}` values in its path, query, headers and body, and map the JSON response onto named variables. Zero dependencies, ESM, Node 18+.
4
4
 
5
- It is the small, database-free core of a "connector" feature: the application stores connectors and requests however it likes (a database, a config file) and passes them to `fireRequest`.
5
+ varfetch is the small, database-free core of a "connector" feature. Your application stores connectors and requests however it likes (a database, a config file, a UI) and hands them to `fireRequest`. varfetch does the part that is easy to get wrong: building the request, refusing to be turned into an SSRF proxy, and extracting values from the answer.
6
6
 
7
7
  ```js
8
8
  import { fireRequest } from 'varfetch';
9
9
 
10
- const connector = {
11
- baseUrl: 'https://api.example.org',
12
- auth: { type: 'bearer', token: '...' }, // none | bearer | api_key | basic | custom
13
- headers: [{ key: 'Accept-Language', value: 'fi' }],
14
- };
15
-
16
- const request = {
17
- method: 'GET',
18
- path: '/api/v1/date/{{date}}',
19
- query: [{ key: 'cycles', value: 'false' }],
20
- mappings: [
21
- { jsonPath: '$.holyDay.name', variable: 'holyday' },
22
- { jsonPath: '$.holyDay.texts.gospel.reference', variable: 'gospel' },
23
- ],
24
- };
25
-
26
- const result = await fireRequest({ connector, request, variables: { date: '2026-10-11' } });
27
- // { ok: true, status: 200, values: { holyday: '...', gospel: '...' }, body: {...} }
10
+ const result = await fireRequest({
11
+ connector: {
12
+ baseUrl: 'https://api.example.org',
13
+ auth: { type: 'bearer', token: process.env.API_TOKEN },
14
+ headers: [{ key: 'Accept-Language', value: 'fi' }],
15
+ },
16
+ request: {
17
+ method: 'GET',
18
+ path: '/api/v1/date/{{date}}',
19
+ query: [{ key: 'cycles', value: 'false' }],
20
+ mappings: [
21
+ { jsonPath: '$.holyDay.name', variable: 'holyday' },
22
+ { jsonPath: '$.holyDay.texts.gospel.reference', variable: 'gospel' },
23
+ ],
24
+ },
25
+ variables: { date: '2026-10-11' },
26
+ });
27
+
28
+ if (result.ok) console.log(result.values); // { holyday: '...', gospel: '...' }
29
+ else console.error(result.error);
28
30
  ```
29
31
 
30
- ## What it does
32
+ `fireRequest` never throws: failures come back as `{ ok: false, error }`.
33
+
34
+ ## Features
35
+
36
+ | | |
37
+ |---|---|
38
+ | **Interpolation** | `{{name}}` in path, query values, header values, auth values and body. Names may contain letters (incl. ä/ö/å), digits, `_` and `-`. Missing variables render as an empty string. |
39
+ | **Auth** | `none`, `bearer`, `api_key` (any header), `basic`, `custom` headers. Values are interpolated too. |
40
+ | **JSON mapping** | `$`, `$.a.b`, `$.items[0].name`, `$['key']`. Own properties only (`$.constructor` resolves to nothing). Objects and arrays are stored as JSON text. |
41
+ | **Response types** | `auto` (JSON when the content type says so), `json`, `text`, `binary` / `image` (raw `Buffer` plus content type). |
42
+ | **SSRF guard** | Blocks loopback, private, link-local (cloud metadata), CGNAT, reserved, multicast and IPv6 forms that embed IPv4. Allow and deny rules by host, wildcard, IP or CIDR. Applied to every redirect hop. |
43
+ | **DNS pinning** | The connection goes only to the addresses the guard validated, so DNS rebinding between check and connect is impossible. See [docs/security.md](docs/security.md). |
44
+ | **Limits** | 10 s timeout, 5 MiB response (measured after decompression), at most 5 redirects. Credentials and connector headers are never sent to another origin after a redirect. |
45
+
46
+ ## Install
47
+
48
+ ```sh
49
+ npm install varfetch
50
+ ```
31
51
 
32
- - **Interpolation:** `{{name}}` in path, query values, header values, auth values and body. Missing variables become an empty string.
33
- - **JSON path:** `$`, `$.a.b`, `$.items[0].name`, `$['key']`. Own properties only.
34
- - **Mapping:** each mapping writes a string (or JSON text for objects and arrays) to a variable; `skipIfNull` (default true) leaves unresolved paths out.
35
- - **SSRF guard:** every URL, redirects included, is resolved and checked. Loopback, private, link-local, CGNAT, reserved and multicast addresses are blocked unless you allow them: `network: { allow: ['10.1.0.0/16', 'anno.internal:3000'], deny: ['*.blocked.example'] }`. `deny` wins over `allow`. The check happens before the connection and does not defend against DNS rebinding.
36
- - **Limits:** 10 s timeout and 5 MiB response by default (`timeoutMs`, `maxBytes`), at most 5 redirects, credentials and other connector headers are not forwarded to another origin.
52
+ Requires Node 18 or newer. There are no runtime dependencies. TypeScript types ship in the package.
53
+
54
+ ## Reaching private networks
55
+
56
+ By default every private and internal address is refused. To let a server call an API on its own network, allow that target explicitly:
57
+
58
+ ```js
59
+ await fireRequest({
60
+ connector, request, variables,
61
+ network: {
62
+ allow: ['anno.internal:3000', '10.1.0.0/16'],
63
+ deny: ['*.blocked.example'], // deny always wins
64
+ },
65
+ });
66
+ ```
37
67
 
38
- Variable values are put into the path as given. A value containing `/`, `?` or `#` changes the URL, so run untrusted values through `encodeURIComponent`, or use them in a query parameter, which is encoded for you.
68
+ Keep these rules in server configuration (an environment variable, for example), not in anything a user of your application can edit. See the [integration guide](docs/integration-guide.md).
69
+
70
+ ## Path variables
71
+
72
+ Variable values are put into the path as given. A value containing `/`, `?` or `#` changes the URL. If any value comes from a user, either pass `encodePathVariables: true` (each value goes through `encodeURIComponent`) or put it in a query parameter, which is always encoded.
73
+
74
+ ## API summary
75
+
76
+ - `fireRequest({ connector, request, variables, network, timeoutMs, maxBytes, encodePathVariables, fetch })` → `{ ok, status?, values, body?, contentType?, error? }`
77
+ - Building blocks: `buildRequest`, `buildAuthHeaders`, `mapResponse`, `interpolate`, `interpolatePairs`, `extractVariableNames`, `evaluateJsonPath`, `checkUrlAllowed`, `parsePattern`, `pinnedFetch`.
78
+
79
+ Full reference: [docs/install-and-use.md](docs/install-and-use.md#api-reference). Types: [`src/index.d.ts`](src/index.d.ts).
80
+
81
+ ## Documentation
82
+
83
+ - [Install and use guide](docs/install-and-use.md): data shapes, every option, recipes, troubleshooting
84
+ - [Integration guide](docs/integration-guide.md): wiring varfetch into an application, with the Saarnavideo and LCYT integrations as worked examples
85
+ - [Architecture](docs/architecture.md): modules, request lifecycle, design decisions
86
+ - [Security model](docs/security.md): threat model, SSRF guard, DNS pinning, what is not covered
87
+ - [Changelog](CHANGELOG.md)
88
+
89
+ ## Development
90
+
91
+ ```sh
92
+ npm test # node --test, no build step
93
+ ```
39
94
 
40
- ## API
95
+ CI runs the tests on Node 18, 20 and 22.
41
96
 
42
- `fireRequest({ connector, request, variables, network, fetch, timeoutMs, maxBytes })` returns `{ ok, status?, values, body?, error? }` and never throws.
97
+ ### Releases
43
98
 
44
- Building blocks: `buildRequest(connector, request, variables)`, `buildAuthHeaders`, `mapResponse(mappings, body)`, `interpolate`, `interpolatePairs`, `extractVariableNames`, `evaluateJsonPath`, `checkUrlAllowed(url, { allow, deny })`, `parsePattern`. Types are in `src/index.d.ts`.
99
+ Versioning and publishing use [Changesets](https://github.com/changesets/changesets). For a user-facing change run `npm run changeset` and commit the file it creates. On merge to `main` the Release workflow opens a "Version Packages" PR; merging it publishes to npm through [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no npm token stored). Do not edit `version` or run `npm publish` by hand. One-time setup: on npmjs.com, package `varfetch` → Settings → Trusted Publisher → GitHub Actions, repository `jsilvanus/varfetch`, workflow `release.yml`.
45
100
 
46
101
  ## License
47
102
 
@@ -0,0 +1,61 @@
1
+ # Architecture
2
+
3
+ varfetch is five small modules and a transport. It has no state, no I/O except the HTTP call itself, and no dependencies. All persistence, UI and scheduling belong to the host application.
4
+
5
+ ```
6
+ fireRequest(args) src/fire.js
7
+ │
8
+ ┌───────────────────┼───────────────────────┐
9
+ ▼ ▼ ▼
10
+ buildRequest redirect loop mapResponse
11
+ src/request.js (≤ 5 hops, per hop:) src/request.js
12
+ │ interpolate │ │
13
+ │ src/interpolate.js │ └─ evaluateJsonPath
14
+ │ ├─ checkUrlAllowed src/json-path.js
15
+ │ │ src/network-guard.js
16
+ │ │ resolve → rules → validated addresses
17
+ │ │
18
+ │ └─ pinnedFetch(url, …, { addresses })
19
+ │ src/transport.js (node:http / node:https)
20
+ ▼
21
+ { url, method, headers, body }
22
+ ```
23
+
24
+ ## Modules
25
+
26
+ | Module | Responsibility | Pure? |
27
+ |---|---|---|
28
+ | `interpolate.js` | `{{name}}` substitution, listing the names a string uses | yes |
29
+ | `json-path.js` | Minimal JSONPath subset (`$`, `.key`, `[n]`, `['key']`), own properties only | yes |
30
+ | `request.js` | `buildRequest` (URL, headers, auth, body from definitions + variables), `buildAuthHeaders`, `mapResponse` | yes |
31
+ | `network-guard.js` | Pattern parsing, default restricted ranges, DNS resolution, allow/deny decision, returns validated addresses | DNS only |
32
+ | `transport.js` | `pinnedFetch`: HTTP(S) request pinned to given addresses, returns a standard `Response` | network |
33
+ | `fire.js` | Orchestration: build, guard, send, follow redirects, enforce limits, parse, map; never throws | network |
34
+
35
+ `index.js` re-exports everything; `index.d.ts` holds the hand-written types. Both are the public API.
36
+
37
+ ## Request lifecycle
38
+
39
+ 1. **Build.** `buildRequest` interpolates the path, query values, connector headers and auth values, and the body. A bad base URL throws here; `fireRequest` turns it into `{ ok: false, error: 'Invalid request: …' }`.
40
+ 2. **Start the clock.** One `AbortSignal.timeout` covers the whole call: DNS, connect, all redirect hops and reading the body.
41
+ 3. **Guard (per hop).** `checkUrlAllowed` resolves the host and applies the rules (see [security.md](security.md)). A refusal ends the call with the reason as `error`.
42
+ 4. **Send (per hop).** `pinnedFetch` connects to the validated addresses only. A custom `fetch` replaces it.
43
+ 5. **Redirects.** `fetch`'s own redirect handling is never used. For a 3xx with `Location`, varfetch resolves the URL, drops all connector headers except `Content-Type`/`Accept` when the origin changes, turns 303 (and 301/302 after POST) into a bodyless GET, cancels the old body and loops back to step 3. More than 5 hops is an error.
44
+ 6. **Read.** The body is read with a hard byte limit (decompressed bytes). A non-2xx status is `{ ok: false, status, error: 'HTTP 404' }`.
45
+ 7. **Parse.** `binary`/`image` returns the `Buffer` and content type. Otherwise JSON is parsed when requested, or when `auto` and the content type contains `json`. A parse failure is an error.
46
+ 8. **Map.** `mapResponse` evaluates each `jsonPath` and writes strings (objects as JSON text) into `values`.
47
+
48
+ ## Design decisions
49
+
50
+ - **Definitions are plain data.** A connector is `{ baseUrl, auth, headers }` and a request is `{ method, path, query, body, mappings }`. varfetch has no storage layer so it fits a database row, a YAML file, or an object literal. The host converts its rows to these shapes (Saarnavideo: `toVarfetch`).
51
+ - **Never throws.** `fireRequest` always resolves to a result object, so callers can map failures straight onto an HTTP 502 or a UI message without try/catch.
52
+ - **The guard sits where the URL is final.** Checking at the point of connection (and per redirect hop) means the guard cannot be bypassed by a request definition, a variable value or a redirect.
53
+ - **Pinning through `lookup`, not through rewriting the URL.** Replacing the host with an IP would break TLS certificate checks and virtual hosting. A pinned `lookup` keeps the URL, Host and SNI intact and changes only where the socket goes.
54
+ - **Own transport instead of `fetch`.** Node's `fetch` (undici) offers no dependency-free hook for a per-request resolver. `node:http(s)` does, and the result is wrapped in a standard `Response` so the rest of the code (and any custom `fetch`) shares one shape.
55
+ - **Per-address allow rules.** An allow rule is a statement about addresses; evaluating it per address is the only reading that is safe when a name has several.
56
+ - **Small JSONPath.** Only what connector mappings need. No wildcards, filters or recursion: they cost parsing complexity and make mappings unpredictable.
57
+ - **Rules come from the host.** `allow`/`deny` are arguments, not environment reads, so each host decides where policy lives (env, database, per organisation). LCYT merges organisation and site rules from its database; Saarnavideo reads `CONNECTOR_ALLOW`/`CONNECTOR_DENY`.
58
+
59
+ ## Testing
60
+
61
+ `node --test test/*.test.js`. Network tests start a local `http` server on `127.0.0.1` and allow it explicitly; DNS behaviour is tested with an injected `lookup`, so no real DNS is needed. The DNS pinning tests use an unresolvable name (`rebind.invalid`) and succeed only if the connection uses the validated address.
@@ -0,0 +1,199 @@
1
+ # Install and use
2
+
3
+ ## Install
4
+
5
+ ```sh
6
+ npm install varfetch # or pnpm add / yarn add
7
+ ```
8
+
9
+ - Node 18 or newer (CI runs 18, 20 and 22).
10
+ - ESM only: `import { fireRequest } from 'varfetch'`. From CommonJS use `const { fireRequest } = await import('varfetch')`.
11
+ - No runtime dependencies. Types are included.
12
+
13
+ ## First request
14
+
15
+ ```js
16
+ import { fireRequest } from 'varfetch';
17
+
18
+ const result = await fireRequest({
19
+ connector: { baseUrl: 'https://api.example.org' },
20
+ request: {
21
+ path: '/api/v1/date/{{date}}',
22
+ mappings: [{ jsonPath: '$.holyDay.name', variable: 'holyday' }],
23
+ },
24
+ variables: { date: '2026-10-11' },
25
+ });
26
+ // { ok: true, status: 200, values: { holyday: '…' }, body: { …whole JSON… } }
27
+ ```
28
+
29
+ Two objects describe what to call, one describes the values:
30
+
31
+ - **connector**: where and as whom (`baseUrl`, `auth`, shared `headers`). Reused by many requests.
32
+ - **request**: what to call (`method`, `path`, `query`, `body`) and what to take from the answer (`mappings`).
33
+ - **variables**: a plain object whose values fill the `{{name}}` placeholders.
34
+
35
+ ## Data shapes
36
+
37
+ ### Connector
38
+
39
+ ```ts
40
+ {
41
+ baseUrl: string; // https://host[:port][/prefix]
42
+ auth?: Auth;
43
+ headers?: { key: string; value: string }[]; // sent with every request, values interpolated
44
+ }
45
+ ```
46
+
47
+ | `auth.type` | Fields | Sends |
48
+ |---|---|---|
49
+ | `none` (or no `auth`) | | nothing |
50
+ | `bearer` | `token` | `Authorization: Bearer <token>` |
51
+ | `api_key` | `headerName`, `value` | `<headerName>: <value>` |
52
+ | `basic` | `username`, `password?` | `Authorization: Basic base64(user:pass)` |
53
+ | `custom` | `headers: { name: value }` | those headers |
54
+
55
+ Auth values may contain `{{name}}`, so a token can come from `variables`. Auth headers win over connector headers with the same name.
56
+
57
+ ### Request
58
+
59
+ ```ts
60
+ {
61
+ method?: string; // default GET
62
+ path?: string; // appended to baseUrl, {{name}} allowed
63
+ query?: { key: string; value: string }[]; // values interpolated and URL-encoded
64
+ bodyType?: 'none' | 'json' | 'text'; // body is sent only for non-GET/HEAD
65
+ body?: string; // interpolated; Content-Type set from bodyType unless you set one
66
+ responseType?: 'auto' | 'json' | 'text' | 'binary' | 'image';
67
+ mappings?: { jsonPath: string; variable: string; skipIfNull?: boolean }[];
68
+ timeoutMs?: number; // overridden by fireRequest's timeoutMs
69
+ }
70
+ ```
71
+
72
+ `responseType` `auto` (default) parses JSON when the response content type contains `json`, otherwise the body is text. With `text`, `$` maps the whole text. `binary` and `image` return a `Buffer` as `body` and the `contentType`, and ignore `mappings`.
73
+
74
+ ### Result
75
+
76
+ ```ts
77
+ { ok: boolean; status?: number; values: Record<string, string | null>; body?: unknown; contentType?: string; error?: string }
78
+ ```
79
+
80
+ `values` holds one entry per mapping: strings as they are, objects and arrays as JSON text, unresolved paths left out (or `null` when `skipIfNull: false`). `error` is a short human-readable text (`HTTP 404`, `Timed out after 10000 ms`, `Blocked: target resolves to a private/internal/reserved address`, …). It never contains a stack trace or your secrets.
81
+
82
+ ## Variables
83
+
84
+ - `{{name}}`: letters (including ä/ö/å), digits, `_` and `-`; whitespace inside the braces is fine (`{{ name }}`).
85
+ - Missing or `null` variables become an empty string. Use `extractVariableNames(text)` to list what a string needs, for example to show input fields.
86
+ - **Path values are inserted raw.** `{{d}}` = `../x?y` changes the URL. When the value is not under your control, set `encodePathVariables: true` or use a query parameter.
87
+
88
+ ```js
89
+ await fireRequest({ connector, request, variables: { user: name }, encodePathVariables: true });
90
+ ```
91
+
92
+ ## JSON paths
93
+
94
+ | Path | Meaning |
95
+ |---|---|
96
+ | `$` | the whole body |
97
+ | `$.a.b` | nested keys |
98
+ | `$.items[0].name` | array index |
99
+ | `$['weird key']`, `$["k"]` | quoted key |
100
+
101
+ No wildcards, filters or `..`. Only own properties resolve, so `$.constructor` and `$.__proto__` give nothing.
102
+
103
+ ## Options of `fireRequest`
104
+
105
+ | Option | Default | |
106
+ |---|---|---|
107
+ | `network` | `{}` | `{ allow: string[], deny: string[] }`, see below |
108
+ | `timeoutMs` | request's `timeoutMs`, else 10000 | covers DNS, connect, redirects and body |
109
+ | `maxBytes` | 5 MiB | response limit, after decompression |
110
+ | `encodePathVariables` | `false` | `encodeURIComponent` on values inserted into the path |
111
+ | `fetch` | pinned transport | custom `fetch`; disables DNS pinning (see [security](security.md)) |
112
+
113
+ Fixed: at most 5 redirects; connector headers are not forwarded to another origin.
114
+
115
+ ## Network rules
116
+
117
+ ```js
118
+ network: {
119
+ allow: ['10.1.0.0/16', 'anno.internal:3000', '[fd00::]/8', '*.corp.example'],
120
+ deny: ['10.1.9.9', '*.blocked.example'],
121
+ }
122
+ ```
123
+
124
+ - Without rules, only public addresses are reachable.
125
+ - `allow` opens specific private targets. Each resolved address must be public or covered by an allow rule; a host name rule covers all addresses of that host.
126
+ - `deny` wins over everything and is also useful to block public hosts.
127
+ - A pattern without a port matches any port. Host names are case-insensitive; `*.example.com` also matches `example.com`.
128
+
129
+ To see why a URL is refused:
130
+
131
+ ```js
132
+ import { checkUrlAllowed } from 'varfetch';
133
+ await checkUrlAllowed(new URL('http://10.0.0.5:3000/'), { allow: [] });
134
+ // { allowed: false, reason: 'Blocked: target resolves to a private/internal/reserved address' }
135
+ ```
136
+
137
+ ## Recipes
138
+
139
+ **POST with a JSON body**
140
+
141
+ ```js
142
+ request: {
143
+ method: 'POST', path: '/v1/notes', bodyType: 'json',
144
+ body: '{"title": "{{title}}"}', // values are inserted as text: escape JSON yourself if they may contain quotes
145
+ mappings: [{ jsonPath: '$.id', variable: 'noteId' }],
146
+ }
147
+ ```
148
+
149
+ If a value can contain quotes or newlines, build the body with `JSON.stringify` in your code and pass it as a variable: `body: '{{payload}}'` with `variables: { payload: JSON.stringify({ title }) }`.
150
+
151
+ **Download an image**
152
+
153
+ ```js
154
+ const r = await fireRequest({ connector, request: { path: '/logo.png', responseType: 'image' }, network });
155
+ if (r.ok) fs.writeFileSync('logo.png', r.body); // r.contentType is e.g. 'image/png'
156
+ ```
157
+
158
+ **Preview before applying.** `buildRequest(connector, request, variables)` returns `{ url, method, headers, body }` without sending anything; show it to the user, or log it with the headers masked.
159
+
160
+ **Test your own code.** Pass `fetch` to avoid the network:
161
+
162
+ ```js
163
+ const fakeFetch = async () => new Response(JSON.stringify({ a: 1 }), { headers: { 'content-type': 'application/json' } });
164
+ await fireRequest({ connector: { baseUrl: 'https://api.example.org' }, request, fetch: fakeFetch, network: { allow: [] } });
165
+ ```
166
+
167
+ (The guard still resolves the host. For a fully offline test use a literal public IP in `baseUrl`, or a local server allowed through `network.allow`.)
168
+
169
+ ## API reference
170
+
171
+ | Export | Signature |
172
+ |---|---|
173
+ | `fireRequest(args)` | `Promise<FireResult>`, never rejects |
174
+ | `buildRequest(connector, request, variables?, { encodePathVariables }?)` | `{ url: URL, method, headers, body? }`; throws on an invalid base URL |
175
+ | `buildAuthHeaders(auth, variables?)` | `Record<string, string>` |
176
+ | `mapResponse(mappings, body)` | `Record<string, string \| null>` |
177
+ | `interpolate(text, variables?, encode?)` | `string` |
178
+ | `interpolatePairs(pairs, variables?)` | `{ key, value }[]` |
179
+ | `extractVariableNames(text)` | `string[]` |
180
+ | `evaluateJsonPath(data, path)` | value or `undefined` |
181
+ | `checkUrlAllowed(url, { allow, deny, lookup?, signal? })` | `{ allowed, reason?, addresses? }` |
182
+ | `parsePattern(pattern)` | `{ kind: 'host' \| 'ip' \| 'cidr', value, port }` |
183
+ | `pinnedFetch(url, init, { addresses })` | `Promise<Response>` connecting only to `addresses` |
184
+
185
+ ## Troubleshooting
186
+
187
+ | `error` | Cause and fix |
188
+ |---|---|
189
+ | `Blocked: target resolves to a private/internal/reserved address` | The target is on a private network. Add an `allow` rule if it is intended. |
190
+ | `Blocked by network policy` | A `deny` rule matched. |
191
+ | `Could not resolve host: …` | DNS failure, or the name does not exist. |
192
+ | `Invalid request: Invalid URL` | `baseUrl` or the interpolated path does not form a URL. |
193
+ | `Timed out after N ms` | Raise `timeoutMs`, or the remote is slow. |
194
+ | `Response larger than N bytes` | Raise `maxBytes` or fetch a smaller resource. |
195
+ | `Response is not valid JSON` | `responseType` is `json` (or auto and the server says JSON) but the body is not. |
196
+ | `HTTP 4xx/5xx` | The remote refused; `status` has the code. |
197
+ | `Too many redirects` | More than five hops or a loop. |
198
+ | `Credentials in the URL are not supported` | Remove `user:pass@` from `baseUrl`; use `auth`. |
199
+ | `values` is empty although `ok` is true | The JSON paths did not resolve. Inspect `result.body`. |
@@ -0,0 +1,135 @@
1
+ # Integration guide
2
+
3
+ How to wire varfetch into an application. The pattern is the same everywhere: store connectors and requests, convert a stored request to varfetch's plain shapes, fire it with server-owned network rules, and store the resulting values as variables.
4
+
5
+ ```
6
+ UI / API ──► stored connector + request ──► toVarfetch() ──► fireRequest() ──► values ──► variables
7
+ (your database) ▲
8
+ network rules from server config
9
+ ```
10
+
11
+ ## 1. Decide where things live
12
+
13
+ | Concern | Put it | Why |
14
+ |---|---|---|
15
+ | Connector and request definitions | Your database or config, editable by authorised users | They are data |
16
+ | Secrets (tokens, passwords) | Same store, encrypted; return only `hasSecret` to clients | Never send them back to a browser |
17
+ | **Network `allow`/`deny` rules** | **Server configuration** (env, or admin-only settings) | A user who can edit connectors must not be able to widen what the server may reach |
18
+ | Variables | Wherever your app keeps project/session values | varfetch only produces the values |
19
+
20
+ ## 2. Model the data
21
+
22
+ Anything that converts to these shapes works:
23
+
24
+ ```ts
25
+ interface Connector { baseUrl: string; auth?: Auth; headers?: { key: string; value: string }[] }
26
+ interface RequestDef { method?; path?; query?; bodyType?; body?; responseType?; mappings?: { jsonPath; variable; skipIfNull? }[]; timeoutMs? }
27
+ ```
28
+
29
+ Validate on the way in (a Zod schema, JSON Schema): `baseUrl` must be `http(s)`, variable names must be valid (`[\p{L}_][\p{L}\p{N}_-]*`), header names must be tokens. Validate again when converting a row: JSON columns can hold anything.
30
+
31
+ ## 3. A minimal Express integration
32
+
33
+ ```js
34
+ import express from 'express';
35
+ import { fireRequest } from 'varfetch';
36
+
37
+ const network = {
38
+ allow: (process.env.CONNECTOR_ALLOW ?? '').split(',').map((s) => s.trim()).filter(Boolean),
39
+ deny: (process.env.CONNECTOR_DENY ?? '').split(',').map((s) => s.trim()).filter(Boolean),
40
+ };
41
+
42
+ const app = express();
43
+ app.use(express.json());
44
+
45
+ app.post('/connectors/:id/requests/:rid/fire', async (req, res) => {
46
+ const { connector, request } = await loadFromDatabase(req.params.id, req.params.rid); // your code
47
+ const result = await fireRequest({
48
+ connector,
49
+ request,
50
+ variables: req.body.variables ?? {},
51
+ network,
52
+ encodePathVariables: true, // values come from the client
53
+ });
54
+ if (!result.ok) return res.status(502).json({ error: result.error, status: result.status });
55
+ res.json({ values: result.values });
56
+ });
57
+ ```
58
+
59
+ Notes:
60
+
61
+ - Answer upstream failures with **502** (and the remote `status` when present); keep your own 4xx for bad input.
62
+ - Do not return `result.body` to the client unless you want the whole remote answer exposed; `values` is the designed output.
63
+ - Never log `connector.auth`. If you log requests, use `buildRequest` and mask `Authorization` and any api-key header.
64
+
65
+ ## 4. Turning values into variables
66
+
67
+ `values` is `Record<string, string | null>`. Typical flow ("fetch variables" button):
68
+
69
+ 1. Fire the request with the current variables as input (the request path may use them, e.g. `{{date}}`).
70
+ 2. Show the user old versus new values.
71
+ 3. Save only what they accept.
72
+
73
+ Do not write `values` straight into state that triggers other work (renders, publications) without a confirmation step unless the connector is fully trusted. A remote server controls those strings.
74
+
75
+ ## 5. Worked example: Saarnavideo
76
+
77
+ Saarnavideo uses varfetch for user-defined API variables, with the church-year service anno-api as the main use case. Nothing about that service is built in.
78
+
79
+ - **Storage.** Prisma models `ApiConnector` and `ApiRequest` (global, both schemas). The connector secret is stored but only `hasSecret` is returned.
80
+ - **Conversion.** `toVarfetch(connectorRow, requestRow)` (`src/domain/connectors.ts`) validates JSON columns with Zod (`asPairs`, `asAuth`, `mappingSchema`) and returns `{ connector, request }`.
81
+ - **Network rules.** `networkRulesFromEnv()` reads `CONNECTOR_ALLOW` and `CONNECTOR_DENY` (comma-separated patterns). The app has no login, so rules are environment-only. To use an anno-api on a private network, run Saarnavideo with, for example, `CONNECTOR_ALLOW=anno.internal:3000` or `CONNECTOR_ALLOW=10.1.0.0/16`.
82
+ - **Firing.** `runStoredRequest` (`src/app/api/_lib/connectors.ts`) loads the request, calls `fireRequest({ connector, request, variables, network })`, and answers 502 with `fireErrorMessage(result)` on failure.
83
+ - **Variables.** `POST /api/projects/[id]/fetch-variables` fires a request with the project's variables (service date default: next Sunday as `paiva`) and the Lähde step's `FetchVariables` shows old versus new values before `saveVariables` stores the accepted ones.
84
+ - **Preset.** "Lisää kirkkovuosipohja" adds a request preset for an anno-api style day endpoint (`/api/v1/date/{{paiva}}`) with mappings to ASCII variable names (`pyhapaiva`, `teema`, `evankeliumi`, `evankeliumiteksti`, `vari`, `jakso`, `aika`) to a connector whose address the user typed.
85
+
86
+ Hardening worth doing in a login-less app: `paiva` goes into the path, so set `encodePathVariables: true` in `runStoredRequest`, and add a login before exposing the app beyond a trusted network.
87
+
88
+ ## 6. Worked example: LCYT
89
+
90
+ `lcyt-connectors` uses varfetch for the same job inside a multi-tenant server and layers its own policy on top:
91
+
92
+ - **Policy from the database.** Site-wide (admin) and per-organisation rules are loaded and merged into one `{ allow, deny }`: org deny and site deny become `deny`, org and site allow become `allow`. `fireRequest({ network })` then enforces them at every hop.
93
+ - **Layered error messages.** Before firing, LCYT calls `checkUrlAllowed` once per deny layer with a memoised `lookup`, so the error can say whether the organisation or the site policy blocked the request. The `lookup` option accepts a function returning one result or an array.
94
+ - **Binary responses.** `responseType: 'image'` returns the bytes and `contentType`; LCYT stores them as an asset instead of mapping text.
95
+ - **Events.** Values written to variables emit an event so the UI updates live.
96
+
97
+ ## 7. Policy recipes
98
+
99
+ | Goal | Rules |
100
+ |---|---|
101
+ | Public APIs only | no rules |
102
+ | One internal service | `allow: ['anno.internal:3000']` |
103
+ | Internal subnet, but not the database host | `allow: ['10.1.0.0/16']`, `deny: ['10.1.0.5']` |
104
+ | Public APIs except one vendor | `deny: ['*.vendor.example']` |
105
+ | Only specific public hosts (allow-list mode) | varfetch has no "deny all" rule: enforce it yourself by rejecting connectors whose host is not on your list before calling `fireRequest` |
106
+ | Local development against `localhost` | `allow: ['127.0.0.1:3000']` (name `localhost` resolves to a loopback address, so `allow: ['localhost:3000']` works too) |
107
+
108
+ ## 8. Operations checklist
109
+
110
+ - [ ] Network rules come from server config, not from user-editable data
111
+ - [ ] Secrets encrypted at rest, never returned or logged
112
+ - [ ] Upstream failures answered as 502; no stack traces to clients
113
+ - [ ] `encodePathVariables: true` whenever a variable value comes from a user
114
+ - [ ] Authorisation on who can create and edit connectors and fire requests
115
+ - [ ] Rate limiting on the fire endpoint
116
+ - [ ] Mapped values escaped when rendered
117
+ - [ ] No custom `fetch` in production unless you enforce the network policy elsewhere (it disables DNS pinning)
118
+ - [ ] Behind a corporate proxy: see [security.md](security.md#when-pinning-does-not-apply)
119
+
120
+ ## 9. Testing an integration
121
+
122
+ Run a local `http` server, allow it by address, and fire real requests:
123
+
124
+ ```js
125
+ const server = http.createServer((req, res) => { res.setHeader('content-type', 'application/json'); res.end('{"name":"Sunday"}'); });
126
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
127
+ const port = server.address().port;
128
+ const result = await fireRequest({
129
+ connector: { baseUrl: `http://127.0.0.1:${port}` },
130
+ request: { path: '/', mappings: [{ jsonPath: '$.name', variable: 'n' }] },
131
+ network: { allow: [`127.0.0.1:${port}`] },
132
+ });
133
+ ```
134
+
135
+ To assert that your app refuses private targets, fire at `http://127.0.0.1:<port>` with no `allow` and expect `ok: false`.
@@ -0,0 +1,73 @@
1
+ # Security model
2
+
3
+ varfetch exists so that an application can let someone configure an outbound HTTP request without handing that person a way to attack the server's own network. This page states what it defends against, how, and what it does not cover.
4
+
5
+ ## Threat model
6
+
7
+ The **operator** runs the application and decides which networks it may reach. The **configurer** is whoever edits connectors and requests (an admin, an organisation owner, or, in an app without login, anyone who can open the page). The **remote server** is whatever the URL points at, and it may be hostile or compromised.
8
+
9
+ | Threat | Defence |
10
+ |---|---|
11
+ | Configurer points a connector at an internal service or the cloud metadata endpoint (`169.254.169.254`) | Default-deny of private, loopback, link-local and reserved addresses |
12
+ | A public-looking name that resolves to a private address | The check runs on the resolved addresses, not the name |
13
+ | DNS rebinding: the name answers with a public address for the check and a private one for the connection | **DNS pinning**: the connection uses the validated addresses only |
14
+ | Remote server redirects to an internal address | Redirects are followed by varfetch and every hop is checked and pinned |
15
+ | Remote server redirects to another origin to harvest credentials | Connector headers (including auth) are dropped on a cross-origin redirect |
16
+ | Huge or endless response | 5 MiB limit on decompressed bytes, 10 s timeout (both configurable) |
17
+ | Compression bomb | The limit counts bytes after decoding |
18
+ | Variable value alters the request path (`../`, `?`, `#`) | `encodePathVariables`, or put the value in a query parameter |
19
+ | Prototype access through the JSON path (`$.__proto__`) | Own properties only |
20
+ | Non-HTTP schemes (`file:`, `ftp:`) | Rejected |
21
+ | `http://user:pw@host` URLs | Rejected, use the `auth` setting |
22
+ | Hung DNS resolver | The lookup is bound to the request timeout |
23
+
24
+ ## The SSRF guard
25
+
26
+ `checkUrlAllowed(url, { allow, deny })` decides per URL. Order of evaluation:
27
+
28
+ 1. Scheme other than `http`/`https` → blocked.
29
+ 2. Credentials in the URL → blocked.
30
+ 3. The host is resolved (`dns.lookup`, all addresses).
31
+ 4. A `deny` pattern matches **any** resolved address → blocked.
32
+ 5. For **each** resolved address: it must be covered by an `allow` pattern or not be in a restricted range; otherwise the whole URL is blocked.
33
+ 6. Otherwise allowed, and the validated `addresses` are returned.
34
+
35
+ Step 5 is per address on purpose. A host that resolves to one allowed and one restricted address is refused, because the connection could use either one. A *hostname* allow pattern (`anno.internal`) covers all addresses of that host, because you named the host as trusted.
36
+
37
+ Blocked by default:
38
+
39
+ - IPv4: `0.0.0.0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`, `192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`, `198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`
40
+ - IPv6: `::1`, `::`, `fc00::/7`, `fe80::/10`, `ff00::/8`, `100::/64`, `2001::/32` (Teredo), `2001:db8::/32`, `2002::/16` (6to4), `64:ff9b::/96` (NAT64)
41
+ - IPv4-mapped IPv6 (`::ffff:a.b.c.d`) is checked as the IPv4 address
42
+
43
+ Pattern syntax: exact host (`api.example.com`), wildcard (`*.example.com`, also matches `example.com`), IP (`10.1.2.3`), CIDR (`10.0.0.0/8`, `[fc00::]/7`), each with an optional port (`10.1.2.3:3000`, `[::1]:11434`). No port means any port. Host names are compared in lower case without a trailing dot.
44
+
45
+ ## DNS pinning
46
+
47
+ A plain "resolve, check, then `fetch`" has a gap: `fetch` resolves the name again. An attacker who controls the DNS zone returns a public address with TTL 0 for the check and `127.0.0.1` for the connection.
48
+
49
+ varfetch closes the gap in `pinnedFetch` (`src/transport.js`). It calls `node:http` or `node:https` with a `lookup` function that returns the addresses `checkUrlAllowed` validated, so the operating system's resolver is never consulted for the connection. Everything else stays as if the name had been used: the URL, the `Host` header, the TLS server name (SNI) and certificate verification (the certificate must be valid for the host name, not for the IP).
50
+
51
+ Details:
52
+
53
+ - One DNS lookup per hop. If the resolver returns several addresses, Node's normal connection fallback applies, but only among validated addresses.
54
+ - No connection pooling (`agent: false`), so a reused socket cannot carry an old validation to a new request.
55
+ - The same pinning applies to every redirect hop.
56
+ - IP literals in the URL need no lookup and are checked directly.
57
+
58
+ ### When pinning does not apply
59
+
60
+ If you pass your own `fetch` to `fireRequest`, that function resolves the host itself. The guard still checks the URL (and every redirect), but the rebinding window is back. Use a custom `fetch` for tests, or when a corporate proxy is required (the proxy then does the resolving, so enforce the policy there). Do not use it to "add logging" in production without keeping this in mind.
61
+
62
+ ## What varfetch does not protect
63
+
64
+ - **The remote server's content.** Response values are strings from a server you chose to call. If you render them in HTML, escape them; if you put them into another request, treat them as untrusted.
65
+ - **Secrets at rest.** Auth tokens in a connector object are plain strings. Store them encrypted, never return them to a browser, and scrub them from error messages. (Saarnavideo stores the secret but only returns `hasSecret`.)
66
+ - **Who may configure connectors.** If anyone can create a connector, anyone can make your server call the allowed targets. Decide your own authorisation. In particular, keep `allow` rules in server configuration, so the people who configure connectors cannot widen what the server may reach.
67
+ - **Request volume.** There is no rate limiting. A connector can be used to send many requests to an allowed target.
68
+ - **TOCTOU on allowed internal targets.** An allowed internal host is trusted by definition; pinning prevents the address from changing, not the service behind it from misbehaving.
69
+ - **Proxies.** `HTTP_PROXY` and friends are ignored by the default transport. In a proxy-only network, use a custom `fetch` and enforce policy at the proxy.
70
+
71
+ ## Reporting a vulnerability
72
+
73
+ Open a private security advisory on the GitHub repository (Security tab), or email the maintainer listed in `package.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "varfetch",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fire configured HTTP requests with {{variable}} interpolation, an SSRF guard and JSON path mapping of the response onto named variables.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,11 +11,25 @@
11
11
  "default": "./src/index.js"
12
12
  }
13
13
  },
14
- "files": ["src", "LICENSE", "README.md"],
14
+ "files": [
15
+ "src",
16
+ "docs",
17
+ "LICENSE",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
15
21
  "scripts": {
16
- "test": "node --test test/*.test.js"
22
+ "test": "node --test test/*.test.js",
23
+ "changeset": "changeset"
17
24
  },
18
- "keywords": ["http", "variables", "api", "connector", "ssrf", "jsonpath"],
25
+ "keywords": [
26
+ "http",
27
+ "variables",
28
+ "api",
29
+ "connector",
30
+ "ssrf",
31
+ "jsonpath"
32
+ ],
19
33
  "author": "Juha Itäleino <jsilvanus@gmail.com>",
20
34
  "license": "EUPL-1.2",
21
35
  "engines": {
@@ -28,5 +42,13 @@
28
42
  "bugs": {
29
43
  "url": "https://github.com/jsilvanus/varfetch/issues"
30
44
  },
31
- "homepage": "https://github.com/jsilvanus/varfetch#readme"
45
+ "homepage": "https://github.com/jsilvanus/varfetch#readme",
46
+ "devDependencies": {
47
+ "@changesets/cli": "^2.31.1",
48
+ "@types/node": "^22.20.5"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ }
32
54
  }
package/src/fire.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { buildRequest, mapResponse } from './request.js';
2
2
  import { checkUrlAllowed } from './network-guard.js';
3
+ import { pinnedFetch } from './transport.js';
3
4
 
4
5
  const MAX_REDIRECTS = 5;
5
6
  const DEFAULT_TIMEOUT_MS = 10_000;
@@ -9,22 +10,26 @@ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
9
10
  * Send a request and map the response onto variables.
10
11
  *
11
12
  * Every URL is checked with the SSRF guard, redirects included (they are
12
- * followed here, up to 5, instead of by fetch).
13
+ * followed here, up to 5, instead of by fetch). Unless a custom `fetch` is
14
+ * given, the connection goes only to the addresses the guard validated
15
+ * (`pinnedFetch`), so DNS rebinding between check and connect is not possible.
16
+ * A custom `fetch` resolves the host itself and is therefore checked, not pinned.
13
17
  *
14
18
  * @param {object} args
15
19
  * @param {import('./index.js').Connector} args.connector
16
20
  * @param {import('./index.js').RequestDef} args.request
17
21
  * @param {Record<string, unknown>} [args.variables] values for {{name}}
18
22
  * @param {{ allow?: string[], deny?: string[] }} [args.network] extra guard patterns, see network-guard.js
19
- * @param {typeof fetch} [args.fetch]
23
+ * @param {typeof fetch} [args.fetch] replaces the pinned transport (tests, proxies); DNS pinning does not apply to it
24
+ * @param {boolean} [args.encodePathVariables] run variable values in the path through encodeURIComponent
20
25
  * @param {number} [args.timeoutMs] default 10000
21
26
  * @param {number} [args.maxBytes] response size limit, default 5 MiB
22
27
  * @returns {Promise<import('./index.js').FireResult>}
23
28
  */
24
- export async function fireRequest({ connector, request, variables = {}, network = {}, fetch: fetchImpl = fetch, timeoutMs, maxBytes = DEFAULT_MAX_BYTES }) {
29
+ export async function fireRequest({ connector, request, variables = {}, network = {}, fetch: fetchImpl, timeoutMs, maxBytes = DEFAULT_MAX_BYTES, encodePathVariables = false }) {
25
30
  let built;
26
31
  try {
27
- built = buildRequest(connector, request, variables);
32
+ built = buildRequest(connector, request, variables, { encodePathVariables });
28
33
  } catch (err) {
29
34
  return { ok: false, values: {}, error: `Invalid request: ${err.message}` };
30
35
  }
@@ -40,12 +45,15 @@ export async function fireRequest({ connector, request, variables = {}, network
40
45
  let response;
41
46
 
42
47
  for (let hop = 0; ; hop++) {
43
- const guard = await checkUrlAllowed(url, network);
48
+ const guard = await checkUrlAllowed(url, { ...network, signal });
44
49
  if (!guard.allowed) return { ok: false, values: {}, error: guard.reason };
45
50
 
46
- response = await fetchImpl(url, { method, headers, body, redirect: 'manual', signal });
51
+ response = fetchImpl
52
+ ? await fetchImpl(url, { method, headers, body, redirect: 'manual', signal })
53
+ : await pinnedFetch(url, { method, headers, body, signal }, { addresses: guard.addresses });
47
54
  const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null;
48
55
  if (!location) break;
56
+ await discardBody(response);
49
57
  if (hop >= MAX_REDIRECTS) return { ok: false, values: {}, status: response.status, error: 'Too many redirects' };
50
58
 
51
59
  const next = new URL(location, url);
@@ -60,10 +68,14 @@ export async function fireRequest({ connector, request, variables = {}, network
60
68
  url = next;
61
69
  }
62
70
 
63
- const text = await readLimited(response, maxBytes);
71
+ const bytes = await readLimited(response, maxBytes);
64
72
  if (!response.ok) return { ok: false, status: response.status, values: {}, error: `HTTP ${response.status}` };
65
73
 
66
74
  const contentType = response.headers.get('content-type') || '';
75
+ if (request.responseType === 'binary' || request.responseType === 'image') {
76
+ return { ok: true, status: response.status, values: {}, body: bytes, contentType };
77
+ }
78
+ const text = bytes.toString('utf8');
67
79
  const wantsJson = request.responseType === 'json' || ((request.responseType ?? 'auto') === 'auto' && /json/i.test(contentType));
68
80
  let parsed = text;
69
81
  if (wantsJson) {
@@ -75,7 +87,7 @@ export async function fireRequest({ connector, request, variables = {}, network
75
87
  }
76
88
  return { ok: true, status: response.status, values: mapResponse(request.mappings, parsed), body: parsed };
77
89
  } catch (err) {
78
- const message = err?.name === 'TimeoutError' ? `Timed out after ${timeout} ms` : err?.message ?? String(err);
90
+ const message = err?.name === 'TimeoutError' || (err?.name === 'AbortError' && signal.aborted) ? `Timed out after ${timeout} ms` : err?.message ?? String(err);
79
91
  return { ok: false, values: {}, error: message };
80
92
  }
81
93
  }
@@ -83,7 +95,7 @@ export async function fireRequest({ connector, request, variables = {}, network
83
95
  async function readLimited(response, maxBytes) {
84
96
  const declared = Number(response.headers.get('content-length'));
85
97
  if (declared > maxBytes) throw new Error(`Response larger than ${maxBytes} bytes`);
86
- if (!response.body) return '';
98
+ if (!response.body) return Buffer.alloc(0);
87
99
  const reader = response.body.getReader();
88
100
  const chunks = [];
89
101
  let size = 0;
@@ -97,5 +109,14 @@ async function readLimited(response, maxBytes) {
97
109
  }
98
110
  chunks.push(value);
99
111
  }
100
- return Buffer.concat(chunks).toString('utf8');
112
+ return Buffer.concat(chunks);
113
+ }
114
+
115
+ /** Release a redirect response's connection. Older Node versions throw on cancelling an already closed stream. */
116
+ async function discardBody(response) {
117
+ try {
118
+ await response.body?.cancel();
119
+ } catch {
120
+ // The body is already finished: nothing to release.
121
+ }
101
122
  }
package/src/index.d.ts CHANGED
@@ -31,7 +31,8 @@ export interface RequestDef {
31
31
  query?: Pair[];
32
32
  bodyType?: 'none' | 'json' | 'text';
33
33
  body?: string;
34
- responseType?: 'auto' | 'json' | 'text';
34
+ /** `binary` and `image` return the raw bytes as `body` (a Buffer) and skip `mappings`. */
35
+ responseType?: 'auto' | 'json' | 'text' | 'binary' | 'image';
35
36
  mappings?: Mapping[];
36
37
  timeoutMs?: number;
37
38
  }
@@ -43,25 +44,33 @@ export interface FireResult {
43
44
  status?: number;
44
45
  /** Variable name to value; non-string values are JSON text. */
45
46
  values: Record<string, string | null>;
46
- /** Parsed JSON, or the text for a non-JSON response. */
47
+ /** Parsed JSON, the text for a non-JSON response, or a Buffer for a `binary`/`image` request. */
47
48
  body?: unknown;
49
+ /** Response content-type, set for a `binary`/`image` request. */
50
+ contentType?: string;
48
51
  error?: string;
49
52
  }
50
53
 
51
- export function interpolate(text: string, variables?: Variables): string;
54
+ export function interpolate(text: string, variables?: Variables, encode?: (value: string) => string): string;
52
55
  export function interpolatePairs(pairs: Pair[] | undefined, variables?: Variables): Pair[];
53
56
  export function extractVariableNames(text: string): string[];
54
57
  export function evaluateJsonPath(data: unknown, path: string): unknown;
55
58
  export function parsePattern(pattern: string): { kind: 'host' | 'ip' | 'cidr'; value: string; port: number | null };
56
59
  export function checkUrlAllowed(
57
60
  url: URL,
58
- options?: NetworkRules & { lookup?: (host: string, opts: { all: true; verbatim: true }) => Promise<Array<{ address: string; family: number }>> },
59
- ): Promise<{ allowed: boolean; reason?: string }>;
61
+ options?: NetworkRules & { signal?: AbortSignal; lookup?: (host: string, opts: { all: true; verbatim: true }) => Promise<Array<{ address: string; family: number }>> },
62
+ ): Promise<{ allowed: boolean; reason?: string; addresses?: Array<{ address: string; family: number }> }>;
63
+ export function pinnedFetch(
64
+ url: URL,
65
+ init: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
66
+ pin: { addresses: Array<{ address: string; family: number }> },
67
+ ): Promise<Response>;
60
68
  export function buildAuthHeaders(auth: Auth | undefined, variables?: Variables): Record<string, string>;
61
69
  export function buildRequest(
62
70
  connector: Connector,
63
71
  request: RequestDef,
64
72
  variables?: Variables,
73
+ options?: { encodePathVariables?: boolean },
65
74
  ): { url: URL; method: string; headers: Record<string, string>; body?: string };
66
75
  export function mapResponse(mappings: Mapping[] | undefined, body: unknown): Record<string, string | null>;
67
76
  export function fireRequest(args: {
@@ -72,4 +81,5 @@ export function fireRequest(args: {
72
81
  fetch?: typeof fetch;
73
82
  timeoutMs?: number;
74
83
  maxBytes?: number;
84
+ encodePathVariables?: boolean;
75
85
  }): Promise<FireResult>;
package/src/index.js CHANGED
@@ -3,3 +3,4 @@ export { evaluateJsonPath } from './json-path.js';
3
3
  export { checkUrlAllowed, parsePattern } from './network-guard.js';
4
4
  export { buildRequest, buildAuthHeaders, mapResponse } from './request.js';
5
5
  export { fireRequest } from './fire.js';
6
+ export { pinnedFetch } from './transport.js';
@@ -9,12 +9,14 @@ const VAR_RE = /\{\{\s*([\p{L}_][\p{L}\p{N}_-]*)\s*\}\}/gu;
9
9
  * A missing variable renders as an empty string.
10
10
  * @param {string} text
11
11
  * @param {Record<string, unknown>} [variables]
12
+ * @param {(value: string) => string} [encode] applied to each inserted value
12
13
  */
13
- export function interpolate(text, variables) {
14
+ export function interpolate(text, variables, encode) {
14
15
  if (typeof text !== 'string' || !text.includes('{{')) return text;
15
16
  return text.replace(VAR_RE, (_match, name) => {
16
17
  const value = variables?.[name];
17
- return value === undefined || value === null ? '' : String(value);
18
+ if (value === undefined || value === null) return '';
19
+ return encode ? encode(String(value)) : String(value);
18
20
  });
19
21
  }
20
22
 
@@ -24,8 +24,15 @@
24
24
  * - bracketed IPv6 + port "[::1]:11434"
25
25
  * A pattern without a port matches that host or IP on any port.
26
26
  *
27
- * The addresses are resolved once, before the fetch. This does not defend
28
- * against DNS rebinding between the check and the connection.
27
+ * Allow rules are applied per resolved address: an `allow` CIDR or IP only
28
+ * exempts the addresses it matches, so a name that resolves to one allowed and
29
+ * one restricted address is blocked. A hostname pattern exempts every address
30
+ * of that host.
31
+ *
32
+ * `checkUrlAllowed` returns the addresses it validated (`addresses`). To close
33
+ * the DNS rebinding gap (a second lookup at connect time returning a different
34
+ * address) the caller must connect to exactly those addresses: `fireRequest`
35
+ * does this through `pinnedFetch` (src/transport.js).
29
36
  */
30
37
  import { BlockList, isIP } from 'node:net';
31
38
  import { lookup as dnsLookup } from 'node:dns/promises';
@@ -38,8 +45,12 @@ const DEFAULT_BLOCKED_V4 = [
38
45
  ['169.254.0.0', 16], // link-local — includes cloud metadata (169.254.169.254)
39
46
  ['172.16.0.0', 12], // private
40
47
  ['192.0.0.0', 24], // IETF protocol assignments
48
+ ['192.0.2.0', 24], // documentation
49
+ ['192.88.99.0', 24], // 6to4 relay anycast
41
50
  ['192.168.0.0', 16], // private
42
51
  ['198.18.0.0', 15], // benchmarking
52
+ ['198.51.100.0', 24], // documentation
53
+ ['203.0.113.0', 24], // documentation
43
54
  ['224.0.0.0', 4], // multicast
44
55
  ['240.0.0.0', 4], // reserved
45
56
  ];
@@ -49,6 +60,11 @@ const DEFAULT_BLOCKED_V6 = [
49
60
  ['fc00::', 7], // unique local (ULA)
50
61
  ['fe80::', 10], // link-local
51
62
  ['ff00::', 8], // multicast
63
+ ['100::', 64], // discard-only
64
+ ['2001::', 32], // Teredo (embeds an IPv4 address)
65
+ ['2001:db8::', 32], // documentation
66
+ ['2002::', 16], // 6to4 (embeds an IPv4 address)
67
+ ['64:ff9b::', 96], // NAT64 (embeds an IPv4 address)
52
68
  // No explicit IPv4-mapped (::ffff:x.x.x.x) rule needed: net.BlockList
53
69
  // already matches a mapped address checked as 'ipv6' against the
54
70
  // corresponding plain-IPv4 rule above checked as 'ipv4' — adding an
@@ -105,12 +121,13 @@ function hostMatches(pattern, hostname) {
105
121
  return pattern === hostname;
106
122
  }
107
123
 
108
- function ruleMatches(pattern, { hostname, addresses, port }) {
124
+ /** Does `pattern` cover this one resolved address of the host? */
125
+ function ruleMatchesAddress(pattern, { hostname, port }, address) {
109
126
  const parsed = parsePattern(pattern);
110
127
  if (parsed.port != null && parsed.port !== port) return false;
111
128
 
112
129
  if (parsed.kind === 'host') return hostMatches(parsed.value, hostname);
113
- if (parsed.kind === 'ip') return addresses.some((a) => a.address === parsed.value);
130
+ if (parsed.kind === 'ip') return sameAddress(address.address, parsed.value);
114
131
 
115
132
  // CIDR
116
133
  try {
@@ -119,7 +136,19 @@ function ruleMatches(pattern, { hostname, addresses, port }) {
119
136
  const family = isIP(net) === 6 ? 'ipv6' : 'ipv4';
120
137
  const bl = new BlockList();
121
138
  bl.addSubnet(net, prefix, family);
122
- return addresses.some((a) => bl.check(a.address, a.family === 6 ? 'ipv6' : 'ipv4'));
139
+ return bl.check(address.address, address.family === 6 ? 'ipv6' : 'ipv4');
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ function sameAddress(a, b) {
146
+ if (a === b) return true;
147
+ // Compare through BlockList so "::1" and "0:0:0:0:0:0:0:1" are the same address.
148
+ try {
149
+ const bl = new BlockList();
150
+ bl.addAddress(b, isIP(b) === 6 ? 'ipv6' : 'ipv4');
151
+ return bl.check(a, isIP(a) === 6 ? 'ipv6' : 'ipv4');
123
152
  } catch {
124
153
  return false;
125
154
  }
@@ -127,37 +156,56 @@ function ruleMatches(pattern, { hostname, addresses, port }) {
127
156
 
128
157
  /**
129
158
  * @param {URL} url
130
- * @param {{ allow?: string[], deny?: string[], lookup?: typeof dnsLookup }} [options]
131
- * @returns {Promise<{ allowed: boolean, reason?: string }>}
159
+ * @param {{ allow?: string[], deny?: string[], lookup?: typeof dnsLookup, signal?: AbortSignal }} [options]
160
+ * @returns {Promise<{ allowed: boolean, reason?: string, addresses?: Array<{ address: string, family: number }> }>}
161
+ * `addresses` (when allowed) are the validated addresses to connect to.
132
162
  */
133
- export async function checkUrlAllowed(url, { allow = [], deny = [], lookup = dnsLookup } = {}) {
163
+ export async function checkUrlAllowed(url, { allow = [], deny = [], lookup = dnsLookup, signal } = {}) {
134
164
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
135
165
  return { allowed: false, reason: `Unsupported protocol: ${url.protocol}` };
136
166
  }
167
+ if (url.username || url.password) {
168
+ return { allowed: false, reason: 'Credentials in the URL are not supported' };
169
+ }
137
170
 
138
171
  // WHATWG URL keeps brackets around an IPv6 literal in .hostname ("[::1]").
139
- const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
172
+ // A trailing dot ("localhost.") names the same host, so it is dropped for matching.
173
+ const hostname = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
140
174
  const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
141
175
 
142
176
  let addresses;
143
177
  try {
144
- addresses = await lookup(hostname, { all: true, verbatim: true });
145
- } catch {
178
+ addresses = await raceAbort(lookup(hostname, { all: true, verbatim: true }), signal);
179
+ } catch (err) {
180
+ if (signal?.aborted) throw err;
146
181
  addresses = [];
147
182
  }
183
+ if (!Array.isArray(addresses)) addresses = [addresses];
148
184
  if (addresses.length === 0) {
149
185
  return { allowed: false, reason: `Could not resolve host: ${hostname}` };
150
186
  }
151
187
 
152
- const match = { hostname, addresses, port };
153
- if (deny.some((pattern) => ruleMatches(pattern, match))) {
188
+ const match = { hostname, port };
189
+ if (deny.some((pattern) => addresses.some((a) => ruleMatchesAddress(pattern, match, a)))) {
154
190
  return { allowed: false, reason: 'Blocked by network policy' };
155
191
  }
156
- if (allow.some((pattern) => ruleMatches(pattern, match))) {
157
- return { allowed: true };
158
- }
159
- if (addresses.some((a) => isDefaultRestricted(a.address, a.family))) {
160
- return { allowed: false, reason: 'Blocked: target resolves to a private/internal/reserved address' };
192
+ // Every address must be allowed by a rule or be public: pinning connects to any of them.
193
+ for (const a of addresses) {
194
+ if (allow.some((pattern) => ruleMatchesAddress(pattern, match, a))) continue;
195
+ if (isDefaultRestricted(a.address, a.family)) {
196
+ return { allowed: false, reason: 'Blocked: target resolves to a private/internal/reserved address' };
197
+ }
161
198
  }
162
- return { allowed: true };
199
+ return { allowed: true, addresses };
200
+ }
201
+
202
+ /** Reject as soon as `signal` aborts, so a hung DNS lookup cannot outlive the request timeout. */
203
+ function raceAbort(promise, signal) {
204
+ if (!signal) return promise;
205
+ if (signal.aborted) return Promise.reject(signal.reason);
206
+ return new Promise((resolve, reject) => {
207
+ const onAbort = () => reject(signal.reason);
208
+ signal.addEventListener('abort', onAbort, { once: true });
209
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
210
+ });
163
211
  }
package/src/request.js CHANGED
@@ -33,16 +33,18 @@ export function buildAuthHeaders(auth, variables) {
33
33
  * Path, query, headers and body are interpolated with `variables`.
34
34
  *
35
35
  * Variable values are put into the path as given, so a value containing `/`,
36
- * `?` or `#` changes the URL. Pass untrusted values through `encodeURIComponent`
37
- * yourself, or write `{{name}}` into a query parameter, which is encoded for you.
36
+ * `?` or `#` changes the URL. For untrusted values set `encodePathVariables`
37
+ * (each value goes through `encodeURIComponent`), or write `{{name}}` into a
38
+ * query parameter, which is always encoded.
38
39
  *
39
40
  * @param {import('./index.js').Connector} connector
40
41
  * @param {import('./index.js').RequestDef} request
41
42
  * @param {Record<string, unknown>} [variables]
43
+ * @param {{ encodePathVariables?: boolean }} [options]
42
44
  */
43
- export function buildRequest(connector, request, variables = {}) {
45
+ export function buildRequest(connector, request, variables = {}, { encodePathVariables = false } = {}) {
44
46
  const base = connector.baseUrl.replace(/\/+$/, '');
45
- const path = interpolate(request.path || '', variables);
47
+ const path = interpolate(request.path || '', variables, encodePathVariables ? encodeURIComponent : undefined);
46
48
  const url = new URL(base + (path.startsWith('/') ? path : `/${path}`));
47
49
  for (const { key, value } of interpolatePairs(request.query, variables)) {
48
50
  if (key) url.searchParams.append(key, value ?? '');
@@ -0,0 +1,153 @@
1
+ /**
2
+ * HTTP transport that connects only to addresses the SSRF guard has validated.
3
+ *
4
+ * `fetch` resolves the host name itself, so a check done before the call can be
5
+ * defeated by DNS rebinding: the name answers with a public address for the
6
+ * check and a private one for the connection. `pinnedFetch` closes that gap by
7
+ * giving node:http(s) a `lookup` that returns the already validated addresses.
8
+ * The URL, the `Host` header, the TLS server name (SNI) and certificate
9
+ * verification still use the original host name, so nothing else changes.
10
+ *
11
+ * It returns a standard `Response`. Proxies from the environment are not used
12
+ * (neither does Node's built-in fetch).
13
+ */
14
+ import http from 'node:http';
15
+ import https from 'node:https';
16
+ import zlib from 'node:zlib';
17
+
18
+ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
19
+
20
+ /**
21
+ * @param {URL} url
22
+ * @param {{ method?: string, headers?: Record<string, string>, body?: string, signal?: AbortSignal }} init
23
+ * @param {{ addresses: Array<{ address: string, family: number }> }} pin validated addresses to connect to
24
+ * @returns {Promise<Response>}
25
+ */
26
+ export function pinnedFetch(url, { method = 'GET', headers = {}, body, signal } = {}, { addresses }) {
27
+ if (!addresses?.length) return Promise.reject(new Error('No validated address to connect to'));
28
+ const lib = url.protocol === 'https:' ? https : http;
29
+
30
+ const lookup = (_hostname, options, callback) => {
31
+ if (typeof options === 'function') callback = options;
32
+ const wanted = typeof options === 'object' && options?.family ? Number(options.family) : 0;
33
+ const usable = addresses.filter((a) => !wanted || a.family === wanted);
34
+ if (usable.length === 0) {
35
+ callback(Object.assign(new Error('No validated address for the requested IP family'), { code: 'ENOTFOUND' }));
36
+ } else if (typeof options === 'object' && options?.all) {
37
+ callback(null, usable.map(({ address, family }) => ({ address, family })));
38
+ } else {
39
+ callback(null, usable[0].address, usable[0].family);
40
+ }
41
+ };
42
+
43
+ const requestHeaders = { 'accept-encoding': 'identity', ...lowerCaseKeys(headers) };
44
+ if (body !== undefined) requestHeaders['content-length'] = String(Buffer.byteLength(body));
45
+
46
+ return new Promise((resolve, reject) => {
47
+ const hostname = url.hostname.replace(/^\[|\]$/g, '');
48
+ const req = lib.request(
49
+ {
50
+ protocol: url.protocol,
51
+ hostname,
52
+ port: url.port || undefined,
53
+ path: `${url.pathname}${url.search}`,
54
+ method,
55
+ headers: requestHeaders,
56
+ lookup,
57
+ agent: false, // no pooling: a reused socket may be pinned to another validation
58
+ signal,
59
+ },
60
+ (res) => {
61
+ try {
62
+ resolve(toResponse(res));
63
+ } catch (err) {
64
+ res.destroy();
65
+ reject(err);
66
+ }
67
+ },
68
+ );
69
+ req.on('error', reject);
70
+ req.end(body);
71
+ });
72
+ }
73
+
74
+ function lowerCaseKeys(headers) {
75
+ return Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
76
+ }
77
+
78
+ function toResponse(res) {
79
+ const responseHeaders = new Headers();
80
+ for (let i = 0; i < res.rawHeaders.length; i += 2) {
81
+ try {
82
+ responseHeaders.append(res.rawHeaders[i], res.rawHeaders[i + 1]);
83
+ } catch {
84
+ // A header the Headers class refuses is not needed by callers.
85
+ }
86
+ }
87
+
88
+ let stream = res;
89
+ const encoding = (res.headers['content-encoding'] || '').toLowerCase().trim();
90
+ if (encoding && encoding !== 'identity') {
91
+ const decoder = decoderFor(encoding);
92
+ if (!decoder) throw new Error(`Unsupported content-encoding: ${encoding}`);
93
+ stream = res.pipe(decoder);
94
+ res.on('error', (err) => decoder.destroy(err));
95
+ responseHeaders.delete('content-encoding');
96
+ responseHeaders.delete('content-length'); // the compressed length no longer applies
97
+ }
98
+
99
+ const status = res.statusCode;
100
+ if (NULL_BODY_STATUS.has(status)) {
101
+ stream.resume();
102
+ return new Response(null, { status, headers: responseHeaders });
103
+ }
104
+ return new Response(toWebStream(stream), { status, headers: responseHeaders });
105
+ }
106
+
107
+ function decoderFor(encoding) {
108
+ switch (encoding) {
109
+ case 'gzip':
110
+ case 'x-gzip':
111
+ return zlib.createGunzip();
112
+ case 'deflate':
113
+ return zlib.createInflate();
114
+ case 'br':
115
+ return zlib.createBrotliDecompress();
116
+ default:
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Node stream -> web ReadableStream with backpressure. Readable.toWeb is avoided on purpose:
123
+ * on Node 18 it closes the controller twice for a response that ends before it is read.
124
+ */
125
+ function toWebStream(stream) {
126
+ let finished = false;
127
+ return new ReadableStream({
128
+ start(controller) {
129
+ stream.on('data', (chunk) => {
130
+ if (finished) return;
131
+ controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
132
+ if (controller.desiredSize <= 0) stream.pause();
133
+ });
134
+ stream.on('end', () => {
135
+ if (finished) return;
136
+ finished = true;
137
+ controller.close();
138
+ });
139
+ stream.on('error', (err) => {
140
+ if (finished) return;
141
+ finished = true;
142
+ controller.error(err);
143
+ });
144
+ },
145
+ pull() {
146
+ stream.resume();
147
+ },
148
+ cancel() {
149
+ finished = true;
150
+ stream.destroy();
151
+ },
152
+ });
153
+ }