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 +23 -0
- package/README.md +85 -30
- package/docs/architecture.md +61 -0
- package/docs/install-and-use.md +199 -0
- package/docs/integration-guide.md +135 -0
- package/docs/security.md +73 -0
- package/package.json +27 -5
- package/src/fire.js +31 -10
- package/src/index.d.ts +15 -5
- package/src/index.js +1 -0
- package/src/interpolate.js +4 -2
- package/src/network-guard.js +67 -19
- package/src/request.js +6 -4
- package/src/transport.js +153 -0
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
|
|
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
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
+
CI runs the tests on Node 18, 20 and 22.
|
|
41
96
|
|
|
42
|
-
|
|
97
|
+
### Releases
|
|
43
98
|
|
|
44
|
-
|
|
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`.
|
package/docs/security.md
ADDED
|
@@ -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.
|
|
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": [
|
|
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": [
|
|
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
|
|
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 =
|
|
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
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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';
|
package/src/interpolate.js
CHANGED
|
@@ -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
|
-
|
|
18
|
+
if (value === undefined || value === null) return '';
|
|
19
|
+
return encode ? encode(String(value)) : String(value);
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
22
|
|
package/src/network-guard.js
CHANGED
|
@@ -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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
153
|
-
if (deny.some((pattern) =>
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
37
|
-
*
|
|
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 ?? '');
|
package/src/transport.js
ADDED
|
@@ -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
|
+
}
|