@ouraihub/aether-worker 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ import { badRequest, KitFailure } from '@ouraihub/aether-core'
2
+ import type { OutboundHttp } from '@ouraihub/aether-core/ports'
3
+ import type { FetchBinding } from './cloudflare.ts'
4
+
5
+ export interface OutboundLimits {
6
+ readonly firstByteMs: number
7
+ readonly idleMs: number
8
+ readonly totalMs: number
9
+ readonly maxResponseBytes: number
10
+ }
11
+ /** This binding MUST enforce public-only DNS at connect time, including all resolved
12
+ * IPv4/IPv6 addresses and rebinding. A DNS preflight followed by ordinary fetch is
13
+ * not this contract. It must not follow redirects or forward through an open proxy.
14
+ * Deploy a trusted egress service binding; absent that binding remote egress fails.
15
+ */
16
+ export interface TrustedOutboundGateway {
17
+ readonly security: 'public-addresses-at-connect-time'
18
+ readonly binding: FetchBinding
19
+ }
20
+ /** Separately deployed, target-bound internal gateway. It must enforce exact
21
+ * configured origin/port at connect time, including DNS, with no redirect/proxy. */
22
+ export interface TrustedPrivateOutboundGateway {
23
+ readonly security: 'explicit-private-targets-at-connect-time'
24
+ readonly binding: FetchBinding
25
+ readonly allowedOrigins: readonly string[]
26
+ }
27
+ export interface WorkerOutboundOptions {
28
+ readonly gateway?: TrustedOutboundGateway
29
+ readonly privateGateway?: TrustedPrivateOutboundGateway
30
+ readonly allowedOrigins: readonly string[]
31
+ readonly limits: OutboundLimits
32
+ /** Local integration tests only, literal loopback hosts; never hostname DNS. */
33
+ readonly allowLoopbackForTests?: boolean
34
+ readonly localFetch?: (url: string, init: RequestInit) => Promise<Response>
35
+ }
36
+ function isLoopback(hostname: string): boolean {
37
+ return hostname === '[::1]' || /^127(?:\.(?:\d{1,3})){3}$/u.test(hostname)
38
+ }
39
+ function privateLiteral(host: string): boolean {
40
+ if (host.startsWith('[')) {
41
+ const ip = host.slice(1, -1).toLowerCase()
42
+ // Public IPv6 unicast only; mapped/compatible IPv4, special and local ranges fail closed.
43
+ return (
44
+ !/^[23][0-9a-f]{0,3}:/u.test(ip) ||
45
+ ip.startsWith('2001:db8:') ||
46
+ ip.startsWith('2001:0:') ||
47
+ ip.startsWith('2002:')
48
+ )
49
+ }
50
+ if (!/^\d+(?:\.\d+){3}$/u.test(host))
51
+ return host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || !host.includes('.')
52
+ const parts = host.split('.').map(Number)
53
+ const a = parts[0],
54
+ b = parts[1],
55
+ c = parts[2]
56
+ return (
57
+ a === undefined ||
58
+ b === undefined ||
59
+ c === undefined ||
60
+ a === 0 ||
61
+ a === 10 ||
62
+ a === 127 ||
63
+ a >= 224 ||
64
+ (a === 100 && b >= 64 && b <= 127) ||
65
+ (a === 169 && b === 254) ||
66
+ (a === 172 && b >= 16 && b <= 31) ||
67
+ (a === 192 && (b === 168 || b === 0 || b === 2)) ||
68
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
69
+ (a === 203 && b === 0 && c === 113)
70
+ )
71
+ }
72
+ export function createWorkerOutbound(options: WorkerOutboundOptions): OutboundHttp {
73
+ const limits = Object.freeze({ ...options.limits })
74
+ for (const value of Object.values(limits))
75
+ if (!Number.isSafeInteger(value) || value <= 0) throw badRequest('outboundLimits', 'invalid')
76
+ const origins = new Set(
77
+ options.allowedOrigins.map((origin) => {
78
+ let url: URL
79
+ try {
80
+ url = new URL(origin)
81
+ } catch {
82
+ throw badRequest('allowedOrigins', 'invalid')
83
+ }
84
+ if (url.origin !== origin || url.username || url.password) throw badRequest('allowedOrigins', 'invalid')
85
+ return origin
86
+ }),
87
+ )
88
+ if (origins.size === 0) throw new KitFailure('needs_config', 'Outbound origins must be explicitly configured')
89
+ const privateOrigins = new Set(options.privateGateway?.allowedOrigins ?? [])
90
+ for (const origin of privateOrigins) {
91
+ let target: URL
92
+ try {
93
+ target = new URL(origin)
94
+ } catch {
95
+ throw badRequest('privateOrigins', 'invalid')
96
+ }
97
+ if (
98
+ target.origin !== origin ||
99
+ !origins.has(origin) ||
100
+ (target.protocol !== 'http:' && target.protocol !== 'https:')
101
+ )
102
+ throw badRequest('privateOrigins', 'invalid')
103
+ }
104
+ return Object.freeze({
105
+ async fetch(url: string, init: RequestInit): Promise<Response> {
106
+ let target: URL
107
+ try {
108
+ target = new URL(url)
109
+ } catch {
110
+ throw badRequest('url', 'invalid')
111
+ }
112
+ const local = options.allowLoopbackForTests === true && isLoopback(target.hostname)
113
+ const privateTarget = privateOrigins.has(target.origin)
114
+ if (
115
+ target.username ||
116
+ target.password ||
117
+ target.hash ||
118
+ (!local && !privateTarget && target.protocol !== 'https:') ||
119
+ ((local || privateTarget) && target.protocol !== 'https:' && target.protocol !== 'http:') ||
120
+ (!local && !privateTarget && privateLiteral(target.hostname)) ||
121
+ !origins.has(target.origin)
122
+ )
123
+ throw badRequest('url', 'outbound_policy')
124
+ const gateway = privateTarget ? options.privateGateway : options.gateway
125
+ if (
126
+ !local &&
127
+ (!gateway ||
128
+ gateway.security !==
129
+ (privateTarget ? 'explicit-private-targets-at-connect-time' : 'public-addresses-at-connect-time'))
130
+ )
131
+ throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
132
+ const abort = new AbortController()
133
+ let failure: KitFailure | null = null
134
+ const firstTimer = setTimeout(
135
+ () => fail(new KitFailure('timeout', 'Outbound first-byte deadline exceeded')),
136
+ limits.firstByteMs,
137
+ )
138
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
139
+ const totalTimer = setTimeout(
140
+ () => fail(new KitFailure('timeout', 'Outbound total deadline exceeded')),
141
+ limits.totalMs,
142
+ )
143
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
144
+ let bodyController: ReadableStreamDefaultController<Uint8Array> | undefined
145
+ let finished = false
146
+ function clean(): void {
147
+ clearTimeout(firstTimer)
148
+ clearTimeout(idleTimer ?? 0)
149
+ clearTimeout(totalTimer)
150
+ init.signal?.removeEventListener('abort', callerAbort)
151
+ abort.signal.removeEventListener('abort', onAbort)
152
+ }
153
+ function fail(error: KitFailure): void {
154
+ if (!abort.signal.aborted) {
155
+ failure = error
156
+ abort.abort()
157
+ }
158
+ }
159
+ function callerAbort(): void {
160
+ fail(new KitFailure('cancelled', 'Outbound request cancelled'))
161
+ }
162
+ function onAbort(): void {
163
+ if (finished) return
164
+ finished = true
165
+ bodyController?.error(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
166
+ if (reader) void reader.cancel().catch(() => {})
167
+ clean()
168
+ }
169
+ init.signal?.addEventListener('abort', callerAbort, { once: true })
170
+ abort.signal.addEventListener('abort', onAbort, { once: true })
171
+ if (init.signal?.aborted) callerAbort()
172
+ if (abort.signal.aborted) throw failure ?? new KitFailure('cancelled', 'Outbound request cancelled')
173
+ try {
174
+ const headers = new Headers(init.headers)
175
+ headers.set('User-Agent', 'Aether-Worker/1')
176
+ const request: RequestInit & { credentials: 'omit' } = {
177
+ ...init,
178
+ headers,
179
+ redirect: 'manual',
180
+ signal: abort.signal,
181
+ credentials: 'omit',
182
+ }
183
+ // Remote fetch is always the trusted binding. Global fetch is only the literal-loopback opt-in.
184
+ const pending = local
185
+ ? (options.localFetch ?? fetch)(target.href, request)
186
+ : gateway?.binding.fetch(target.href, request)
187
+ if (!pending) throw new KitFailure('needs_config', 'Trusted connect-time outbound gateway is required')
188
+ void pending.then(
189
+ (response) => {
190
+ if (abort.signal.aborted) void response.body?.cancel().catch(() => {})
191
+ },
192
+ () => {},
193
+ )
194
+ const aborted = Promise.withResolvers<never>()
195
+ const rejectAbort = () => aborted.reject(failure ?? new KitFailure('cancelled', 'Outbound request cancelled'))
196
+ abort.signal.addEventListener('abort', rejectAbort, { once: true })
197
+ let response: Response
198
+ try {
199
+ response = await Promise.race([pending, aborted.promise])
200
+ } finally {
201
+ abort.signal.removeEventListener('abort', rejectAbort)
202
+ }
203
+ if (abort.signal.aborted) {
204
+ void response.body?.cancel().catch(() => {})
205
+ throw failure
206
+ }
207
+ if (response.redirected || (response.url && response.url !== target.href)) {
208
+ void response.body?.cancel().catch(() => {})
209
+ throw badRequest('url', 'redirect_forbidden')
210
+ }
211
+ const length = response.headers.get('content-length')
212
+ if (length !== null && (!/^\d+$/u.test(length) || Number(length) > limits.maxResponseBytes)) {
213
+ void response.body?.cancel().catch(() => {})
214
+ throw badRequest('response', 'too_large', limits.maxResponseBytes)
215
+ }
216
+ if (!response.body) {
217
+ finished = true
218
+ clean()
219
+ return response
220
+ }
221
+ reader = response.body.getReader()
222
+ let received = 0
223
+ const body = new ReadableStream<Uint8Array>({
224
+ start(controller) {
225
+ bodyController = controller
226
+ },
227
+ async pull(controller) {
228
+ if (!reader || finished) return
229
+ idleTimer = setTimeout(
230
+ () => fail(new KitFailure('timeout', 'Outbound idle deadline exceeded')),
231
+ limits.idleMs,
232
+ )
233
+ try {
234
+ const result = await reader.read()
235
+ clearTimeout(idleTimer)
236
+ if (finished) return
237
+ if (result.done) {
238
+ finished = true
239
+ clean()
240
+ controller.close()
241
+ reader.releaseLock()
242
+ return
243
+ }
244
+ clearTimeout(firstTimer)
245
+ received += result.value.byteLength
246
+ if (received > limits.maxResponseBytes) {
247
+ fail(badRequest('response', 'too_large', limits.maxResponseBytes))
248
+ return
249
+ }
250
+ controller.enqueue(result.value)
251
+ } catch {
252
+ fail(new KitFailure('internal', 'Outbound response failed'))
253
+ }
254
+ },
255
+ async cancel() {
256
+ finished = true
257
+ clean()
258
+ abort.abort()
259
+ await reader?.cancel().catch(() => {})
260
+ },
261
+ })
262
+ return new Response(body, {
263
+ status: response.status,
264
+ statusText: response.statusText,
265
+ headers: response.headers,
266
+ })
267
+ } catch (error) {
268
+ finished = true
269
+ clean()
270
+ abort.abort()
271
+ if (error instanceof KitFailure) throw error
272
+ throw failure ?? new KitFailure('internal', 'Outbound request failed')
273
+ }
274
+ },
275
+ })
276
+ }
277
+
278
+ export interface TrustedTargetChecker {
279
+ readonly security: 'all-dns-addresses-preflight'
280
+ /** Reject if any A/AAAA result is private/special/unknown, or lookup fails.
281
+ * The content proxy must independently enforce its own connect-time address policy. */
282
+ checkTarget(url: URL, signal: AbortSignal): Promise<void>
283
+ }
284
+ export function createWorkerTargetCheck(options: {
285
+ readonly checker: TrustedTargetChecker
286
+ readonly allowLoopbackForTests?: boolean
287
+ }): (url: URL, signal: AbortSignal) => Promise<void> {
288
+ if (!options.checker || options.checker.security !== 'all-dns-addresses-preflight')
289
+ throw new KitFailure('needs_config', 'Trusted DNS target checker is required')
290
+ return async (url: URL, signal: AbortSignal): Promise<void> => {
291
+ if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
292
+ const local = options.allowLoopbackForTests === true && isLoopback(url.hostname)
293
+ if (
294
+ url.username ||
295
+ url.password ||
296
+ url.hash ||
297
+ (!local && url.protocol !== 'https:') ||
298
+ (local && url.protocol !== 'http:' && url.protocol !== 'https:') ||
299
+ (!local && privateLiteral(url.hostname))
300
+ )
301
+ throw badRequest('url', 'outbound_policy')
302
+ if (!local) await options.checker.checkTarget(new URL(url.href), signal)
303
+ if (signal.aborted) throw new KitFailure('cancelled', 'Target check cancelled')
304
+ }
305
+ }