@pikku/core 0.12.77 → 0.12.79
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 +92 -0
- package/dist/services/in-memory-workflow-service.d.ts +11 -0
- package/dist/services/in-memory-workflow-service.js +43 -0
- package/dist/utils/node-host-resolver.d.ts +12 -0
- package/dist/utils/node-host-resolver.js +16 -0
- package/dist/utils/safe-fetch.d.ts +18 -0
- package/dist/utils/safe-fetch.js +167 -29
- package/dist/wirings/gateway/gateway-runner.js +32 -3
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +87 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +155 -0
- package/knowledge/decisions/security/gateway-handlers-run-through-the-function-runner-gate.md +13 -3
- package/package.json +2 -1
- package/src/services/in-memory-workflow-service.ts +52 -0
- package/src/utils/node-host-resolver.ts +20 -0
- package/src/utils/safe-fetch.test.ts +143 -1
- package/src/utils/safe-fetch.ts +200 -25
- package/src/wirings/gateway/gateway-authorization.test.ts +131 -0
- package/src/wirings/gateway/gateway-runner.ts +35 -3
- package/src/wirings/secret/validate-secret-definitions.test.ts +47 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +198 -0
- package/src/wirings/workflow/workflow-dispatch-relay.test.ts +128 -0
- package/src/wirings/workflow/workflow-stalled-recovery.test.ts +106 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/utils/safe-fetch.ts
CHANGED
|
@@ -32,6 +32,134 @@ function parseIPv4Octets(
|
|
|
32
32
|
return octets as [number, number, number, number]
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* IPv4 blocks that must never be reachable from user-supplied URLs: private,
|
|
37
|
+
* loopback, link-local (cloud metadata), carrier-grade NAT (Alibaba's
|
|
38
|
+
* `100.100.100.200` metadata endpoint), IETF protocol assignments, benchmarking,
|
|
39
|
+
* 6to4 anycast, the documentation TEST-NETs, multicast and reserved space.
|
|
40
|
+
*/
|
|
41
|
+
const PRIVATE_IPV4_BLOCKS: ReadonlyArray<readonly [string, number]> = [
|
|
42
|
+
['0.0.0.0', 8],
|
|
43
|
+
['10.0.0.0', 8],
|
|
44
|
+
['100.64.0.0', 10],
|
|
45
|
+
['127.0.0.0', 8],
|
|
46
|
+
['169.254.0.0', 16],
|
|
47
|
+
['172.16.0.0', 12],
|
|
48
|
+
['192.0.0.0', 24],
|
|
49
|
+
['192.0.2.0', 24],
|
|
50
|
+
['192.88.99.0', 24],
|
|
51
|
+
['192.168.0.0', 16],
|
|
52
|
+
['198.18.0.0', 15],
|
|
53
|
+
['198.51.100.0', 24],
|
|
54
|
+
['203.0.113.0', 24],
|
|
55
|
+
['224.0.0.0', 4],
|
|
56
|
+
['240.0.0.0', 4],
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
const toUint32 = (octets: [number, number, number, number]): number =>
|
|
60
|
+
((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0
|
|
61
|
+
|
|
62
|
+
const PRIVATE_IPV4_RANGES = PRIVATE_IPV4_BLOCKS.map(([base, prefix]) => {
|
|
63
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
|
|
64
|
+
return [(toUint32(parseIPv4Octets(base)!) & mask) >>> 0, mask] as const
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const isPrivateIPv4 = (octets: [number, number, number, number]): boolean => {
|
|
68
|
+
const addr = toUint32(octets)
|
|
69
|
+
return PRIVATE_IPV4_RANGES.some(
|
|
70
|
+
([base, mask]) => (addr & mask) >>> 0 === base
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Expands an IPv6 literal into its eight 16-bit groups, handling `::` elision
|
|
76
|
+
* and a trailing dotted-quad. `null` when not a well-formed IPv6 literal.
|
|
77
|
+
*/
|
|
78
|
+
function parseIPv6Groups(host: string): number[] | null {
|
|
79
|
+
const zoneless = host.split('%')[0]!
|
|
80
|
+
const halves = zoneless.split('::')
|
|
81
|
+
if (halves.length > 2) return null
|
|
82
|
+
|
|
83
|
+
const parseSide = (side: string): number[] | null => {
|
|
84
|
+
if (side === '') return []
|
|
85
|
+
const parts = side.split(':')
|
|
86
|
+
const groups: number[] = []
|
|
87
|
+
for (let i = 0; i < parts.length; i++) {
|
|
88
|
+
const part = parts[i]!
|
|
89
|
+
if (i === parts.length - 1 && part.includes('.')) {
|
|
90
|
+
const v4 = parseIPv4Octets(part)
|
|
91
|
+
if (!v4) return null
|
|
92
|
+
groups.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3])
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (!/^[0-9a-f]{1,4}$/.test(part)) return null
|
|
96
|
+
groups.push(parseInt(part, 16))
|
|
97
|
+
}
|
|
98
|
+
return groups
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const head = parseSide(halves[0]!)
|
|
102
|
+
if (head === null) return null
|
|
103
|
+
|
|
104
|
+
if (halves.length === 1) {
|
|
105
|
+
return head.length === 8 ? head : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tail = parseSide(halves[1]!)
|
|
109
|
+
if (tail === null) return null
|
|
110
|
+
if (head.length + tail.length > 7) return null
|
|
111
|
+
return [...head, ...new Array(8 - head.length - tail.length).fill(0), ...tail]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const embeddedIPv4 = (
|
|
115
|
+
hi: number,
|
|
116
|
+
lo: number
|
|
117
|
+
): [number, number, number, number] => [
|
|
118
|
+
(hi >> 8) & 0xff,
|
|
119
|
+
hi & 0xff,
|
|
120
|
+
(lo >> 8) & 0xff,
|
|
121
|
+
lo & 0xff,
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
function isPrivateIPv6(groups: number[]): boolean {
|
|
125
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups as [
|
|
126
|
+
number,
|
|
127
|
+
number,
|
|
128
|
+
number,
|
|
129
|
+
number,
|
|
130
|
+
number,
|
|
131
|
+
number,
|
|
132
|
+
number,
|
|
133
|
+
number,
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
if (groups.every((g) => g === 0)) return true // :: unspecified
|
|
137
|
+
if (groups.slice(0, 7).every((g) => g === 0) && g7 === 1) return true // ::1
|
|
138
|
+
|
|
139
|
+
// IPv4-mapped ::ffff:0:0/96 and IPv4-compatible ::/96
|
|
140
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0) {
|
|
141
|
+
if (g5 === 0xffff || g5 === 0) return isPrivateIPv4(embeddedIPv4(g6, g7))
|
|
142
|
+
}
|
|
143
|
+
// NAT64 well-known prefix 64:ff9b::/96
|
|
144
|
+
if (
|
|
145
|
+
g0 === 0x64 &&
|
|
146
|
+
g1 === 0xff9b &&
|
|
147
|
+
g2 === 0 &&
|
|
148
|
+
g3 === 0 &&
|
|
149
|
+
g4 === 0 &&
|
|
150
|
+
g5 === 0
|
|
151
|
+
)
|
|
152
|
+
return isPrivateIPv4(embeddedIPv4(g6, g7))
|
|
153
|
+
// 6to4 2002::/16 carries the IPv4 address in the next 32 bits
|
|
154
|
+
if (g0 === 0x2002) return isPrivateIPv4(embeddedIPv4(g1, g2))
|
|
155
|
+
if (g0 === 0x100 && g1 === 0 && g2 === 0 && g3 === 0) return true // discard-only 100::/64
|
|
156
|
+
if ((g0 & 0xffc0) === 0xfe80) return true // link-local fe80::/10
|
|
157
|
+
if ((g0 & 0xfe00) === 0xfc00) return true // unique-local fc00::/7
|
|
158
|
+
if ((g0 & 0xffc0) === 0xfec0) return true // deprecated site-local fec0::/10
|
|
159
|
+
if ((g0 & 0xff00) === 0xff00) return true // multicast ff00::/8
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
35
163
|
/**
|
|
36
164
|
* Whether a hostname is an obvious internal target. Best-effort literal
|
|
37
165
|
* matching only: it cannot catch a public hostname that *resolves* to a
|
|
@@ -46,31 +174,30 @@ export function isPrivateHost(hostname: string): boolean {
|
|
|
46
174
|
return true
|
|
47
175
|
|
|
48
176
|
if (host.includes(':')) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (mappedV4) return isPrivateHost(mappedV4[1]!)
|
|
52
|
-
const mappedHex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
53
|
-
if (mappedHex) {
|
|
54
|
-
const hi = parseInt(mappedHex[1]!, 16)
|
|
55
|
-
const lo = parseInt(mappedHex[2]!, 16)
|
|
56
|
-
return isPrivateHost(
|
|
57
|
-
`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
if (/^fe[89ab]/.test(host)) return true // link-local fe80::/10
|
|
61
|
-
if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local fc00::/7
|
|
62
|
-
return false
|
|
177
|
+
const groups = parseIPv6Groups(host)
|
|
178
|
+
return groups ? isPrivateIPv6(groups) : false
|
|
63
179
|
}
|
|
64
180
|
|
|
65
181
|
const v4 = parseIPv4Octets(host)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
182
|
+
return v4 ? isPrivateIPv4(v4) : false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Resolves a hostname to the IP addresses it points at. */
|
|
186
|
+
export type HostResolver = (hostname: string) => Promise<string[]>
|
|
187
|
+
|
|
188
|
+
let defaultHostResolver: HostResolver | undefined
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Installs the resolver {@link safeFetch} uses when a call passes no
|
|
192
|
+
* `resolveHost` of its own.
|
|
193
|
+
*
|
|
194
|
+
* Core cannot resolve DNS itself — Workers has no DNS API — so without a
|
|
195
|
+
* resolver the guard is literal-only and a public name pointing at
|
|
196
|
+
* `169.254.169.254` passes. Node runtimes install
|
|
197
|
+
* `nodeHostResolver` from `@pikku/core/node-host-resolver` at startup.
|
|
198
|
+
*/
|
|
199
|
+
export function setDefaultHostResolver(resolver: HostResolver | undefined) {
|
|
200
|
+
defaultHostResolver = resolver
|
|
74
201
|
}
|
|
75
202
|
|
|
76
203
|
export interface SafeFetchOptions {
|
|
@@ -81,6 +208,50 @@ export interface SafeFetchOptions {
|
|
|
81
208
|
allowedHosts?: string[]
|
|
82
209
|
/** Maximum redirect hops to follow (each re-validated). Defaults to 3. */
|
|
83
210
|
maxRedirects?: number
|
|
211
|
+
/**
|
|
212
|
+
* Resolves a hostname so a *public* name pointing at a private address is
|
|
213
|
+
* refused. Defaults to whatever {@link setDefaultHostResolver} installed;
|
|
214
|
+
* pass `null` to opt a call out of resolution entirely.
|
|
215
|
+
*/
|
|
216
|
+
resolveHost?: HostResolver | null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Whether a hostname is already an IP literal, which the sync check covers. */
|
|
220
|
+
function isIpLiteral(hostname: string): boolean {
|
|
221
|
+
const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '')
|
|
222
|
+
if (host.includes(':')) return parseIPv6Groups(host.toLowerCase()) !== null
|
|
223
|
+
return parseIPv4Octets(host) !== null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Rejects a hostname that resolves to an internal address.
|
|
228
|
+
*
|
|
229
|
+
* Resolution happens once per hop and the connection is not pinned to the
|
|
230
|
+
* address checked, so a rebind between this check and the socket connecting is
|
|
231
|
+
* still possible; catching that needs a runtime-level connect hook.
|
|
232
|
+
*/
|
|
233
|
+
async function assertResolvedHostAllowed(
|
|
234
|
+
hostname: string,
|
|
235
|
+
options: SafeFetchOptions
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
if (options.allowedHosts) return
|
|
238
|
+
const resolver =
|
|
239
|
+
options.resolveHost === null
|
|
240
|
+
? undefined
|
|
241
|
+
: (options.resolveHost ?? defaultHostResolver)
|
|
242
|
+
if (!resolver || isIpLiteral(hostname)) return
|
|
243
|
+
|
|
244
|
+
const addresses = await resolver(hostname)
|
|
245
|
+
if (addresses.length === 0) {
|
|
246
|
+
throw new Error(`Refusing to fetch: '${hostname}' resolved to no addresses`)
|
|
247
|
+
}
|
|
248
|
+
for (const address of addresses) {
|
|
249
|
+
if (isPrivateHost(address)) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Refusing to fetch from a private/internal host: '${hostname}' resolves to ${address}`
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
84
255
|
}
|
|
85
256
|
|
|
86
257
|
export function assertFetchableUrl(
|
|
@@ -142,7 +313,9 @@ export async function safeFetch(
|
|
|
142
313
|
options: SafeFetchOptions = {}
|
|
143
314
|
): Promise<Response> {
|
|
144
315
|
const maxRedirects = options.maxRedirects ?? 3
|
|
145
|
-
|
|
316
|
+
const initial = assertFetchableUrl(url, options)
|
|
317
|
+
await assertResolvedHostAllowed(initial.hostname, options)
|
|
318
|
+
let currentUrl = initial.toString()
|
|
146
319
|
let currentInit = init
|
|
147
320
|
|
|
148
321
|
for (let hop = 0; ; hop++) {
|
|
@@ -157,10 +330,12 @@ export async function safeFetch(
|
|
|
157
330
|
if (!location || hop >= maxRedirects) {
|
|
158
331
|
return response
|
|
159
332
|
}
|
|
160
|
-
const
|
|
333
|
+
const next = assertFetchableUrl(
|
|
161
334
|
new URL(location, currentUrl).toString(),
|
|
162
335
|
options
|
|
163
|
-
)
|
|
336
|
+
)
|
|
337
|
+
await assertResolvedHostAllowed(next.hostname, options)
|
|
338
|
+
const nextUrl = next.toString()
|
|
164
339
|
await response.body?.cancel()
|
|
165
340
|
let nextInit = redirectInit(response.status, currentInit)
|
|
166
341
|
if (new URL(nextUrl).origin !== new URL(currentUrl).origin) {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
addGlobalPermission,
|
|
9
9
|
clearPermissionsCache,
|
|
10
10
|
} from '../../permissions.js'
|
|
11
|
+
import { addTagMiddleware } from '../../middleware-runner.js'
|
|
11
12
|
import type {
|
|
12
13
|
GatewayAdapter,
|
|
13
14
|
GatewayInboundMessage,
|
|
@@ -84,6 +85,31 @@ const seedCompiledMeta = () => {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* What the inspector records for a gateway and the function it was wired with.
|
|
90
|
+
* These tests wire gateways by hand, so nothing populates it otherwise — and it
|
|
91
|
+
* has to be in place before `wireGateway`, exactly as the generated bootstrap
|
|
92
|
+
* loads every meta file before any wiring file.
|
|
93
|
+
*/
|
|
94
|
+
const seedDeclaredHandler = (
|
|
95
|
+
gatewayName: string,
|
|
96
|
+
funcId: string,
|
|
97
|
+
funcMeta: Record<string, any>,
|
|
98
|
+
gatewayMeta: Record<string, any> = {}
|
|
99
|
+
) => {
|
|
100
|
+
;(pikkuState(null, 'function', 'meta') as any)[funcId] = {
|
|
101
|
+
pikkuFuncId: funcId,
|
|
102
|
+
inputSchemaName: null,
|
|
103
|
+
outputSchemaName: null,
|
|
104
|
+
...funcMeta,
|
|
105
|
+
}
|
|
106
|
+
;(pikkuState(null, 'gateway', 'meta') as any)[gatewayName] = {
|
|
107
|
+
pikkuFuncId: funcId,
|
|
108
|
+
name: gatewayName,
|
|
109
|
+
...gatewayMeta,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
87
113
|
const postMessage = async (route: string) => {
|
|
88
114
|
const request = new Request(`http://localhost${route}`, {
|
|
89
115
|
method: 'POST',
|
|
@@ -229,6 +255,68 @@ describe('gateway handler authorization', () => {
|
|
|
229
255
|
assert.deepEqual(calls, ['ran'])
|
|
230
256
|
})
|
|
231
257
|
|
|
258
|
+
test('a handler declared with pikkuFunc keeps its session requirement', async () => {
|
|
259
|
+
const calls: string[] = []
|
|
260
|
+
|
|
261
|
+
seedDeclaredHandler('declared-session', 'myHandler', {
|
|
262
|
+
sessionless: false,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
wireGateway({
|
|
266
|
+
name: 'declared-session',
|
|
267
|
+
type: 'webhook',
|
|
268
|
+
route: '/webhooks/declared-session',
|
|
269
|
+
adapter: createMockAdapter(),
|
|
270
|
+
func: {
|
|
271
|
+
func: async () => {
|
|
272
|
+
calls.push('ran')
|
|
273
|
+
return { text: 'reply' }
|
|
274
|
+
},
|
|
275
|
+
} as any,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
seedCompiledMeta()
|
|
279
|
+
httpRouter.initialize()
|
|
280
|
+
|
|
281
|
+
const response = await postMessage('/webhooks/declared-session')
|
|
282
|
+
|
|
283
|
+
assert.equal(response.status, 403)
|
|
284
|
+
assert.deepEqual(
|
|
285
|
+
calls,
|
|
286
|
+
[],
|
|
287
|
+
'a session-required pikkuFunc must not be silently made sessionless'
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('a handler declared sessionless keeps running without a session', async () => {
|
|
292
|
+
const calls: string[] = []
|
|
293
|
+
|
|
294
|
+
seedDeclaredHandler('declared-sessionless', 'mySessionlessHandler', {
|
|
295
|
+
sessionless: true,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
wireGateway({
|
|
299
|
+
name: 'declared-sessionless',
|
|
300
|
+
type: 'webhook',
|
|
301
|
+
route: '/webhooks/declared-sessionless',
|
|
302
|
+
adapter: createMockAdapter(),
|
|
303
|
+
func: {
|
|
304
|
+
func: async () => {
|
|
305
|
+
calls.push('ran')
|
|
306
|
+
return { text: 'reply' }
|
|
307
|
+
},
|
|
308
|
+
} as any,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
seedCompiledMeta()
|
|
312
|
+
httpRouter.initialize()
|
|
313
|
+
|
|
314
|
+
const response = await postMessage('/webhooks/declared-sessionless')
|
|
315
|
+
|
|
316
|
+
assert.equal(response.status, 200)
|
|
317
|
+
assert.deepEqual(calls, ['ran'])
|
|
318
|
+
})
|
|
319
|
+
|
|
232
320
|
test('gateway-level auth: true requires a session', async () => {
|
|
233
321
|
const calls: string[] = []
|
|
234
322
|
|
|
@@ -281,6 +369,49 @@ describe('gateway handler authorization', () => {
|
|
|
281
369
|
assert.deepEqual(calls, [], 'auth: true must require a session')
|
|
282
370
|
})
|
|
283
371
|
|
|
372
|
+
test('tag middleware declared for the gateway actually runs', async () => {
|
|
373
|
+
const order: string[] = []
|
|
374
|
+
|
|
375
|
+
addTagMiddleware('audited', [
|
|
376
|
+
async (_s: any, _wire: any, next: any) => {
|
|
377
|
+
order.push('middleware')
|
|
378
|
+
await next()
|
|
379
|
+
},
|
|
380
|
+
] as any)
|
|
381
|
+
|
|
382
|
+
seedDeclaredHandler(
|
|
383
|
+
'tagged',
|
|
384
|
+
'myTaggedHandler',
|
|
385
|
+
{ sessionless: true },
|
|
386
|
+
{ tags: ['audited'], middleware: [{ type: 'tag', tag: 'audited' }] }
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
wireGateway({
|
|
390
|
+
name: 'tagged',
|
|
391
|
+
type: 'webhook',
|
|
392
|
+
route: '/webhooks/tagged',
|
|
393
|
+
adapter: createMockAdapter(),
|
|
394
|
+
func: {
|
|
395
|
+
func: async () => {
|
|
396
|
+
order.push('handler')
|
|
397
|
+
return { text: 'reply' }
|
|
398
|
+
},
|
|
399
|
+
} as any,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
seedCompiledMeta()
|
|
403
|
+
httpRouter.initialize()
|
|
404
|
+
|
|
405
|
+
const response = await postMessage('/webhooks/tagged')
|
|
406
|
+
|
|
407
|
+
assert.equal(response.status, 200)
|
|
408
|
+
assert.deepEqual(
|
|
409
|
+
order,
|
|
410
|
+
['middleware', 'handler'],
|
|
411
|
+
'addTagMiddleware must gate a gateway carrying the tag'
|
|
412
|
+
)
|
|
413
|
+
})
|
|
414
|
+
|
|
284
415
|
test('the adapter still auto-sends the handler reply', async () => {
|
|
285
416
|
const adapter = createMockAdapter()
|
|
286
417
|
|
|
@@ -23,20 +23,46 @@ const bridgeMiddlewareSession = async (wire: PikkuRawWire): Promise<void> => {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Metadata the inspector recorded for the function the gateway was wired with.
|
|
28
|
+
* The bootstrap loads every meta file before any wiring file, so this is
|
|
29
|
+
* already populated by the time a gateway wires itself. A gateway wired by
|
|
30
|
+
* hand rather than through codegen has no entry, and falls back to the
|
|
31
|
+
* sessionless default below.
|
|
32
|
+
*/
|
|
33
|
+
const declaredHandlerMeta = (config: CoreGateway) => {
|
|
34
|
+
const declaredFuncId = pikkuState(null, 'gateway', 'meta')[config.name]
|
|
35
|
+
?.pikkuFuncId
|
|
36
|
+
return declaredFuncId
|
|
37
|
+
? pikkuState(null, 'function', 'meta')[declaredFuncId]
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
// knowledge: decisions/security/gateway-handlers-run-through-the-function-runner-gate.md
|
|
27
42
|
const registerGatewayHandler = (config: CoreGateway): string => {
|
|
28
43
|
const funcId = gatewayHandlerFuncId(config.name)
|
|
29
44
|
const funcMeta = pikkuState(null, 'function', 'meta')
|
|
45
|
+
const declared = declaredHandlerMeta(config)
|
|
30
46
|
funcMeta[funcId] = {
|
|
47
|
+
...declared,
|
|
31
48
|
pikkuFuncId: funcId,
|
|
32
|
-
inputSchemaName: null,
|
|
33
|
-
outputSchemaName: null,
|
|
34
|
-
sessionless: true,
|
|
49
|
+
inputSchemaName: declared?.inputSchemaName ?? null,
|
|
50
|
+
outputSchemaName: declared?.outputSchemaName ?? null,
|
|
51
|
+
sessionless: declared?.sessionless ?? true,
|
|
35
52
|
}
|
|
36
53
|
addFunction(funcId, config.func as any)
|
|
37
54
|
return funcId
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Tag middleware the inspector resolved for this gateway. It is keyed by
|
|
59
|
+
* gateway name rather than reachable from `config`, because `tags` is a
|
|
60
|
+
* compile-time input everywhere — nothing at runtime maps a tag to its
|
|
61
|
+
* middleware group.
|
|
62
|
+
*/
|
|
63
|
+
const gatewayInheritedMiddleware = (config: CoreGateway) =>
|
|
64
|
+
pikkuState(null, 'gateway', 'meta')[config.name]?.middleware
|
|
65
|
+
|
|
40
66
|
export const resolveGatewayAdapter = (
|
|
41
67
|
config: CoreGateway,
|
|
42
68
|
services: CoreSingletonServices
|
|
@@ -134,6 +160,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
|
|
|
134
160
|
const createWebhookPostHandler = (config: CoreGateway) => {
|
|
135
161
|
const { name, middleware: userMiddleware } = config
|
|
136
162
|
const handlerFuncId = registerGatewayHandler(config)
|
|
163
|
+
const inheritedMiddleware = gatewayInheritedMiddleware(config)
|
|
137
164
|
|
|
138
165
|
return async (
|
|
139
166
|
services: CoreSingletonServices,
|
|
@@ -169,6 +196,7 @@ const createWebhookPostHandler = (config: CoreGateway) => {
|
|
|
169
196
|
singletonServices: services,
|
|
170
197
|
data: () => parsed,
|
|
171
198
|
auth: config.auth,
|
|
199
|
+
inheritedMiddleware,
|
|
172
200
|
wire: wire as any,
|
|
173
201
|
})
|
|
174
202
|
}
|
|
@@ -253,6 +281,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
253
281
|
|
|
254
282
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
255
283
|
const handlerFuncId = registerGatewayHandler(config)
|
|
284
|
+
const inheritedMiddleware = gatewayInheritedMiddleware(config)
|
|
256
285
|
|
|
257
286
|
addFunction(connectFuncId, {
|
|
258
287
|
auth: false,
|
|
@@ -292,6 +321,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
292
321
|
singletonServices: services,
|
|
293
322
|
data: () => parsed,
|
|
294
323
|
auth: config.auth,
|
|
324
|
+
inheritedMiddleware,
|
|
295
325
|
wire: wire as any,
|
|
296
326
|
})
|
|
297
327
|
}
|
|
@@ -333,6 +363,7 @@ export const createListenerMessageHandler = (
|
|
|
333
363
|
): ((rawData: unknown) => Promise<void>) => {
|
|
334
364
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
335
365
|
const handlerFuncId = registerGatewayHandler(config)
|
|
366
|
+
const inheritedMiddleware = gatewayInheritedMiddleware(config)
|
|
336
367
|
|
|
337
368
|
return async (rawData: unknown): Promise<void> => {
|
|
338
369
|
const adapter = await resolveGatewayAdapter(config, singletonServices)
|
|
@@ -354,6 +385,7 @@ export const createListenerMessageHandler = (
|
|
|
354
385
|
singletonServices,
|
|
355
386
|
data: () => parsed,
|
|
356
387
|
auth: config.auth,
|
|
388
|
+
inheritedMiddleware,
|
|
357
389
|
wire,
|
|
358
390
|
})
|
|
359
391
|
}
|
|
@@ -108,6 +108,53 @@ describe('validateAndBuildSecretDefinitionsMeta', () => {
|
|
|
108
108
|
)
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
test('should carry allowedHosts into the meta', () => {
|
|
112
|
+
const definitions = [
|
|
113
|
+
{
|
|
114
|
+
name: 'example-api',
|
|
115
|
+
displayName: 'Example API',
|
|
116
|
+
secretId: 'EXAMPLE_API_CREDENTIALS',
|
|
117
|
+
allowedHosts: ['api.example.com', '*.example.com'],
|
|
118
|
+
sourceFile: 'a.ts',
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
const result = validateAndBuildSecretDefinitionsMeta(
|
|
122
|
+
definitions as any,
|
|
123
|
+
new Map()
|
|
124
|
+
)
|
|
125
|
+
assert.deepStrictEqual(result['example-api']!.allowedHosts, [
|
|
126
|
+
'api.example.com',
|
|
127
|
+
'*.example.com',
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('should carry allowedHosts into the meta for a shared secretId', () => {
|
|
132
|
+
const definitions = [
|
|
133
|
+
{
|
|
134
|
+
name: 'cred1',
|
|
135
|
+
displayName: 'Cred 1',
|
|
136
|
+
secretId: 'SHARED',
|
|
137
|
+
allowedHosts: ['first.example.com'],
|
|
138
|
+
sourceFile: 'a.ts',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'cred2',
|
|
142
|
+
displayName: 'Cred 2',
|
|
143
|
+
secretId: 'SHARED',
|
|
144
|
+
allowedHosts: ['second.example.com'],
|
|
145
|
+
sourceFile: 'b.ts',
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
const result = validateAndBuildSecretDefinitionsMeta(
|
|
149
|
+
definitions as any,
|
|
150
|
+
new Map()
|
|
151
|
+
)
|
|
152
|
+
assert.deepStrictEqual(result['cred1']!.allowedHosts, ['first.example.com'])
|
|
153
|
+
assert.deepStrictEqual(result['cred2']!.allowedHosts, [
|
|
154
|
+
'second.example.com',
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
|
|
111
158
|
test('should handle empty definitions', () => {
|
|
112
159
|
const result = validateAndBuildSecretDefinitionsMeta([], new Map())
|
|
113
160
|
assert.deepStrictEqual(result, {})
|
|
@@ -57,6 +57,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
57
57
|
oauth2: def.oauth2,
|
|
58
58
|
rotationPeriod: def.rotationPeriod,
|
|
59
59
|
docsUrl: def.docsUrl,
|
|
60
|
+
allowedHosts: def.allowedHosts,
|
|
60
61
|
sourceFile: def.sourceFile,
|
|
61
62
|
}
|
|
62
63
|
}
|
|
@@ -75,6 +76,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
75
76
|
oauth2: def.oauth2,
|
|
76
77
|
rotationPeriod: def.rotationPeriod,
|
|
77
78
|
docsUrl: def.docsUrl,
|
|
79
|
+
allowedHosts: def.allowedHosts,
|
|
78
80
|
sourceFile: def.sourceFile,
|
|
79
81
|
}
|
|
80
82
|
}
|