@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6

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.
@@ -0,0 +1,100 @@
1
+ # @spfn/core/security
2
+
3
+ Helpers for making outbound requests safely. The headline export is **`safeFetch`**,
4
+ an SSRF-hardened drop-in for `fetch`.
5
+
6
+ ## Why
7
+
8
+ Any time the backend fetches a URL that a user can influence — a webhook target, an
9
+ image URL, an OAuth callback, a `$ref` in stored data — an attacker can point it at an
10
+ internal address (`http://169.254.169.254/…` for cloud credentials, `http://127.0.0.1:…`
11
+ for internal services). That is **SSRF** (Server-Side Request Forgery).
12
+
13
+ A string allowlist or a "block these hostnames" check does **not** stop it: the attacker
14
+ controls DNS, so `evil.com` can resolve to `169.254.169.254` *after* your check passes
15
+ (**DNS rebinding**). The only robust defense is to resolve the host, validate the
16
+ resolved IP, and **pin the connection to that validated IP**.
17
+
18
+ ## `safeFetch(input, init?)`
19
+
20
+ Same signature as `fetch`. It:
21
+
22
+ 1. checks the protocol (default `http:`/`https:`) and any host allowlist;
23
+ 2. resolves the hostname and rejects if it points at a private/reserved range;
24
+ 3. **pins** the connection to a validated IP via a custom undici `lookup`, closing the
25
+ rebinding window between check and connect;
26
+ 4. follows redirects **manually** and re-validates **every hop** — including a hop whose
27
+ target is a bare IP literal (which undici would otherwise connect to directly, skipping
28
+ the pinning lookup). Capped at `maxRedirects` (default 5).
29
+
30
+ The original method, body, and headers are **not** replayed across a redirect: the next
31
+ hop may be attacker-chosen, so forwarding the payload or auth headers would leak them — the
32
+ redirected request is issued as a header-less `GET`.
33
+
34
+ Blocked targets throw `SsrfBlockedError`.
35
+
36
+ ```typescript
37
+ import { safeFetch } from '@spfn/core/security';
38
+
39
+ const res = await safeFetch(webhookUrl, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify(payload),
43
+ });
44
+ ```
45
+
46
+ ## Policy
47
+
48
+ Private/reserved IPs are blocked by default. Configure the process-wide default once at
49
+ boot, or build a scoped fetch with `createSafeFetch(policy)`:
50
+
51
+ ```typescript
52
+ // server.config.ts — sets the default safeFetch() policy
53
+ export default defineServerConfig()
54
+ .outboundFetch({ allowHosts: ['hooks.slack.com'] })
55
+ .build();
56
+
57
+ // or a one-off, scoped instance (reuse it — it owns a pooled dispatcher)
58
+ import { createSafeFetch } from '@spfn/core/security';
59
+ const fetchSlack = createSafeFetch({ allowHosts: ['hooks.slack.com'] });
60
+ ```
61
+
62
+ | Field | Type | Default | Notes |
63
+ |---|---|---|---|
64
+ | `allowedProtocols` | `string[]` | `['http:','https:']` | Permitted URL schemes. |
65
+ | `blockPrivateIps` | `boolean` | `true` | Block private/reserved ranges (loopback, link-local/metadata, RFC1918, CGNAT, ULA, multicast, NAT64/6to4). |
66
+ | `allowHosts` | `string[]` | — | Exact hostname allowlist (case-insensitive), enforced on every redirect hop. Strongest control for a known upstream set. |
67
+ | `maxRedirects` | `number` | `5` | Redirects to follow, each re-validated. |
68
+
69
+ Env: `SAFE_FETCH_BLOCK_PRIVATE_IPS` sets the boot default for `blockPrivateIps`
70
+ (keep `true` in production; `false` only for trusted internal-network calls in dev).
71
+
72
+ > **Compatibility note.** The Slack webhook sender (`@spfn/notification`) now routes
73
+ > through `safeFetch`, so a webhook pointed at a **private/internal address** (self-hosted
74
+ > Mattermost, an internal Slack-compatible endpoint on `10.x`/`192.168.x`, etc.) will now
75
+ > fail to send. Allow it explicitly with `outboundFetch({ allowHosts: ['your.host'] })`, or
76
+ > — if the target is a raw private IP — `outboundFetch({ blockPrivateIps: false })` (which
77
+ > relaxes the check for *all* outbound calls, so prefer `allowHosts`).
78
+
79
+ ## `assertSafeUrl(url, policy?)`
80
+
81
+ When the actual request is made by code you **don't** control (e.g. an app-provided
82
+ storage `download(url)`), you can't pin the connection. Validate the URL first:
83
+
84
+ ```typescript
85
+ import { assertSafeUrl } from '@spfn/core/security';
86
+
87
+ if (/^https?:\/\//i.test(ref)) await assertSafeUrl(ref); // throws SsrfBlockedError if internal
88
+ return storage.download(ref);
89
+ ```
90
+
91
+ This runs the same protocol/allowlist/DNS-resolution checks but **cannot** prevent
92
+ rebinding between the check and that code's own connection — it raises the bar (blocks
93
+ the obvious metadata/loopback injection) rather than fully closing SSRF. Prefer
94
+ `safeFetch` whenever you own the request.
95
+
96
+ ## Also here
97
+
98
+ - `isPrivateOrReservedIp(ip)` — the IP classifier, exported for reuse/testing.
99
+ - `proxy-signature` — HMAC signing/verification for the proxy→backend trust path
100
+ (see `@spfn/core/middleware` proxy-guard).