@spfn/core 0.3.0-beta.4 → 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.
- package/README.md +183 -4
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.js +6 -387
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +61 -6
- package/dist/ops/index.js +330 -30
- package/dist/ops/index.js.map +1 -1
- package/dist/server/index.js +24 -1
- package/dist/server/index.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -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).
|