@yedoma-labs/tanur-ssrf 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +130 -2
- package/dist/index.cjs +720 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +61 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +717 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yedoma-labs
|
|
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,131 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @yedoma-labs/tanur-ssrf
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SSRF-safe `fetch` for Node.js. Zero runtime dependencies, Node.js 20+.
|
|
4
|
+
|
|
5
|
+
Use it when your server fetches a URL that a user controls: webhooks, image URLs, link previews, import-by-URL.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
Blocklists and hostname regexes do not stop server-side request forgery (SSRF):
|
|
10
|
+
|
|
11
|
+
- **DNS rebinding.** A hostname resolves to a public IP for the check and to `127.0.0.1` or `10.x` for the connection.
|
|
12
|
+
- **Redirect chains.** A public URL redirects to `http://localhost:6379` or `http://169.254.169.254/` (cloud metadata).
|
|
13
|
+
- **IP encodings.** `0x7f000001`, `2130706433`, `0177.0.0.1`, `[::ffff:127.0.0.1]`, `[::ffff:a9fe:a9fe]`, and NAT64 or 6to4 forms.
|
|
14
|
+
- **Hostname tricks.** Trailing dots, mixed case, `*.localhost`, `*.internal`.
|
|
15
|
+
|
|
16
|
+
`createSafeFetch` closes these gaps by design. It resolves a hostname once, checks every returned address, connects to exactly that address, and validates each redirect hop from scratch.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createSafeFetch, readBodyWithLimit, SafeFetchError } from "@yedoma-labs/tanur-ssrf";
|
|
22
|
+
|
|
23
|
+
const safeFetch = createSafeFetch({
|
|
24
|
+
maxBytes: 5 * 1024 * 1024,
|
|
25
|
+
timeoutMs: 5_000,
|
|
26
|
+
maxRedirects: 3,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const response = await safeFetch(userSuppliedUrl);
|
|
31
|
+
const body = await readBodyWithLimit(response, 1024 * 1024);
|
|
32
|
+
// use body
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error instanceof SafeFetchError) {
|
|
35
|
+
console.warn("blocked or failed:", error.code);
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`safeFetch` has the signature `(input: string | URL | Request, init?: RequestInit) => Promise<Response>`. The result is a standard `Response`.
|
|
42
|
+
|
|
43
|
+
## Options
|
|
44
|
+
|
|
45
|
+
| Option | Default | Meaning |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `allowedProtocols` | `["https:", "http:"]` | Accepted URL protocols. Only `http:` and `https:` are dispatched. |
|
|
48
|
+
| `allowedPorts` | `[80, 443]` | Ports that may be contacted. Anything else fails with `PORT_NOT_ALLOWED`. |
|
|
49
|
+
| `allowPrivateNetworks` | `false` | Allow private, loopback, link-local and other reserved addresses. See the warning below. |
|
|
50
|
+
| `blockedHostnames` | `localhost`, `metadata.google.internal`, `.localhost`, `.internal` | Rejected before DNS. Entries starting with `.` match the suffix and the bare name. **Replaces** the default list, so keep the defaults if you pass your own. |
|
|
51
|
+
| `maxRedirects` | `5` | Redirects followed before `REDIRECT_LIMIT`. |
|
|
52
|
+
| `timeoutMs` | `10000` | Deadline for the whole call: DNS, connect, headers and body consumption. |
|
|
53
|
+
| `maxBytes` | none | Limit on decoded body bytes. Enforced while the body is read. |
|
|
54
|
+
| `resolver` | `dns.lookup(name, { all: true })` | Injectable `(hostname) => Promise<{ address }[]>`. Useful for tests and custom DNS policy. |
|
|
55
|
+
|
|
56
|
+
Invalid options throw `TypeError` when `createSafeFetch` is called.
|
|
57
|
+
|
|
58
|
+
## Errors
|
|
59
|
+
|
|
60
|
+
Every policy failure throws `SafeFetchError` with a `code`:
|
|
61
|
+
|
|
62
|
+
| Code | When |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `PROTOCOL_NOT_ALLOWED` | Protocol is not in `allowedProtocols`, or is not `http:`/`https:` (for example `file:`, `ftp:`). |
|
|
65
|
+
| `PORT_NOT_ALLOWED` | Port is not in `allowedPorts`. |
|
|
66
|
+
| `INVALID_URL` | Unparsable URL, relative URL without base, or credentials in the URL (`user:pass@`). |
|
|
67
|
+
| `BLOCKED_HOSTNAME` | Hostname is on the blocklist. Checked before DNS. |
|
|
68
|
+
| `BLOCKED_ADDRESS` | Any resolved (or literal) address is in a blocked range, or a resolver returned a non-IP value. |
|
|
69
|
+
| `DNS_FAILURE` | The resolver failed or returned no addresses. |
|
|
70
|
+
| `REDIRECT_LIMIT` | More than `maxRedirects` redirects. |
|
|
71
|
+
| `REDIRECT_WITHOUT_LOCATION` | A 3xx redirect status without `Location`. |
|
|
72
|
+
| `TIMEOUT` | `timeoutMs` elapsed. |
|
|
73
|
+
| `BODY_TOO_LARGE` | Body exceeded `maxBytes`, or `readBodyWithLimit` hit its limit. |
|
|
74
|
+
| `NETWORK_ERROR` | Connection or protocol failure, or an unsupported `Content-Encoding`. |
|
|
75
|
+
|
|
76
|
+
Error messages never contain the URL, its query string or credentials. They name the blocked host or address only.
|
|
77
|
+
|
|
78
|
+
Caller-provided `AbortSignal` aborts are not converted. The original `AbortError` is thrown.
|
|
79
|
+
|
|
80
|
+
## Guarantees
|
|
81
|
+
|
|
82
|
+
- **One resolution per hop, and only that answer is used.** The hostname is resolved once. Every returned address is checked. If any one is blocked, the hop is rejected. The socket is opened to the validated address through a pinned `lookup`, so Node never resolves the name again. This prevents DNS rebinding between check and connect.
|
|
83
|
+
- **Host and TLS stay correct.** The `Host` header is the requested hostname (and port if not default). TLS SNI and certificate verification use the hostname, never the IP.
|
|
84
|
+
- **Every redirect is validated.** `Location` is resolved against the current URL. Protocol, credentials, port, hostname, DNS and IP checks run again for each hop. Redirects are never followed automatically.
|
|
85
|
+
- **Credentials are not forwarded across origins.** On a cross-origin redirect, `Authorization`, `Cookie` and `Proxy-Authorization` are dropped, as `fetch` does.
|
|
86
|
+
- **No socket reuse.** Each request uses a fresh connection, so a connection validated under one policy cannot serve another.
|
|
87
|
+
- **IP classification covers:** IPv4 `0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`, `192.0.0/24`, `192.168/16`, `198.18/15`, `224/4`, `240/4`, `255.255.255.255`, and documentation ranges. IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, `ff00::/8`, plus IPv4-mapped, IPv4-translated, NAT64, 6to4 and Teredo forms, each judged by the embedded IPv4 address where applicable.
|
|
88
|
+
- **Parser normalization.** Hostnames are lowercased, a single trailing dot is removed, and IPv6 brackets are handled. Non-canonical IPv4 forms are normalized by the WHATWG URL parser before the check.
|
|
89
|
+
- **Zero runtime dependencies.** Only `node:*` built-ins are used.
|
|
90
|
+
|
|
91
|
+
## Limits and what this does not protect against
|
|
92
|
+
|
|
93
|
+
- **OS and network level.** This library is not a sandbox. Attacks that exploit the operating system, a kernel or a network stack are out of scope. Egress filtering at the network layer (firewall, separate network namespace) is still recommended for high-risk workloads.
|
|
94
|
+
- **Proxies are not supported.** The pinned `lookup` connects directly. Environment proxy settings (`HTTP_PROXY`, `HTTPS_PROXY`) are not honored, and a configured proxy would receive the request without this policy applied at the proxy hop.
|
|
95
|
+
- **Time-of-check versus time-of-use.** The validated address is bound to the connection. Nothing else is pinned: an attacker-controlled server on an allowed public address can still send any content, so the body still needs `maxBytes` and content validation.
|
|
96
|
+
- **Hostname policy is a list.** `blockedHostnames` is advisory on top of the IP checks. Replacing the default list removes the hostname layer.
|
|
97
|
+
- **Lists age.** Reserved ranges are hard-coded. Review them when IANA changes allocations.
|
|
98
|
+
- **Node.js only.** Browsers and Deno or Bun runtimes are not supported and not tested.
|
|
99
|
+
- **Response-level compatibility.** Gzip, deflate and Brotli bodies are decoded. Other `Content-Encoding` values fail with `NETWORK_ERROR`. `redirect` in `init` is ignored, because redirects are always handled by this library.
|
|
100
|
+
|
|
101
|
+
### Do not use `allowPrivateNetworks` for untrusted input
|
|
102
|
+
|
|
103
|
+
`allowPrivateNetworks: true` disables the private, loopback, link-local and reserved address checks. Any URL can then reach internal services, including cloud metadata endpoints. Use it only in tests, or for destinations you know and trust. Keep the default `false` for anything derived from user input.
|
|
104
|
+
|
|
105
|
+
## Installation
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
pnpm add @yedoma-labs/tanur-ssrf
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Supports ESM (`import`) and CommonJS (`require`).
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
pnpm install
|
|
117
|
+
pnpm lint
|
|
118
|
+
pnpm typecheck
|
|
119
|
+
pnpm test
|
|
120
|
+
pnpm build
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The test suite does not perform network requests. Integration tests use a loopback server and an injected resolver. The redirect tests stub `node:http` so that no socket is opened to the redirect targets.
|
|
124
|
+
|
|
125
|
+
## Security
|
|
126
|
+
|
|
127
|
+
See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT. See [LICENSE](LICENSE).
|