ssrf-lab 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +407 -2
- package/dist/adapters/agentClient.d.ts +21 -0
- package/dist/adapters/agentClient.js +78 -0
- package/dist/adapters/agentClient.js.map +1 -0
- package/dist/adapters/checkThenConnect.d.ts +12 -0
- package/dist/adapters/checkThenConnect.js +94 -0
- package/dist/adapters/checkThenConnect.js.map +1 -0
- package/dist/adapters/guardedFetch.d.ts +13 -0
- package/dist/adapters/guardedFetch.js +110 -0
- package/dist/adapters/guardedFetch.js.map +1 -0
- package/dist/adapters/internal.d.ts +30 -0
- package/dist/adapters/internal.js +95 -0
- package/dist/adapters/internal.js.map +1 -0
- package/dist/adapters/libraries.d.ts +14 -0
- package/dist/adapters/libraries.js +47 -0
- package/dist/adapters/libraries.js.map +1 -0
- package/dist/adapters/optional.d.ts +37 -0
- package/dist/adapters/optional.js +84 -0
- package/dist/adapters/optional.js.map +1 -0
- package/dist/adapters/plainFetch.d.ts +9 -0
- package/dist/adapters/plainFetch.js +65 -0
- package/dist/adapters/plainFetch.js.map +1 -0
- package/dist/adapters/types.d.ts +55 -0
- package/dist/adapters/types.js +17 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/adapters/vercelGuardedFetch.d.ts +7 -0
- package/dist/adapters/vercelGuardedFetch.js +65 -0
- package/dist/adapters/vercelGuardedFetch.js.map +1 -0
- package/dist/checker/probes.d.ts +34 -0
- package/dist/checker/probes.js +124 -0
- package/dist/checker/probes.js.map +1 -0
- package/dist/checker/report.d.ts +5 -0
- package/dist/checker/report.js +26 -0
- package/dist/checker/report.js.map +1 -0
- package/dist/checker/run.d.ts +27 -0
- package/dist/checker/run.js +108 -0
- package/dist/checker/run.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +120 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/range/bomb.d.ts +13 -0
- package/dist/range/bomb.js +24 -0
- package/dist/range/bomb.js.map +1 -0
- package/dist/range/canaries.d.ts +12 -0
- package/dist/range/canaries.js +13 -0
- package/dist/range/canaries.js.map +1 -0
- package/dist/range/isolation.d.ts +4 -0
- package/dist/range/isolation.js +24 -0
- package/dist/range/isolation.js.map +1 -0
- package/dist/range/server.d.ts +51 -0
- package/dist/range/server.js +190 -0
- package/dist/range/server.js.map +1 -0
- package/dist/util/addresses.d.ts +21 -0
- package/dist/util/addresses.js +143 -0
- package/dist/util/addresses.js.map +1 -0
- package/dist/util/flipLookup.d.ts +11 -0
- package/dist/util/flipLookup.js +32 -0
- package/dist/util/flipLookup.js.map +1 -0
- package/package.json +57 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roshan Amble
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,408 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ssrf-lab
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**A failed HTTP request is not proof that your SSRF defense worked.**
|
|
4
|
+
|
|
5
|
+
ssrf-lab tests the Node.js fetcher behind your link previews, URL imports and
|
|
6
|
+
webhook destinations. It tells an explicit refusal apart from a request that
|
|
7
|
+
failed for some other reason, using a canary and an independent hit counter on a
|
|
8
|
+
separate internal service. If that service saw the request, a later error cannot
|
|
9
|
+
make the result `BLOCKED`.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
On the cloud-metadata probe below, both clients' requests failed. Only one of
|
|
14
|
+
those failures was a defense:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
node-fetch (global, defaults)
|
|
18
|
+
[!] REACHED Redirect into loopback the internal service recorded a request, independent of the returned body
|
|
19
|
+
[~] LUCKY Redirect into cloud metadata no policy; the request failed with fetch failed <- ENETUNREACH
|
|
20
|
+
|
|
21
|
+
guarded client (this repo)
|
|
22
|
+
[+] BLOCKED Redirect into loopback forbidden-address: 127.0.0.1 is loopback
|
|
23
|
+
[+] BLOCKED Redirect into cloud metadata forbidden-address: 169.254.169.254 is link-local
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`LUCKY` means nothing refused the request; something else failed first. On a
|
|
27
|
+
network where the metadata endpoint is reachable, that request lands.
|
|
28
|
+
Excerpt from the [full run](#measured-output).
|
|
29
|
+
|
|
30
|
+
**10 main SSRF/fetch-safety probes · 7 separate direct-address cases · MIT · Node.js 22.12+**
|
|
31
|
+
|
|
32
|
+
## Try it
|
|
33
|
+
|
|
34
|
+
| You want to | Start here |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| See it work, with no local install or secrets | [Create a repository from the starter](https://github.com/roshan-amble/ssrf-lab-starter/generate) and run its Node 22/24 workflow from Actions ([recorded passing and failing runs](https://github.com/roshan-amble/ssrf-lab-starter/tree/main/verification)) |
|
|
37
|
+
| Test your own fetcher | The [custom-fetcher example](examples/custom-fetcher/README.md): an application module, a deliberately unsafe control and a copyable isolated CI workflow (Node.js 22.19+) |
|
|
38
|
+
| Catch regressions in CI | The [reusable GitHub Action](action/README.md) · [step-by-step tutorial](docs/tutorials/node-fetcher-github-actions.md) · [48-second recording](docs/media/ssrf-lab-walkthrough.mp4) ([raw recording and reports](docs/evidence/2026-10-16-action/README.md)) |
|
|
39
|
+
| Run it locally | The [quick start](#quick-start) below |
|
|
40
|
+
|
|
41
|
+
## What it found
|
|
42
|
+
|
|
43
|
+
- **`ssrf-agent` 1.0.5**, the latest release of a package whose stated purpose is
|
|
44
|
+
to prevent SSRF, lets `0.0.0.0` reach loopback, and on macOS also
|
|
45
|
+
`[::ffff:127.0.0.1]`. See the [finding, root cause and disclosure timeline](docs/findings/2026-09-13-ssrf-agent-bypass.md).
|
|
46
|
+
- **`@tak-ps/node-safeurl`** has a reproduced redirect-conformance bug, with a
|
|
47
|
+
[tested fix](patches/node-safeurl/README.md) submitted as
|
|
48
|
+
[PR #4](https://github.com/dfpc-coe/node-safeurl/pull/4).
|
|
49
|
+
- Recorded trials of [guarded-fetch, linkpeek and Linkwarden](#tested-against)
|
|
50
|
+
on Node 22 and 24.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
With Node.js 22.12 or later, compare the three built-in clients against the
|
|
55
|
+
lab's own loopback service:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx ssrf-lab@0.1.0 scan --client all --suite direct # add --json for machine-readable output
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This direct-address suite needs no accounts, API keys or external targets.
|
|
62
|
+
It compares an unprotected fetcher, a check-then-connect client and the lab's
|
|
63
|
+
guarded client. The npm package does not install the third-party packages it
|
|
64
|
+
compares.
|
|
65
|
+
|
|
66
|
+
To reproduce the pinned npm-package comparisons or work on the harness, build
|
|
67
|
+
from a clone. Its development dependencies include the comparison packages:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/roshan-amble/ssrf-lab.git
|
|
71
|
+
cd ssrf-lab
|
|
72
|
+
npm ci
|
|
73
|
+
npm run build
|
|
74
|
+
node dist/cli.js scan --client libs --suite direct # add --json for machine-readable output
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use an authorized disposable environment; `0.0.0.0` and IPv4-mapped IPv6
|
|
78
|
+
behavior can vary by operating system. **The redirect suite also attempts
|
|
79
|
+
non-loopback addresses. Run the commands below only with network isolation:**
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# All 10 main probes against the 3 built-in clients
|
|
83
|
+
node dist/cli.js scan --client all
|
|
84
|
+
|
|
85
|
+
# Both suites, nonzero exit on REACHED
|
|
86
|
+
node dist/cli.js scan --client guarded --suite all --fail-on-reach
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`--fail-on-reach` exits 1 for an observed `REACHED`. It does not require every
|
|
90
|
+
other result to be `BLOCKED` and is not a security certification.
|
|
91
|
+
|
|
92
|
+
For the full tests and redirect scans, CI uses a fresh Linux network namespace
|
|
93
|
+
with only loopback enabled. This prevents deliberately unsafe clients from
|
|
94
|
+
contacting real metadata services or other networks. See
|
|
95
|
+
[release verification](docs/RELEASE_VERIFICATION.md) and the
|
|
96
|
+
[workflow](.github/workflows/ci.yml) for the exact commands and saved logs.
|
|
97
|
+
Standalone npm installations that select a third-party client print the exact
|
|
98
|
+
commands to install the required comparison packages when they are missing.
|
|
99
|
+
|
|
100
|
+
## What it is
|
|
101
|
+
|
|
102
|
+
Most SSRF material shows 1 payload against 1 endpoint. Production is harder in a
|
|
103
|
+
different way: a fetcher has several controls, and a log cannot tell you whether
|
|
104
|
+
a request was **refused by a control** or simply **failed on its own**. Those
|
|
105
|
+
look identical until the day the network changes and the attack lands.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
your client under test
|
|
109
|
+
│
|
|
110
|
+
▼
|
|
111
|
+
┌──────────────────────────┐ ┌───────────────────────────┐
|
|
112
|
+
│ PUBLIC range │ 302 │ INTERNAL service │
|
|
113
|
+
│ (stands for the │ ─────▶ │ answers a canary string │
|
|
114
|
+
│ internet; the client │ │ counts every hit │
|
|
115
|
+
│ is allowed to fetch it)│ │ nothing may reach it │
|
|
116
|
+
│ │ └───────────────────────────┘
|
|
117
|
+
│ redirects: loopback, │
|
|
118
|
+
│ decimal-IP, metadata, │ ┌───────────────────────────┐
|
|
119
|
+
│ reserved, credentials, │ ─────▶ │ 169.254.169.254 │
|
|
120
|
+
│ endless loop │ │ 198.51.100.1 │
|
|
121
|
+
│ payloads: gzip bomb, │ │ block external egress! │
|
|
122
|
+
│ endless body, 1 B/s │ └───────────────────────────┘
|
|
123
|
+
└──────────────────────────┘
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Two servers, not one, because a single origin cannot tell a control from an
|
|
127
|
+
accident: the client is **allowed** to fetch the public range, so anything that
|
|
128
|
+
arrives at the internal service arrived by attack.
|
|
129
|
+
|
|
130
|
+
## Tested against
|
|
131
|
+
|
|
132
|
+
Recorded library and application-source trials on Node 22 and 24. Each trial
|
|
133
|
+
requires a successful allowed request and a working unsafe control.
|
|
134
|
+
|
|
135
|
+
| Client | Reproduce and inspect | Measured result |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| Vercel Labs' `guarded-fetch` 0.1.6 candidate build | [CI recipe](integrations/guarded-fetch/README.md) · [Raw reports](docs/evidence/2026-10-19-guarded-fetch-source/README.md) | 9 explicit refusals; 0 internal hits |
|
|
138
|
+
| `linkpeek` 2.1.5 public `preview()` API | [Trial recipe](integrations/linkpeek/README.md) · [Raw reports](docs/evidence/2026-10-19-linkpeek/README.md) | 8 address/redirect refusals + separate credential rejection; 0 internal hits |
|
|
139
|
+
| Linkwarden's shared `safeFetch()` at `952ac45` | [Application fetch recipe](integrations/linkwarden/README.md) · [Raw reports](docs/evidence/2026-10-20-linkwarden/README.md) | 9 explicit refusals; 0 internal hits |
|
|
140
|
+
|
|
141
|
+
These are tests prepared and run for ssrf-lab, not evidence of maintainer
|
|
142
|
+
adoption or endorsements. The [guarded-fetch PR](https://github.com/vercel-labs/guarded-fetch/pull/12),
|
|
143
|
+
the [linkpeek trial discussion](https://github.com/thegruber/linkpeek/discussions/39#discussioncomment-19046880)
|
|
144
|
+
and the Linkwarden [test-coverage proposal](https://github.com/linkwarden/linkwarden/issues/1869)
|
|
145
|
+
await maintainer feedback; the Linkwarden coverage is limited to the shared fetch
|
|
146
|
+
function. DNS rebinding and TLS are outside these trials. linkpeek's separate
|
|
147
|
+
development-dependency audit failure is recorded with its evidence.
|
|
148
|
+
|
|
149
|
+
## Additional pinned integrations
|
|
150
|
+
|
|
151
|
+
The harness also exercises Vercel Labs' `guarded-fetch@0.1.5` and the published
|
|
152
|
+
outbound networking component used by `n8n@2.43.5`. The n8n integration covers
|
|
153
|
+
its request and fetch APIs with SSRF protection both enabled and disabled.
|
|
154
|
+
It is a component test, not coverage of every n8n workflow node.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Local direct-address cases for guarded-fetch
|
|
158
|
+
node dist/cli.js scan --client vercel-guarded --suite direct --json
|
|
159
|
+
|
|
160
|
+
# Optional n8n component integration, installed separately
|
|
161
|
+
npm ci --ignore-scripts --prefix integrations/n8n
|
|
162
|
+
node integrations/n8n/verify.mjs --direct-only
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Their redirect tests require an entry address that the clients actually accept.
|
|
166
|
+
CI assigns that address to loopback inside an isolated network namespace,
|
|
167
|
+
verifies successful entry requests, and records both servers' hit counters.
|
|
168
|
+
Ordinary loopback entry tests are marked `N/A` for these adapters.
|
|
169
|
+
See [integration scope and reproduction](docs/INTEGRATIONS.md) for the exact
|
|
170
|
+
configuration, limitations, and per-commit evidence artifacts.
|
|
171
|
+
|
|
172
|
+
An [optional additional group](integrations/community/README.md) compares
|
|
173
|
+
`ssrf-safe-fetch@0.1.0`, `ssrf-fetch@0.1.0`, and
|
|
174
|
+
`@tak-ps/node-safeurl@1.8.0` with the same independent hit counters. It also
|
|
175
|
+
includes a reproduced redirect-conformance bug and a
|
|
176
|
+
[tested node-safeurl patch](patches/node-safeurl/README.md), submitted as
|
|
177
|
+
[upstream PR #4](https://github.com/dfpc-coe/node-safeurl/pull/4) and awaiting
|
|
178
|
+
maintainer review.
|
|
179
|
+
|
|
180
|
+
## Findings
|
|
181
|
+
|
|
182
|
+
The direct suite sends a request to a loopback service using 7 address forms,
|
|
183
|
+
through npm packages whose stated purpose is to stop SSRF. These are the
|
|
184
|
+
published finding's measured results on macOS, with exact versions:
|
|
185
|
+
|
|
186
|
+
| Package | Version | Result |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| [request-filtering-agent](https://www.npmjs.com/package/request-filtering-agent) | 3.2.1 | 7 of 7 blocked |
|
|
189
|
+
| [ssrf-req-filter](https://www.npmjs.com/package/ssrf-req-filter) | 1.1.1 | 7 of 7 blocked |
|
|
190
|
+
| [ssrf-agent](https://www.npmjs.com/package/ssrf-agent) | 1.0.5 | **5 of 7.** `0.0.0.0` and `[::ffff:127.0.0.1]` reached the internal service |
|
|
191
|
+
|
|
192
|
+
On a Linux host without IPv6, the mapped case fails with `EAFNOSUPPORT` and
|
|
193
|
+
reports `PREVENTED` instead; `0.0.0.0` still reaches the internal service.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
node dist/cli.js scan --client libs --suite direct
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
[](docs/evidence/2026-10-12-7f8f15f/README.md)
|
|
200
|
+
|
|
201
|
+
Actual output from isolated CI at [`7f8f15f`](https://github.com/roshan-amble/ssrf-lab/commit/7f8f15f4f3f068c40e3f3c2a0f69a02eff67f684).
|
|
202
|
+
[Read the terminal output](docs/evidence/2026-10-12-7f8f15f/node22/demo.txt)
|
|
203
|
+
· [Inspect the evidence and original recording](docs/evidence/2026-10-12-7f8f15f/README.md)
|
|
204
|
+
· [Verify a release and inspect real CI output](docs/RELEASE_VERIFICATION.md)
|
|
205
|
+
|
|
206
|
+
Details, root cause, suggested fix, and disclosure timeline:
|
|
207
|
+
[docs/findings/2026-09-13-ssrf-agent-bypass.md](docs/findings/2026-09-13-ssrf-agent-bypass.md).
|
|
208
|
+
The CVE request remains a request unless and until an assignment is confirmed.
|
|
209
|
+
|
|
210
|
+
This is a reproduction of a known address-classification weakness, not a claim
|
|
211
|
+
of a new attack class. See the related [`ip` advisory, CVE-2024-29415](https://github.com/advisories/GHSA-2p57-rm9w-gvfp).
|
|
212
|
+
The earlier [CVE-2021-23718](https://nvd.nist.gov/vuln/detail/CVE-2021-23718)
|
|
213
|
+
covers `ssrf-agent` versions **before** 1.0.5; the local results above test 1.0.5.
|
|
214
|
+
|
|
215
|
+
## The verdicts, and why there are 5
|
|
216
|
+
|
|
217
|
+
| Verdict | Meaning |
|
|
218
|
+
| --- | --- |
|
|
219
|
+
| `BLOCKED` | The client named a refusing control, with no contradictory measured internal hit. Evidence for this probe, not proof of complete SSRF protection. |
|
|
220
|
+
| `PREVENTED` | No internal request was observed during the probe, but no control was named. Measured with the internal service's hit counter, not inferred solely from an error. |
|
|
221
|
+
| `REACHED` | An internal request was observed, the response proved reach, or a fetch-safety limit was exceeded. A later refusal does not erase a measured hit. |
|
|
222
|
+
| `LUCKY` | No refusing control or observed successful prevention was established. A network failure alone is not evidence of a security boundary. |
|
|
223
|
+
| `N/A` | The adapter lacks a required hook or the selected entry fixture cannot exercise the probe. It does not establish whether the underlying client has its own equivalent protection. |
|
|
224
|
+
|
|
225
|
+
`LUCKY` is the reason this tool exists. A metadata redirect that fails on a
|
|
226
|
+
network without a reachable metadata endpoint is not evidence that the client
|
|
227
|
+
blocks that destination. Reachability and provider-side requirements vary.
|
|
228
|
+
|
|
229
|
+
## The probes
|
|
230
|
+
|
|
231
|
+
| Probe | The attack | The control it tests |
|
|
232
|
+
| --- | --- | --- |
|
|
233
|
+
| Redirect into loopback | `302` to the internal service | Judge every hop, not only the first URL |
|
|
234
|
+
| Redirect into loopback, as a decimal | `302` to `http://2130706433:PORT/` | Classify the parsed host, never the raw string |
|
|
235
|
+
| Redirect that carries credentials | `302` to `http://user:pass@127.0.0.1/` | Refuse the address, and never echo the credentials |
|
|
236
|
+
| Redirect into cloud metadata | `302` to `169.254.169.254` | Refuse link-local addresses |
|
|
237
|
+
| Redirect into TEST-NET-2 | `302` to `198.51.100.1` | Refuse reserved documentation addresses; this is not an RFC 1918 test |
|
|
238
|
+
| Endless redirect chain | `302` to itself | Cap the number of hops |
|
|
239
|
+
| Decompression bomb | 64 KiB on the wire, 64 MiB expanded | Count bytes after decompression, while reading |
|
|
240
|
+
| Body with no end and no length | chunked, never finishes | Cap received bytes with a running total |
|
|
241
|
+
| One byte per second | a response that stays open for hours | One deadline over the whole request |
|
|
242
|
+
| DNS rebinding (simulated) | first answer public, every later answer loopback | Judge the address the socket uses |
|
|
243
|
+
|
|
244
|
+
The separate direct suite exercises 7 local address forms. These are not 17
|
|
245
|
+
independent security guarantees; several cases test related behavior.
|
|
246
|
+
|
|
247
|
+
## The 4 controls in the guarded client
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
// 1. every hop is judged, not only the entry URL
|
|
251
|
+
for (const hop of redirects) refuseForbiddenLiteral(hop)
|
|
252
|
+
|
|
253
|
+
// 2. the address is judged where the socket connects, and pinned
|
|
254
|
+
const lookup = guardedLookup(base) // a mixed answer is refused whole
|
|
255
|
+
|
|
256
|
+
// 3. the cap runs WHILE reading, and counts decompressed bytes
|
|
257
|
+
if ((bytes += chunk.length) > maxBytes) throw new PolicyRefusal('body-too-large', …)
|
|
258
|
+
|
|
259
|
+
// 4. one deadline covers the request AND the body read
|
|
260
|
+
const deadline = AbortSignal.timeout(timeoutMs)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A socket inactivity timeout is not a whole-request deadline: a slow target can
|
|
264
|
+
keep the socket active without finishing the response.
|
|
265
|
+
|
|
266
|
+
## Use it on your own client
|
|
267
|
+
|
|
268
|
+
Start with the [runnable fetch-compatible example](examples/custom-fetcher/README.md)
|
|
269
|
+
and its [GitHub Actions workflow](examples/custom-fetcher/ssrf-check.yml).
|
|
270
|
+
They include real request measurement, positive and negative controls, explicit
|
|
271
|
+
error mapping, cleanup and exit codes. Replace one application module to test
|
|
272
|
+
your existing fetcher.
|
|
273
|
+
|
|
274
|
+
For clients that need the lower-level adapter interface:
|
|
275
|
+
|
|
276
|
+
After building this repository, import `./dist/index.js` from an example file at
|
|
277
|
+
the repository root. The adapter shape is illustrated below; replace its `fetch`
|
|
278
|
+
body with your own client's implementation.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import { startAttackRange, runScan, renderTable } from './dist/index.js'
|
|
282
|
+
|
|
283
|
+
const range = await startAttackRange()
|
|
284
|
+
|
|
285
|
+
const myClient = {
|
|
286
|
+
name: 'my-fetcher',
|
|
287
|
+
supportsConnectHook: false,
|
|
288
|
+
async fetch(url, options) {
|
|
289
|
+
/* call your fetcher, return { status, bytesRead, bodySample, hops, stoppedByHarness } */
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Full scan: use a network-isolated environment.
|
|
295
|
+
console.log(renderTable(await runScan(myClient, range)))
|
|
296
|
+
} finally {
|
|
297
|
+
await range.close()
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Throw `PolicyRefusal(reason, detail)` from your adapter when your client refuses
|
|
302
|
+
on purpose. The checker gives the internal hit counter priority over that claim.
|
|
303
|
+
Finish or cancel the adapter's network work before settling `fetch`, and do not
|
|
304
|
+
share a range between concurrent scans. The counter measures sequential probes;
|
|
305
|
+
it is not a per-request correlation system.
|
|
306
|
+
|
|
307
|
+
## Measured output
|
|
308
|
+
|
|
309
|
+
A full `scan --client all` at [`d6156ea`](https://github.com/roshan-amble/ssrf-lab/commit/d6156ea),
|
|
310
|
+
run on Linux with Node.js 22.22 in a network namespace with only loopback enabled.
|
|
311
|
+
Network failures and deadlines vary by environment: here the rebinding probe
|
|
312
|
+
reports `PREVENTED` because the namespace had no simulated public address. CI
|
|
313
|
+
records each commit's environment, tests, compiled CLI results and a
|
|
314
|
+
failing-client negative control as downloadable artifacts. Use those per-commit
|
|
315
|
+
artifacts when reporting release results.
|
|
316
|
+
|
|
317
|
+
```
|
|
318
|
+
[+] BLOCKED the client refused, and named the control that refused
|
|
319
|
+
[=] PREVENTED the attack never arrived, but the client named no control
|
|
320
|
+
[!] REACHED the client arrived, or never stopped reading
|
|
321
|
+
[~] LUCKY nothing refused it; something else failed first
|
|
322
|
+
[-] N/A the client has no hook this probe can use
|
|
323
|
+
|
|
324
|
+
node-fetch (global, defaults)
|
|
325
|
+
-----------------------------
|
|
326
|
+
[!] REACHED Redirect into loopback the internal service recorded a request, independent of the returned body
|
|
327
|
+
[!] REACHED Redirect into loopback, written as a decimal the internal service recorded a request, independent of the returned body
|
|
328
|
+
[=] PREVENTED Redirect that carries credentials the internal service saw no request; the attempt failed with fetch failed <- cross origin not allowed for request mode "cors"
|
|
329
|
+
[~] LUCKY Redirect into cloud metadata no policy; the request failed with fetch failed <- ENETUNREACH
|
|
330
|
+
[~] LUCKY Redirect into TEST-NET-2 (reserved) no policy; the request failed with fetch failed <- ENETUNREACH
|
|
331
|
+
[+] BLOCKED Endless redirect chain the client's own limit: fetch failed <- redirect count exceeded
|
|
332
|
+
[!] REACHED Decompression bomb status 200, 67108864 bytes
|
|
333
|
+
[!] REACHED Body with no end and no length status 200, 38614251 bytes; the harness had to stop the read
|
|
334
|
+
[!] REACHED One byte per second status 200, 3 bytes; the harness had to stop the read
|
|
335
|
+
[-] N/A DNS rebinding (simulated) the client exposes no hook on the address it connects to
|
|
336
|
+
|
|
337
|
+
1 blocked · 1 prevented · 5 reached · 2 lucky · 1 not applicable
|
|
338
|
+
|
|
339
|
+
check-then-connect (naive guard)
|
|
340
|
+
--------------------------------
|
|
341
|
+
[+] BLOCKED Redirect into loopback forbidden-address: 127.0.0.1 resolved to 127.0.0.1 (loopback)
|
|
342
|
+
[+] BLOCKED Redirect into loopback, written as a decimal forbidden-address: 127.0.0.1 resolved to 127.0.0.1 (loopback)
|
|
343
|
+
[+] BLOCKED Redirect that carries credentials forbidden-address: 127.0.0.1 resolved to 127.0.0.1 (loopback)
|
|
344
|
+
[+] BLOCKED Redirect into cloud metadata forbidden-address: 169.254.169.254 resolved to 169.254.169.254 (link-local)
|
|
345
|
+
[+] BLOCKED Redirect into TEST-NET-2 (reserved) forbidden-address: 198.51.100.1 resolved to 198.51.100.1 (private)
|
|
346
|
+
[+] BLOCKED Endless redirect chain too-many-redirects: more than 3 hops
|
|
347
|
+
[+] BLOCKED Decompression bomb body-too-large: stopped after 1064960 bytes, cap is 1048576
|
|
348
|
+
[+] BLOCKED Body with no end and no length body-too-large: stopped after 1048914 bytes, cap is 1048576
|
|
349
|
+
[+] BLOCKED One byte per second deadline: 4000 ms elapsed
|
|
350
|
+
[!] REACHED DNS rebinding (simulated) the internal service recorded a request, independent of the returned body
|
|
351
|
+
|
|
352
|
+
9 blocked · 0 prevented · 1 reached · 0 lucky · 0 not applicable
|
|
353
|
+
|
|
354
|
+
guarded client (this repo)
|
|
355
|
+
--------------------------
|
|
356
|
+
[+] BLOCKED Redirect into loopback forbidden-address: 127.0.0.1 is loopback
|
|
357
|
+
[+] BLOCKED Redirect into loopback, written as a decimal forbidden-address: 127.0.0.1 is loopback
|
|
358
|
+
[+] BLOCKED Redirect that carries credentials forbidden-address: 127.0.0.1 is loopback
|
|
359
|
+
[+] BLOCKED Redirect into cloud metadata forbidden-address: 169.254.169.254 is link-local
|
|
360
|
+
[+] BLOCKED Redirect into TEST-NET-2 (reserved) forbidden-address: 198.51.100.1 is private
|
|
361
|
+
[+] BLOCKED Endless redirect chain too-many-redirects: more than 3 hops
|
|
362
|
+
[+] BLOCKED Decompression bomb body-too-large: stopped after 1064960 bytes, cap is 1048576
|
|
363
|
+
[+] BLOCKED Body with no end and no length body-too-large: stopped after 1048914 bytes, cap is 1048576
|
|
364
|
+
[+] BLOCKED One byte per second deadline: 4000 ms elapsed
|
|
365
|
+
[=] PREVENTED DNS rebinding (simulated) the internal service saw no request; the attempt failed with ENETUNREACH
|
|
366
|
+
|
|
367
|
+
9 blocked · 1 prevented · 0 reached · 0 lucky · 0 not applicable
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## What this does NOT prove
|
|
371
|
+
|
|
372
|
+
- **The rebinding probe is simulated.** It swaps answers inside the process with
|
|
373
|
+
an injected resolver. It tests which address a client connects to, not a real
|
|
374
|
+
recursive resolver, DNS cache, or TTL policy.
|
|
375
|
+
- **The listeners are loopback; not every destination is.** Unsafe clients can
|
|
376
|
+
attempt metadata, documentation, and simulated-public addresses. The range
|
|
377
|
+
uses plain HTTP, so TLS and `https` to `http` downgrade behavior are untested.
|
|
378
|
+
- **`trustedOrigin` is a lab affordance.** The range lives on loopback, so the
|
|
379
|
+
entry origin is exempt from the address rules. Without it a guard refuses the
|
|
380
|
+
range itself and every probe passes while testing nothing. No production
|
|
381
|
+
fetcher should use this exception for untrusted requests.
|
|
382
|
+
- **A green scan is not a safe client.** These are bounded tests, not the entire
|
|
383
|
+
class of SSRF attacks, and a deadline can stop a probe without proving address
|
|
384
|
+
validation is correct.
|
|
385
|
+
|
|
386
|
+
## Design notes
|
|
387
|
+
|
|
388
|
+
- **Two servers.** The public range stands for the internet and may be fetched.
|
|
389
|
+
The internal service may not. A hit on the internal service is therefore
|
|
390
|
+
always an attack that landed within the isolated, sequential test.
|
|
391
|
+
- **A canary and a counter.** The body can prove a hit, while the internal service
|
|
392
|
+
independently counts requests. That catches a hit even if the client drops
|
|
393
|
+
the body or throws afterward. Watching only errors cannot do that.
|
|
394
|
+
- **`198.51.100.1`, not `10.0.0.1`.** An early version used `10.0.0.1` and a
|
|
395
|
+
probe reached a real device on the author's own network. The range now points
|
|
396
|
+
at TEST-NET-2, reserved for documentation. This choice is not a substitute for
|
|
397
|
+
network isolation.
|
|
398
|
+
|
|
399
|
+
## Roadmap
|
|
400
|
+
|
|
401
|
+
- A real DNS server in the range, so rebinding is measured rather than simulated
|
|
402
|
+
- TLS on the range, for scheme-downgrade probes
|
|
403
|
+
- Adapters for `axios`, `got` and `undici` with explicit agents
|
|
404
|
+
- Native IPv6 targets beyond the direct suite's IPv4-mapped IPv6 case
|
|
405
|
+
|
|
406
|
+
## License
|
|
407
|
+
|
|
408
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Agent } from 'node:http';
|
|
2
|
+
import { type HttpClient } from './types.js';
|
|
3
|
+
export interface AgentClientSpec {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
/** Builds the library's agent for one URL, the way its README says to. */
|
|
6
|
+
readonly makeAgent: (url: string) => Agent;
|
|
7
|
+
/**
|
|
8
|
+
* How the library words its OWN refusal.
|
|
9
|
+
*
|
|
10
|
+
* Without this, a library that refuses correctly is indistinguishable from a
|
|
11
|
+
* socket error, and the scan would credit it with luck instead of a control.
|
|
12
|
+
*/
|
|
13
|
+
readonly refusalPattern: RegExp;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Drive a third-party SSRF-protection agent through the range.
|
|
17
|
+
*
|
|
18
|
+
* The agent is rebuilt for EVERY hop, which is what each library's README
|
|
19
|
+
* tells a caller to do. A library is therefore judged on its own terms.
|
|
20
|
+
*/
|
|
21
|
+
export declare function agentClient(spec: AgentClientSpec): HttpClient;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { request as httpRequest } from 'node:http';
|
|
2
|
+
import { assertAllowedProtocol, readCapped, redact } from './internal.js';
|
|
3
|
+
import { PolicyRefusal } from './types.js';
|
|
4
|
+
const MAX_REDIRECTS = 5;
|
|
5
|
+
const MAX_BYTES = 1024 * 1024;
|
|
6
|
+
const TIMEOUT_MS = 5000;
|
|
7
|
+
/**
|
|
8
|
+
* Drive a third-party SSRF-protection agent through the range.
|
|
9
|
+
*
|
|
10
|
+
* The agent is rebuilt for EVERY hop, which is what each library's README
|
|
11
|
+
* tells a caller to do. A library is therefore judged on its own terms.
|
|
12
|
+
*/
|
|
13
|
+
export function agentClient(spec) {
|
|
14
|
+
return {
|
|
15
|
+
name: spec.name,
|
|
16
|
+
// These agents judge the address inside themselves and expose no hook a
|
|
17
|
+
// caller can inject, so the rebinding probe cannot drive them.
|
|
18
|
+
supportsConnectHook: false,
|
|
19
|
+
async fetch(url, options = {}) {
|
|
20
|
+
try {
|
|
21
|
+
return await follow(url, options, spec);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
// A library refuses from its agent, from its socket, or by throwing
|
|
25
|
+
// inside createConnection. The 3 routes arrive here differently, so
|
|
26
|
+
// the wording is the only reliable sign that the library decided.
|
|
27
|
+
if (error instanceof Error && spec.refusalPattern.test(error.message)) {
|
|
28
|
+
throw new PolicyRefusal('forbidden-address', error.message);
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async function follow(url, options, spec) {
|
|
36
|
+
const deadline = AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS);
|
|
37
|
+
const hops = [];
|
|
38
|
+
let target = new URL(url);
|
|
39
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
40
|
+
assertAllowedProtocol(target);
|
|
41
|
+
hops.push(redact(target));
|
|
42
|
+
const response = await send(target, spec, deadline);
|
|
43
|
+
const location = response.headers.location;
|
|
44
|
+
if (isRedirect(response.statusCode ?? 0) && typeof location === 'string') {
|
|
45
|
+
response.resume();
|
|
46
|
+
target = new URL(location, target);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const body = await readCapped(response, options.maxBytes ?? MAX_BYTES);
|
|
50
|
+
return {
|
|
51
|
+
status: response.statusCode ?? 0,
|
|
52
|
+
bytesRead: body.bytes,
|
|
53
|
+
bodySample: body.sample,
|
|
54
|
+
hops,
|
|
55
|
+
stoppedByHarness: false,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
throw new PolicyRefusal('too-many-redirects', `more than ${MAX_REDIRECTS} hops`);
|
|
59
|
+
}
|
|
60
|
+
function isRedirect(status) {
|
|
61
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
62
|
+
}
|
|
63
|
+
function send(url, spec, signal) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
let request;
|
|
66
|
+
try {
|
|
67
|
+
request = httpRequest(url, { agent: spec.makeAgent(url.toString()), signal }, resolve);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// ssrf-agent throws synchronously from createConnection.
|
|
71
|
+
reject(error);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
request.on('error', reject);
|
|
75
|
+
request.end();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=agentClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agentClient.js","sourceRoot":"","sources":["../../src/adapters/agentClient.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type HttpClient } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The common wrong shape: resolve, judge the answer, then connect BY NAME.
|
|
4
|
+
*
|
|
5
|
+
* Every other control here is real. It reads every hop, it classifies
|
|
6
|
+
* addresses correctly, it caps the body and it holds a deadline. It still
|
|
7
|
+
* loses, because the name is resolved a SECOND time when the socket opens,
|
|
8
|
+
* and nothing judges that answer.
|
|
9
|
+
*
|
|
10
|
+
* It exists so the rebinding probe has something to catch. Do not copy it.
|
|
11
|
+
*/
|
|
12
|
+
export declare const checkThenConnect: HttpClient;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { lookup as systemLookup } from 'node:dns';
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
3
|
+
import { classifyAddress, isForbiddenAddress } from '../util/addresses.js';
|
|
4
|
+
import { assertAllowedProtocol, readCapped, redact } from './internal.js';
|
|
5
|
+
import { PolicyRefusal } from './types.js';
|
|
6
|
+
const MAX_REDIRECTS = 3;
|
|
7
|
+
const MAX_BYTES = 1024 * 1024;
|
|
8
|
+
const TIMEOUT_MS = 3000;
|
|
9
|
+
/**
|
|
10
|
+
* The common wrong shape: resolve, judge the answer, then connect BY NAME.
|
|
11
|
+
*
|
|
12
|
+
* Every other control here is real. It reads every hop, it classifies
|
|
13
|
+
* addresses correctly, it caps the body and it holds a deadline. It still
|
|
14
|
+
* loses, because the name is resolved a SECOND time when the socket opens,
|
|
15
|
+
* and nothing judges that answer.
|
|
16
|
+
*
|
|
17
|
+
* It exists so the rebinding probe has something to catch. Do not copy it.
|
|
18
|
+
*/
|
|
19
|
+
export const checkThenConnect = {
|
|
20
|
+
name: 'check-then-connect (naive guard)',
|
|
21
|
+
supportsConnectHook: true,
|
|
22
|
+
async fetch(url, options = {}) {
|
|
23
|
+
const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
|
|
24
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
25
|
+
try {
|
|
26
|
+
return await follow(url, options, deadline);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw asDeadline(error, timeoutMs, deadline);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
async function follow(url, options, deadline) {
|
|
34
|
+
const lookup = (options.lookup ?? systemLookup);
|
|
35
|
+
const hops = [];
|
|
36
|
+
let target = new URL(url);
|
|
37
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
38
|
+
const trusted = target.origin === options.trustedOrigin;
|
|
39
|
+
assertAllowedProtocol(target);
|
|
40
|
+
hops.push(redact(target));
|
|
41
|
+
// The check. It is correct, and it is too early.
|
|
42
|
+
if (!trusted) {
|
|
43
|
+
const address = await resolveOnce(lookup, target.hostname);
|
|
44
|
+
if (isForbiddenAddress(address)) {
|
|
45
|
+
throw new PolicyRefusal('forbidden-address', `${target.hostname} resolved to ${address} (${classifyAddress(address)})`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// The connection. It resolves the name again, and takes what it gets.
|
|
49
|
+
const response = await send(target, lookup, deadline);
|
|
50
|
+
const location = response.headers.location;
|
|
51
|
+
if (isRedirect(response.statusCode ?? 0) && typeof location === 'string') {
|
|
52
|
+
response.resume();
|
|
53
|
+
target = new URL(location, target);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const body = await readCapped(response, options.maxBytes ?? MAX_BYTES);
|
|
57
|
+
return {
|
|
58
|
+
status: response.statusCode ?? 0,
|
|
59
|
+
bytesRead: body.bytes,
|
|
60
|
+
bodySample: body.sample,
|
|
61
|
+
hops,
|
|
62
|
+
stoppedByHarness: false,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
throw new PolicyRefusal('too-many-redirects', `more than ${MAX_REDIRECTS} hops`);
|
|
66
|
+
}
|
|
67
|
+
function isRedirect(status) {
|
|
68
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
69
|
+
}
|
|
70
|
+
function resolveOnce(lookup, hostname) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
lookup(hostname, {}, (error, address) => {
|
|
73
|
+
if (error)
|
|
74
|
+
reject(error);
|
|
75
|
+
else
|
|
76
|
+
resolve(String(address));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function send(url, lookup, signal) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const request = httpRequest(url, { lookup, signal }, resolve);
|
|
83
|
+
request.on('error', reject);
|
|
84
|
+
request.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function asDeadline(error, timeoutMs, deadline) {
|
|
88
|
+
if (error instanceof PolicyRefusal)
|
|
89
|
+
return error;
|
|
90
|
+
if (deadline.aborted)
|
|
91
|
+
return new PolicyRefusal('deadline', `${timeoutMs} ms elapsed`);
|
|
92
|
+
return error;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=checkThenConnect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkThenConnect.js","sourceRoot":"","sources":["../../src/adapters/checkThenConnect.ts"],"names":[],"mappings":"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"}
|