@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.
Files changed (198) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +32 -25
  24. package/dist/permissions.js +104 -185
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +9 -34
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +16 -39
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. 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
+ }