@pikku/core 0.12.63 → 0.12.64
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 +147 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +32 -25
- package/dist/permissions.js +104 -185
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -0
- package/dist/wirings/ai-agent/index.js +1 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +2 -38
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +0 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +1 -9
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +5 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +153 -372
- package/src/permissions.ts +139 -259
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
- package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/ai-agent/voice-input.ts +6 -35
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +0 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +1 -10
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- package/src/wirings/oauth2/oauth2-routes.ts +0 -234
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { assertFetchableUrl, isPrivateHost, safeFetch } from './safe-fetch.js'
|
|
5
|
+
|
|
6
|
+
describe('isPrivateHost', () => {
|
|
7
|
+
test('flags loopback, private ranges, and cloud metadata', () => {
|
|
8
|
+
for (const host of [
|
|
9
|
+
'localhost',
|
|
10
|
+
'127.0.0.1',
|
|
11
|
+
'0.0.0.0',
|
|
12
|
+
'10.1.2.3',
|
|
13
|
+
'172.16.0.1',
|
|
14
|
+
'172.31.255.255',
|
|
15
|
+
'192.168.1.1',
|
|
16
|
+
'169.254.169.254',
|
|
17
|
+
'::1',
|
|
18
|
+
'[::1]',
|
|
19
|
+
'fd00::1',
|
|
20
|
+
]) {
|
|
21
|
+
assert.equal(isPrivateHost(host), true, `${host} should be private`)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('flags alias and encoded forms of internal hosts', () => {
|
|
26
|
+
for (const host of [
|
|
27
|
+
'localhost.', // trailing-dot FQDN
|
|
28
|
+
'foo.localhost', // *.localhost resolves to loopback (RFC 6761)
|
|
29
|
+
'::', // unspecified address
|
|
30
|
+
'::ffff:127.0.0.1', // IPv4-mapped IPv6 (dotted)
|
|
31
|
+
'::ffff:7f00:1', // IPv4-mapped IPv6 (hex, as URL normalizes it)
|
|
32
|
+
'2130706433', // decimal-encoded 127.0.0.1
|
|
33
|
+
'0x7f000001', // hex-encoded 127.0.0.1
|
|
34
|
+
'0177.0.0.1', // octal first octet (127)
|
|
35
|
+
'0x7f.0.0.1', // hex first octet (127)
|
|
36
|
+
'fe80::1', // link-local
|
|
37
|
+
'fea9::1', // link-local within fe80::/10
|
|
38
|
+
'febf::1', // top of fe80::/10
|
|
39
|
+
]) {
|
|
40
|
+
assert.equal(isPrivateHost(host), true, `${host} should be private`)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('allows public hosts', () => {
|
|
45
|
+
for (const host of [
|
|
46
|
+
'example.com',
|
|
47
|
+
'8.8.8.8',
|
|
48
|
+
'172.32.0.1',
|
|
49
|
+
'11.0.0.1',
|
|
50
|
+
'134744072', // decimal-encoded 8.8.8.8 — public
|
|
51
|
+
'2001:db8::1', // documentation range — public
|
|
52
|
+
'fec0::1', // deprecated site-local, outside fe80::/10 — treated public
|
|
53
|
+
]) {
|
|
54
|
+
assert.equal(isPrivateHost(host), false, `${host} should be public`)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('assertFetchableUrl', () => {
|
|
60
|
+
test('rejects non-HTTP(S) schemes', () => {
|
|
61
|
+
assert.throws(() => assertFetchableUrl('file:///etc/passwd'), /non-HTTP/)
|
|
62
|
+
assert.throws(() => assertFetchableUrl('ftp://example.com'), /non-HTTP/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('rejects private hosts by default', () => {
|
|
66
|
+
assert.throws(
|
|
67
|
+
() => assertFetchableUrl('http://169.254.169.254/latest/meta-data/'),
|
|
68
|
+
/private\/internal host/
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('honours an allowlist (and rejects hosts not on it)', () => {
|
|
73
|
+
assert.doesNotThrow(() =>
|
|
74
|
+
assertFetchableUrl('http://internal.svc/x', {
|
|
75
|
+
allowedHosts: ['internal.svc'],
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
assert.throws(
|
|
79
|
+
() =>
|
|
80
|
+
assertFetchableUrl('https://example.com', {
|
|
81
|
+
allowedHosts: ['internal.svc'],
|
|
82
|
+
}),
|
|
83
|
+
/not in the allowlist/
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('safeFetch', () => {
|
|
89
|
+
const withStubbedFetch = async (
|
|
90
|
+
handler: (url: string, init: RequestInit) => Response,
|
|
91
|
+
run: (calls: string[]) => Promise<void>
|
|
92
|
+
) => {
|
|
93
|
+
const original = globalThis.fetch
|
|
94
|
+
const calls: string[] = []
|
|
95
|
+
globalThis.fetch = (async (u: any, init: any) => {
|
|
96
|
+
calls.push(String(u))
|
|
97
|
+
return handler(String(u), init)
|
|
98
|
+
}) as typeof fetch
|
|
99
|
+
try {
|
|
100
|
+
await run(calls)
|
|
101
|
+
} finally {
|
|
102
|
+
globalThis.fetch = original
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('never issues a request to a private target', async () => {
|
|
107
|
+
await withStubbedFetch(
|
|
108
|
+
() => new Response(null, { status: 200 }),
|
|
109
|
+
async (calls) => {
|
|
110
|
+
await assert.rejects(
|
|
111
|
+
safeFetch('http://169.254.169.254/latest/meta-data/'),
|
|
112
|
+
/private\/internal host/
|
|
113
|
+
)
|
|
114
|
+
assert.equal(calls.length, 0, 'must not have called fetch at all')
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('does not follow a redirect into a private host', async () => {
|
|
120
|
+
await withStubbedFetch(
|
|
121
|
+
(url) =>
|
|
122
|
+
url.includes('example.com')
|
|
123
|
+
? new Response(null, {
|
|
124
|
+
status: 302,
|
|
125
|
+
headers: { location: 'http://169.254.169.254/' },
|
|
126
|
+
})
|
|
127
|
+
: new Response(null, { status: 200 }),
|
|
128
|
+
async (calls) => {
|
|
129
|
+
// The redirect target is private, so following it is refused and the
|
|
130
|
+
// private host is never fetched (only the initial hop was requested).
|
|
131
|
+
await assert.rejects(
|
|
132
|
+
safeFetch('https://example.com/hook'),
|
|
133
|
+
/private\/internal host/
|
|
134
|
+
)
|
|
135
|
+
assert.equal(calls.length, 1)
|
|
136
|
+
assert.ok(calls[0]!.includes('example.com'))
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('follows a redirect to another public host', async () => {
|
|
142
|
+
await withStubbedFetch(
|
|
143
|
+
(url) =>
|
|
144
|
+
url.includes('start.com')
|
|
145
|
+
? new Response(null, {
|
|
146
|
+
status: 302,
|
|
147
|
+
headers: { location: 'https://end.com/final' },
|
|
148
|
+
})
|
|
149
|
+
: new Response('ok', { status: 200 }),
|
|
150
|
+
async (calls) => {
|
|
151
|
+
const res = await safeFetch('https://start.com')
|
|
152
|
+
assert.equal(res.status, 200)
|
|
153
|
+
assert.deepEqual(calls, ['https://start.com/', 'https://end.com/final'])
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const withHeaderCapturingFetch = async (
|
|
159
|
+
handler: (url: string) => Response,
|
|
160
|
+
run: (
|
|
161
|
+
headersByUrl: Array<{ url: string; headers: Headers }>
|
|
162
|
+
) => Promise<void>
|
|
163
|
+
) => {
|
|
164
|
+
const original = globalThis.fetch
|
|
165
|
+
const seen: Array<{ url: string; headers: Headers }> = []
|
|
166
|
+
globalThis.fetch = (async (u: any, init: any) => {
|
|
167
|
+
seen.push({ url: String(u), headers: new Headers(init?.headers) })
|
|
168
|
+
return handler(String(u))
|
|
169
|
+
}) as typeof fetch
|
|
170
|
+
try {
|
|
171
|
+
await run(seen)
|
|
172
|
+
} finally {
|
|
173
|
+
globalThis.fetch = original
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
test('strips Authorization and Cookie when a redirect crosses origin', async () => {
|
|
178
|
+
await withHeaderCapturingFetch(
|
|
179
|
+
(url) =>
|
|
180
|
+
url.includes('start.com')
|
|
181
|
+
? new Response(null, {
|
|
182
|
+
status: 302,
|
|
183
|
+
headers: { location: 'https://other.com/final' },
|
|
184
|
+
})
|
|
185
|
+
: new Response('ok', { status: 200 }),
|
|
186
|
+
async (seen) => {
|
|
187
|
+
await safeFetch('https://start.com', {
|
|
188
|
+
headers: {
|
|
189
|
+
authorization: 'Bearer secret',
|
|
190
|
+
cookie: 'session=abc',
|
|
191
|
+
'x-trace': 'keep-me',
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
assert.equal(seen.length, 2)
|
|
195
|
+
assert.equal(seen[0]!.headers.get('authorization'), 'Bearer secret')
|
|
196
|
+
assert.equal(seen[1]!.url, 'https://other.com/final')
|
|
197
|
+
assert.equal(seen[1]!.headers.get('authorization'), null)
|
|
198
|
+
assert.equal(seen[1]!.headers.get('cookie'), null)
|
|
199
|
+
assert.equal(seen[1]!.headers.get('x-trace'), 'keep-me')
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('preserves Authorization and Cookie on a same-origin redirect', async () => {
|
|
205
|
+
await withHeaderCapturingFetch(
|
|
206
|
+
(url) =>
|
|
207
|
+
url.endsWith('/a')
|
|
208
|
+
? new Response(null, {
|
|
209
|
+
status: 302,
|
|
210
|
+
headers: { location: 'https://same.com/b' },
|
|
211
|
+
})
|
|
212
|
+
: new Response('ok', { status: 200 }),
|
|
213
|
+
async (seen) => {
|
|
214
|
+
await safeFetch('https://same.com/a', {
|
|
215
|
+
headers: { authorization: 'Bearer secret', cookie: 'session=abc' },
|
|
216
|
+
})
|
|
217
|
+
assert.equal(seen.length, 2)
|
|
218
|
+
assert.equal(seen[1]!.url, 'https://same.com/b')
|
|
219
|
+
assert.equal(seen[1]!.headers.get('authorization'), 'Bearer secret')
|
|
220
|
+
assert.equal(seen[1]!.headers.get('cookie'), 'session=abc')
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const withMethodCapturingFetch = async (
|
|
226
|
+
handler: (url: string) => Response,
|
|
227
|
+
run: (
|
|
228
|
+
seen: Array<{ url: string; method: string; body: unknown }>
|
|
229
|
+
) => Promise<void>
|
|
230
|
+
) => {
|
|
231
|
+
const original = globalThis.fetch
|
|
232
|
+
const seen: Array<{ url: string; method: string; body: unknown }> = []
|
|
233
|
+
globalThis.fetch = (async (u: any, init: any) => {
|
|
234
|
+
seen.push({
|
|
235
|
+
url: String(u),
|
|
236
|
+
method: (init?.method ?? 'GET').toUpperCase(),
|
|
237
|
+
body: init?.body,
|
|
238
|
+
})
|
|
239
|
+
return handler(String(u))
|
|
240
|
+
}) as typeof fetch
|
|
241
|
+
try {
|
|
242
|
+
await run(seen)
|
|
243
|
+
} finally {
|
|
244
|
+
globalThis.fetch = original
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
test('rewrites POST to GET and drops the body on a 303 redirect', async () => {
|
|
249
|
+
await withMethodCapturingFetch(
|
|
250
|
+
(url) =>
|
|
251
|
+
url.endsWith('/submit')
|
|
252
|
+
? new Response(null, {
|
|
253
|
+
status: 303,
|
|
254
|
+
headers: { location: 'https://start.com/result' },
|
|
255
|
+
})
|
|
256
|
+
: new Response('ok', { status: 200 }),
|
|
257
|
+
async (seen) => {
|
|
258
|
+
await safeFetch('https://start.com/submit', {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
body: 'payload',
|
|
261
|
+
headers: { 'content-type': 'text/plain' },
|
|
262
|
+
})
|
|
263
|
+
assert.equal(seen.length, 2)
|
|
264
|
+
assert.equal(seen[1]!.method, 'GET')
|
|
265
|
+
assert.equal(seen[1]!.body, undefined)
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('rewrites POST to GET on a 302 redirect', async () => {
|
|
271
|
+
await withMethodCapturingFetch(
|
|
272
|
+
(url) =>
|
|
273
|
+
url.endsWith('/submit')
|
|
274
|
+
? new Response(null, {
|
|
275
|
+
status: 302,
|
|
276
|
+
headers: { location: 'https://start.com/result' },
|
|
277
|
+
})
|
|
278
|
+
: new Response('ok', { status: 200 }),
|
|
279
|
+
async (seen) => {
|
|
280
|
+
await safeFetch('https://start.com/submit', {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
body: 'payload',
|
|
283
|
+
})
|
|
284
|
+
assert.equal(seen[1]!.method, 'GET')
|
|
285
|
+
assert.equal(seen[1]!.body, undefined)
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('preserves method and body on a 307/308 redirect', async () => {
|
|
291
|
+
for (const status of [307, 308]) {
|
|
292
|
+
await withMethodCapturingFetch(
|
|
293
|
+
(url) =>
|
|
294
|
+
url.endsWith('/submit')
|
|
295
|
+
? new Response(null, {
|
|
296
|
+
status,
|
|
297
|
+
headers: { location: 'https://start.com/result' },
|
|
298
|
+
})
|
|
299
|
+
: new Response('ok', { status: 200 }),
|
|
300
|
+
async (seen) => {
|
|
301
|
+
await safeFetch('https://start.com/submit', {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
body: 'payload',
|
|
304
|
+
})
|
|
305
|
+
assert.equal(seen[1]!.method, 'POST', `status ${status}`)
|
|
306
|
+
assert.equal(seen[1]!.body, 'payload', `status ${status}`)
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('does not follow non-redirect 3xx statuses even with a Location', async () => {
|
|
313
|
+
for (const status of [300, 304, 305, 306]) {
|
|
314
|
+
await withStubbedFetch(
|
|
315
|
+
() =>
|
|
316
|
+
new Response(null, {
|
|
317
|
+
status,
|
|
318
|
+
headers: { location: 'https://end.com/final' },
|
|
319
|
+
}),
|
|
320
|
+
async (calls) => {
|
|
321
|
+
const res = await safeFetch('https://start.com')
|
|
322
|
+
assert.equal(res.status, status, `status ${status}`)
|
|
323
|
+
assert.equal(calls.length, 1, `status ${status} must not follow`)
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('cancels the intermediate redirect response body before following', async () => {
|
|
330
|
+
const original = globalThis.fetch
|
|
331
|
+
let cancelled = false
|
|
332
|
+
globalThis.fetch = (async (u: any) => {
|
|
333
|
+
if (String(u).endsWith('/a')) {
|
|
334
|
+
const body = new ReadableStream({
|
|
335
|
+
cancel() {
|
|
336
|
+
cancelled = true
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
return new Response(body, {
|
|
340
|
+
status: 302,
|
|
341
|
+
headers: { location: 'https://start.com/b' },
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
return new Response('ok', { status: 200 })
|
|
345
|
+
}) as typeof fetch
|
|
346
|
+
try {
|
|
347
|
+
await safeFetch('https://start.com/a')
|
|
348
|
+
} finally {
|
|
349
|
+
globalThis.fetch = original
|
|
350
|
+
}
|
|
351
|
+
assert.equal(cancelled, true, 'intermediate body should be cancelled')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test('forces redirect:manual on the underlying fetch', async () => {
|
|
355
|
+
await withStubbedFetch(
|
|
356
|
+
() => new Response('ok', { status: 200 }),
|
|
357
|
+
async () => {
|
|
358
|
+
let seenRedirect: RequestRedirect | undefined
|
|
359
|
+
const original = globalThis.fetch
|
|
360
|
+
globalThis.fetch = (async (_u: any, init: any) => {
|
|
361
|
+
seenRedirect = init?.redirect
|
|
362
|
+
return new Response('ok', { status: 200 })
|
|
363
|
+
}) as typeof fetch
|
|
364
|
+
try {
|
|
365
|
+
await safeFetch('https://example.com', { redirect: 'follow' })
|
|
366
|
+
} finally {
|
|
367
|
+
globalThis.fetch = original
|
|
368
|
+
}
|
|
369
|
+
assert.equal(seenRedirect, 'manual')
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-aware fetch helpers.
|
|
3
|
+
*
|
|
4
|
+
* `@pikku/core` runs in edge runtimes (Cloudflare Workers) with no Node `dns`,
|
|
5
|
+
* so we cannot resolve hostnames to check for private targets. We reject the
|
|
6
|
+
* obvious internal literals and, crucially, re-validate every redirect hop —
|
|
7
|
+
* a public URL that 302s to `169.254.169.254` is the common bypass. This does
|
|
8
|
+
* NOT defend against a public hostname that itself resolves to a private IP
|
|
9
|
+
* (DNS rebinding), which is out of reach without DNS resolution.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse the many textual encodings of an IPv4 address that `fetch`/`undici`
|
|
14
|
+
* (and `inet_aton`-style parsers) accept — a dotted quad whose octets may be
|
|
15
|
+
* decimal, octal (`0177`) or hex (`0x7f`), or the whole address as a single
|
|
16
|
+
* 32-bit integer (decimal `2130706433`, hex `0x7f000001`). Returns the four
|
|
17
|
+
* octets, or `null` when the host is not a numeric IPv4 literal.
|
|
18
|
+
*/
|
|
19
|
+
function parseIPv4Octets(
|
|
20
|
+
host: string
|
|
21
|
+
): [number, number, number, number] | null {
|
|
22
|
+
const toInt = (part: string): number | null => {
|
|
23
|
+
let n: number
|
|
24
|
+
if (/^0x[0-9a-f]+$/.test(part)) n = parseInt(part, 16)
|
|
25
|
+
else if (/^0[0-7]+$/.test(part)) n = parseInt(part, 8)
|
|
26
|
+
else if (/^\d+$/.test(part)) n = parseInt(part, 10)
|
|
27
|
+
else return null
|
|
28
|
+
return Number.isInteger(n) ? n : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!host.includes('.')) {
|
|
32
|
+
const n = toInt(host)
|
|
33
|
+
if (n === null || n < 0 || n > 0xffffffff) return null
|
|
34
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parts = host.split('.')
|
|
38
|
+
if (parts.length !== 4) return null
|
|
39
|
+
const octets: number[] = []
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
const n = toInt(part)
|
|
42
|
+
if (n === null || n < 0 || n > 0xff) return null
|
|
43
|
+
octets.push(n)
|
|
44
|
+
}
|
|
45
|
+
return octets as [number, number, number, number]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a hostname is an obvious internal/private target (loopback, private
|
|
50
|
+
* IPv4 ranges, link-local incl. the cloud metadata endpoint, or private IPv6).
|
|
51
|
+
*
|
|
52
|
+
* Alias/encoded forms that resolve to the same targets are also rejected: a
|
|
53
|
+
* trailing-dot FQDN (`localhost.`), the `*.localhost` reserved name, IPv4-mapped
|
|
54
|
+
* IPv6 (`::ffff:127.0.0.1`), and octal/decimal/hex-encoded IPv4. This is
|
|
55
|
+
* best-effort literal matching only — it cannot catch a public hostname that
|
|
56
|
+
* *resolves* to a private IP (DNS rebinding), which needs DNS resolution
|
|
57
|
+
* unavailable in edge runtimes.
|
|
58
|
+
*/
|
|
59
|
+
export function isPrivateHost(hostname: string): boolean {
|
|
60
|
+
const host = hostname
|
|
61
|
+
.replace(/^\[|\]$/g, '')
|
|
62
|
+
.replace(/\.$/, '')
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
if (host === '' || host === 'localhost' || host.endsWith('.localhost'))
|
|
65
|
+
return true
|
|
66
|
+
|
|
67
|
+
if (host.includes(':')) {
|
|
68
|
+
if (host === '::' || host === '::1') return true
|
|
69
|
+
const mappedV4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
|
70
|
+
if (mappedV4) return isPrivateHost(mappedV4[1]!)
|
|
71
|
+
const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
72
|
+
if (mappedHex) {
|
|
73
|
+
const hi = parseInt(mappedHex[1]!, 16)
|
|
74
|
+
const lo = parseInt(mappedHex[2]!, 16)
|
|
75
|
+
return isPrivateHost(
|
|
76
|
+
`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (/^fe[89ab]/.test(host)) return true // link-local fe80::/10
|
|
80
|
+
if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const v4 = parseIPv4Octets(host)
|
|
85
|
+
if (v4) {
|
|
86
|
+
const [a, b] = v4
|
|
87
|
+
if (a === 127 || a === 10 || a === 0) return true
|
|
88
|
+
if (a === 169 && b === 254) return true // link-local incl. cloud metadata
|
|
89
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
90
|
+
if (a === 192 && b === 168) return true
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SafeFetchOptions {
|
|
96
|
+
/**
|
|
97
|
+
* When set, the host of every hop must appear in this allowlist. When omitted,
|
|
98
|
+
* any host that is not {@link isPrivateHost} is permitted.
|
|
99
|
+
*/
|
|
100
|
+
allowedHosts?: string[]
|
|
101
|
+
/** Maximum redirect hops to follow (each re-validated). Defaults to 3. */
|
|
102
|
+
maxRedirects?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse and validate a URL for outbound fetching: only http(s), and — unless an
|
|
107
|
+
* `allowedHosts` allowlist is supplied — not an obvious private/internal host.
|
|
108
|
+
* Returns the parsed URL or throws.
|
|
109
|
+
*/
|
|
110
|
+
export function assertFetchableUrl(
|
|
111
|
+
url: string,
|
|
112
|
+
options: SafeFetchOptions = {}
|
|
113
|
+
): URL {
|
|
114
|
+
const parsed = new URL(url)
|
|
115
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
116
|
+
throw new Error(`Refusing to fetch non-HTTP(S) URL: ${parsed.protocol}`)
|
|
117
|
+
}
|
|
118
|
+
if (options.allowedHosts) {
|
|
119
|
+
if (!options.allowedHosts.includes(parsed.hostname)) {
|
|
120
|
+
throw new Error(`URL host is not in the allowlist: ${parsed.hostname}`)
|
|
121
|
+
}
|
|
122
|
+
} else if (isPrivateHost(parsed.hostname)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Refusing to fetch from a private/internal host: ${parsed.hostname}`
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return parsed
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The only 3xx statuses that request a redirect be followed. `300` (Multiple
|
|
132
|
+
* Choices), `304` (Not Modified), `305` (Use Proxy) and `306` are returned to
|
|
133
|
+
* the caller as-is rather than followed.
|
|
134
|
+
*/
|
|
135
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Drop credential-bearing headers (`Authorization`, `Cookie`) so they are not
|
|
139
|
+
* replayed to a different origin across a redirect.
|
|
140
|
+
*/
|
|
141
|
+
function stripCredentialHeaders(init: RequestInit): RequestInit {
|
|
142
|
+
if (!init.headers) return init
|
|
143
|
+
const headers = new Headers(init.headers)
|
|
144
|
+
headers.delete('authorization')
|
|
145
|
+
headers.delete('cookie')
|
|
146
|
+
return { ...init, headers }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Apply the WHATWG-fetch method/body transform for a redirect: a `303` (and a
|
|
151
|
+
* `301`/`302` on a `POST`) becomes a bodyless `GET`; `307`/`308` preserve the
|
|
152
|
+
* original method and body. When the method changes to `GET` the request body
|
|
153
|
+
* and its `Content-*` headers are dropped.
|
|
154
|
+
*/
|
|
155
|
+
function redirectInit(status: number, init: RequestInit): RequestInit {
|
|
156
|
+
const method = (init.method ?? 'GET').toUpperCase()
|
|
157
|
+
const toGet =
|
|
158
|
+
(status === 303 && method !== 'GET' && method !== 'HEAD') ||
|
|
159
|
+
((status === 301 || status === 302) && method === 'POST')
|
|
160
|
+
if (!toGet) return init
|
|
161
|
+
const headers = new Headers(init.headers)
|
|
162
|
+
headers.delete('content-length')
|
|
163
|
+
headers.delete('content-type')
|
|
164
|
+
return { ...init, method: 'GET', body: undefined, headers }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `fetch` with SSRF protection. The initial URL and every redirect target are
|
|
169
|
+
* validated with {@link assertFetchableUrl}. Redirects are followed manually
|
|
170
|
+
* (`redirect: 'manual'`) so an unsafe `Location` can never be followed into the
|
|
171
|
+
* internal network; only the redirect statuses in {@link REDIRECT_STATUSES} are
|
|
172
|
+
* followed, and the method/body are transformed per {@link redirectInit}. Each
|
|
173
|
+
* intermediate redirect response body is cancelled before the next hop so it is
|
|
174
|
+
* not left dangling. When a redirect cannot or should not be followed (no
|
|
175
|
+
* `Location`, or the hop budget is exhausted) the raw redirect response is
|
|
176
|
+
* returned for the caller to handle by status. Credential headers
|
|
177
|
+
* (`Authorization`, `Cookie`) are stripped whenever a redirect crosses origin,
|
|
178
|
+
* so they never leak to a redirected host.
|
|
179
|
+
*/
|
|
180
|
+
export async function safeFetch(
|
|
181
|
+
url: string,
|
|
182
|
+
init: RequestInit = {},
|
|
183
|
+
options: SafeFetchOptions = {}
|
|
184
|
+
): Promise<Response> {
|
|
185
|
+
const maxRedirects = options.maxRedirects ?? 3
|
|
186
|
+
let currentUrl = assertFetchableUrl(url, options).toString()
|
|
187
|
+
let currentInit = init
|
|
188
|
+
|
|
189
|
+
for (let hop = 0; ; hop++) {
|
|
190
|
+
const response = await fetch(currentUrl, {
|
|
191
|
+
...currentInit,
|
|
192
|
+
redirect: 'manual',
|
|
193
|
+
})
|
|
194
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
195
|
+
return response
|
|
196
|
+
}
|
|
197
|
+
const location = response.headers.get('location')
|
|
198
|
+
if (!location || hop >= maxRedirects) {
|
|
199
|
+
return response
|
|
200
|
+
}
|
|
201
|
+
const nextUrl = assertFetchableUrl(
|
|
202
|
+
new URL(location, currentUrl).toString(),
|
|
203
|
+
options
|
|
204
|
+
).toString()
|
|
205
|
+
await response.body?.cancel()
|
|
206
|
+
let nextInit = redirectInit(response.status, currentInit)
|
|
207
|
+
if (new URL(nextUrl).origin !== new URL(currentUrl).origin) {
|
|
208
|
+
nextInit = stripCredentialHeaders(nextInit)
|
|
209
|
+
}
|
|
210
|
+
currentInit = nextInit
|
|
211
|
+
currentUrl = nextUrl
|
|
212
|
+
}
|
|
213
|
+
}
|