@fabrika/app 0.9.0-cloudflare.1 → 0.9.0-cloudflare.2
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/package.json +9 -9
- package/skill/SKILL.md +4 -2
- package/skill/references/app-setup.md +29 -11
- package/skill/references/endpoint.md +5 -0
- package/skill/references/errors.md +31 -31
- package/skill/references/jobs.md +9 -0
- package/skill/references/tests.md +22 -1
- package/src/build.ts +6 -1
- package/src/bun.ts +10 -0
- package/src/cloudflare/application.ts +3 -3
- package/src/cloudflare/bindings.ts +26 -5
- package/src/cloudflare/names.ts +6 -0
- package/src/contract/artifact-schema.ts +6 -1
- package/src/contract/build.ts +33 -10
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +2 -1
- package/src/disk-assets.ts +8 -3
- package/src/implement/boot.ts +9 -3
- package/src/implement/capability.ts +3 -0
- package/src/implement/dispatch.ts +4 -4
- package/src/implement/egress.ts +341 -8
- package/src/implement/invocation.ts +10 -9
- package/src/jobs/runner.ts +7 -2
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +135 -23
- package/src/testing/isolation.ts +8 -1
- package/types/src/build.d.ts +1 -1
- package/types/src/cloudflare/bindings.d.ts +6 -0
- package/types/src/cloudflare/names.d.ts +5 -0
- package/types/src/contract/group.d.ts +9 -1
- package/types/src/contract/kit.d.ts +2 -2
- package/types/src/disk-assets.d.ts +4 -1
- package/types/src/implement/boot.d.ts +6 -0
- package/types/src/implement/capability.d.ts +2 -0
- package/types/src/implement/dispatch.d.ts +2 -1
- package/types/src/implement/egress.d.ts +36 -1
- package/types/src/implement/invocation.d.ts +3 -3
- package/types/src/jobs/runner.d.ts +3 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/testing/endpoint-access.d.ts +25 -1
- package/types/src/testing/isolation.d.ts +1 -1
package/src/disk-assets.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
import { rawRequestTarget } from '@fabrika/auth-core'
|
|
5
5
|
import { join, resolve, sep } from 'node:path'
|
|
6
6
|
import type { SpaAssets } from './implement/dispatch.js'
|
|
7
|
+
import { isPageRoute } from './spa-fallback.js'
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Serves each SPA mount from its build directory under `root`; a page route that names no file gets the SPA's
|
|
11
|
+
* `index.html` (`isPageRoute`), and a missing asset is 404.
|
|
12
|
+
*/
|
|
9
13
|
export function diskAssets(root: string): SpaAssets {
|
|
10
14
|
return {
|
|
11
15
|
async serve(mount, request) {
|
|
@@ -15,8 +19,9 @@ export function diskAssets(root: string): SpaAssets {
|
|
|
15
19
|
// The path is canonical, so every segment decodes and none is `.` or `..`.
|
|
16
20
|
const file = join(dir, ...relative.split('/').filter((segment) => segment !== '').map((segment) => decodeURIComponent(segment)))
|
|
17
21
|
const candidate = file.startsWith(`${dir}${sep}`) ? Bun.file(file) : null
|
|
18
|
-
const
|
|
19
|
-
|
|
22
|
+
const found = candidate !== null && (await candidate.exists()) ? candidate : null
|
|
23
|
+
const served = found ?? (isPageRoute(request) ? Bun.file(join(dir, 'index.html')) : null)
|
|
24
|
+
if (served === null || !(await served.exists())) {
|
|
20
25
|
return new Response('Not found', { status: 404 })
|
|
21
26
|
}
|
|
22
27
|
return new Response(served)
|
package/src/implement/boot.ts
CHANGED
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
type ServedSite,
|
|
66
66
|
type SpaAssets,
|
|
67
67
|
} from './dispatch'
|
|
68
|
-
import { installEgressGuard, platformCall, platformFacility } from './egress'
|
|
68
|
+
import { type HostResolver, installEgressGuard, platformCall, platformFacility, publicEgressRules, type SiteEgress, siteEgress } from './egress'
|
|
69
69
|
import type { CapturedEnvironment } from './environment'
|
|
70
70
|
import { FailureReports } from './failures'
|
|
71
71
|
import { applicationPolicy } from './headers'
|
|
@@ -124,6 +124,11 @@ export interface BootOptions {
|
|
|
124
124
|
* with a job or a webhook does not boot.
|
|
125
125
|
*/
|
|
126
126
|
readonly workloadTokens?: WorkloadTokenSource
|
|
127
|
+
/**
|
|
128
|
+
* Resolves a host name to its addresses, which the Bun adapter supplies (`node:dns`): a cron job's public egress then
|
|
129
|
+
* refuses a public name of a private address before each hop. A Worker exposes no resolution and passes none.
|
|
130
|
+
*/
|
|
131
|
+
readonly resolveHost?: HostResolver
|
|
127
132
|
/** What posts a report to Operations' ingest; `fetch` by default. */
|
|
128
133
|
readonly operationsFetch?: (request: Request) => Promise<Response>
|
|
129
134
|
/** What asks a public procedure's challenge provider to verify a token; `fetch` by default. */
|
|
@@ -460,8 +465,8 @@ function boundHandler(
|
|
|
460
465
|
}
|
|
461
466
|
|
|
462
467
|
/** The origins a staged-kit site's handler reaches, a kit without tables included; nothing for a bare handler. */
|
|
463
|
-
function egressOf(bound: BoundHandler): { readonly egress?:
|
|
464
|
-
return !bound.site || bound.declaration === null ? {} : { egress: bound.declaration.egress }
|
|
468
|
+
function egressOf(bound: BoundHandler): { readonly egress?: SiteEgress } {
|
|
469
|
+
return !bound.site || bound.declaration === null ? {} : { egress: siteEgress(bound.declaration.egress, null) }
|
|
465
470
|
}
|
|
466
471
|
|
|
467
472
|
/** A staged-kit contract's endpoint gets `ctx.links` only for the link mount it declares it `issues`. */
|
|
@@ -899,6 +904,7 @@ function bootOnce(app: ContractImplementation, options: BootOptions, binding: Bi
|
|
|
899
904
|
mail,
|
|
900
905
|
tokens,
|
|
901
906
|
url,
|
|
907
|
+
publicEgress: publicEgressRules(options.environment.framework, options.resolveHost ?? null),
|
|
902
908
|
}, problems)
|
|
903
909
|
for (const id of jobs.declared) {
|
|
904
910
|
declared.add(id)
|
|
@@ -34,6 +34,9 @@ export const CAPABILITY_REFUSALS = {
|
|
|
34
34
|
undeclared_binding: 'the site named a webhook it does not declare in binds',
|
|
35
35
|
undeclared_signs: 'the site signed a token of a purpose it does not declare in signs, or for longer than the purpose allows',
|
|
36
36
|
undeclared_egress: 'the site called an origin it does not declare in egress',
|
|
37
|
+
refused_egress:
|
|
38
|
+
"the site's public egress refused the host: an IP address, a local or internal name, a non-default port, a host of this installation, or a name that resolves to a private address",
|
|
39
|
+
oversized_egress: "a response to the site's public egress was larger than it reads",
|
|
37
40
|
wrong_binding: 'a webhook binding was handed a capability of another kind or dimension than the webhook is bound to',
|
|
38
41
|
missing_id: 'the job named an object without an id',
|
|
39
42
|
wrong_capability: 'an operation was handed a capability of another dimension or action than it needs',
|
|
@@ -84,7 +84,7 @@ import {
|
|
|
84
84
|
} from './capability'
|
|
85
85
|
import type { ChallengeIssuer, ChallengeVerifier } from './challenge'
|
|
86
86
|
import { connectContext, type ImportWire } from './connect'
|
|
87
|
-
import { runAsSite } from './egress'
|
|
87
|
+
import { runAsSite, type SiteEgress, siteEgress } from './egress'
|
|
88
88
|
import type { FailureReports } from './failures'
|
|
89
89
|
import { ServerFault } from './faults'
|
|
90
90
|
import { type HeaderPolicy, withHeaderPolicy } from './headers'
|
|
@@ -167,7 +167,7 @@ export type ServedEndpoint =
|
|
|
167
167
|
/** A staged-kit site, which gets `ctx.db`; a handler `implement` bound has none. */
|
|
168
168
|
readonly site?: ServedSite
|
|
169
169
|
/** The origins a staged-kit site's `fetch` reaches while its handler runs; absent for a handler `implement` bound. */
|
|
170
|
-
readonly egress?:
|
|
170
|
+
readonly egress?: SiteEgress
|
|
171
171
|
/** Its buckets and time limit; without them (a generated endpoint) no bucket and the request budget. */
|
|
172
172
|
readonly admission?: ProcedureAdmission
|
|
173
173
|
/** A POST's `query`, parsed from the URL beside the body: its schema and its shape. */
|
|
@@ -1087,7 +1087,7 @@ export class ContractRuntime {
|
|
|
1087
1087
|
kick: kicks.kick,
|
|
1088
1088
|
})
|
|
1089
1089
|
const args = { input, ctx, ...(query === undefined ? {} : { query }) }
|
|
1090
|
-
return await runAsSite(`'${served.row.id}'`, served.site.declaration.egress, async () => await served.handler(args))
|
|
1090
|
+
return await runAsSite(`'${served.row.id}'`, siteEgress(served.site.declaration.egress, null), async () => await served.handler(args))
|
|
1091
1091
|
})
|
|
1092
1092
|
} finally {
|
|
1093
1093
|
frame.end()
|
|
@@ -1234,7 +1234,7 @@ export class ContractRuntime {
|
|
|
1234
1234
|
if (site === undefined) {
|
|
1235
1235
|
await served.handler(args)
|
|
1236
1236
|
} else {
|
|
1237
|
-
await runAsSite(`webhook '${served.webhook}'`, site.egress, async () => await served.handler(args))
|
|
1237
|
+
await runAsSite(`webhook '${served.webhook}'`, siteEgress(site.egress, null), async () => await served.handler(args))
|
|
1238
1238
|
}
|
|
1239
1239
|
})
|
|
1240
1240
|
} finally {
|
package/src/implement/egress.ts
CHANGED
|
@@ -1,23 +1,107 @@
|
|
|
1
|
-
// `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `egress`: while a site's handler runs, the global `fetch` reaches only the HTTPS origins the site declares, or, for a
|
|
2
|
+
// cron job that declares `egress: 'public'`, only public hosts (ADR-0070). It stops a mistake and makes a bypass
|
|
3
|
+
// conspicuous; it is not a sandbox (ADR-0012 §8): code that kept the `fetch` it found before boot, or opens its own
|
|
4
|
+
// sockets, escapes it. The framework's own facilities (mail, connectors, IAM, the object store) run outside the site's
|
|
5
|
+
// scope, under their own declarations.
|
|
5
6
|
|
|
7
|
+
import {
|
|
8
|
+
type DeclaredEgress,
|
|
9
|
+
FABRIKA_EGRESS_REFUSED_HOSTS,
|
|
10
|
+
isPublicAddress,
|
|
11
|
+
normalizedHost,
|
|
12
|
+
PUBLIC_EGRESS,
|
|
13
|
+
publicEgressRefusal,
|
|
14
|
+
} from '@fabrika/auth-core'
|
|
6
15
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
7
16
|
import { CapabilityRefusedError } from './capability'
|
|
8
17
|
|
|
18
|
+
/** The addresses a host name resolves to, where the runtime exposes resolution: Bun's `node:dns`, never a Worker. */
|
|
19
|
+
export type HostResolver = (hostname: string) => Promise<readonly string[]>
|
|
20
|
+
|
|
21
|
+
/** What public egress refuses beyond what a URL alone shows (`publicEgressRefusal`). */
|
|
22
|
+
export interface PublicEgressRules {
|
|
23
|
+
/** The installation's own hosts, normalized: every host a framework variable names, and `FABRIKA_EGRESS_REFUSED_HOSTS`. */
|
|
24
|
+
readonly refusedHosts: ReadonlySet<string>
|
|
25
|
+
/** Resolves each hop's host before it is sent, so a public name of a private address is refused; `null` on Cloudflare. */
|
|
26
|
+
readonly resolve: HostResolver | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What one site's `fetch` reaches: its declared origins, or the rules of public egress. */
|
|
30
|
+
export type SiteEgress = readonly string[] | PublicEgressRules
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The bounds of one request under public egress, the redirects it follows included. The handler's own signal still
|
|
34
|
+
* aborts it sooner (`ctx.signal`, an `AbortSignal.timeout` of its own).
|
|
35
|
+
*/
|
|
36
|
+
export const PUBLIC_EGRESS_LIMITS = {
|
|
37
|
+
/** One request, its redirects and the reading of its body included: the abort also stops a body still streaming. */
|
|
38
|
+
timeoutMs: 60_000,
|
|
39
|
+
/** One response's body as the handler reads it; a larger one is refused, by its `Content-Length` or while it streams. */
|
|
40
|
+
maxResponseBytes: 16 * 1024 * 1024,
|
|
41
|
+
/** The redirects one request follows when its `redirect` mode is `follow`, each hop checked again. */
|
|
42
|
+
maxRedirects: 10,
|
|
43
|
+
} as const
|
|
44
|
+
|
|
9
45
|
interface EgressScope {
|
|
10
46
|
/** The site, as a refusal names it. */
|
|
11
47
|
readonly site: string
|
|
12
48
|
readonly origins: ReadonlySet<string>
|
|
49
|
+
/** Public egress: `null` for a site that declares origins, or none. */
|
|
50
|
+
readonly open: PublicEgressRules | null
|
|
13
51
|
}
|
|
14
52
|
|
|
15
53
|
const scope = new AsyncLocalStorage<EgressScope>()
|
|
16
54
|
const guards = new WeakSet<object>()
|
|
17
55
|
|
|
18
56
|
/** Runs a site's handler: every `fetch` it starts reaches only `egress`. */
|
|
19
|
-
export function runAsSite<T>(site: string, egress:
|
|
20
|
-
return
|
|
57
|
+
export function runAsSite<T>(site: string, egress: SiteEgress, work: () => Promise<T>): Promise<T> {
|
|
58
|
+
return isOrigins(egress)
|
|
59
|
+
? scope.run({ site, origins: new Set(egress), open: null }, work)
|
|
60
|
+
: scope.run({ site, origins: new Set(), open: egress }, work)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The egress a site runs with: its declared origins, or `rules` for `'public'`. Only a cron job's runner holds rules; a
|
|
65
|
+
* site run without them reaches nothing, though the build lets no other site declare `'public'`.
|
|
66
|
+
*/
|
|
67
|
+
export function siteEgress(declared: DeclaredEgress, rules: PublicEgressRules | null): SiteEgress {
|
|
68
|
+
return declared === PUBLIC_EGRESS ? (rules ?? []) : declared
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isOrigins(egress: SiteEgress): egress is readonly string[] {
|
|
72
|
+
return Array.isArray(egress)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The rules of public egress in one environment: the hosts its framework variables name (IAM, Operations, its own
|
|
77
|
+
* origin, the platform's receivers, the object store), the installation hosts Control hands it, and the resolver the
|
|
78
|
+
* runtime adapter supplies.
|
|
79
|
+
*/
|
|
80
|
+
export function publicEgressRules(framework: Readonly<Record<string, string>>, resolve: HostResolver | null): PublicEgressRules {
|
|
81
|
+
const hosts = new Set<string>()
|
|
82
|
+
for (const [name, value] of Object.entries(framework)) {
|
|
83
|
+
if (name === FABRIKA_EGRESS_REFUSED_HOSTS) {
|
|
84
|
+
for (const host of value.split(',')) {
|
|
85
|
+
const normalized = normalizedHost(host.trim())
|
|
86
|
+
if (normalized !== '') hosts.add(normalized)
|
|
87
|
+
}
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const host = urlHost(value)
|
|
91
|
+
if (host !== null) hosts.add(host)
|
|
92
|
+
}
|
|
93
|
+
return { refusedHosts: hosts, resolve }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The host of a framework variable's value when it is an http(s) URL, such as an origin or a DSN; `null` otherwise. */
|
|
97
|
+
function urlHost(value: string): string | null {
|
|
98
|
+
const text = value.trim()
|
|
99
|
+
if (!/^https?:\/\//i.test(text)) return null
|
|
100
|
+
try {
|
|
101
|
+
return normalizedHost(new URL(text).hostname)
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
21
105
|
}
|
|
22
106
|
|
|
23
107
|
/**
|
|
@@ -41,6 +125,8 @@ export function platformCall<T>(work: () => T): T {
|
|
|
41
125
|
return scope.exit(work)
|
|
42
126
|
}
|
|
43
127
|
|
|
128
|
+
type Fetch = (input: string, init: RequestInit) => Promise<Response>
|
|
129
|
+
|
|
44
130
|
/**
|
|
45
131
|
* Wraps the global `fetch` in the guard, once: a `fetch` that is already a guard stays. A test that replaces `fetch`
|
|
46
132
|
* after boot replaces the guard with it.
|
|
@@ -50,15 +136,20 @@ export function installEgressGuard(): void {
|
|
|
50
136
|
if (guards.has(current)) {
|
|
51
137
|
return
|
|
52
138
|
}
|
|
139
|
+
const send: Fetch = (input, init) => current(input, init)
|
|
53
140
|
const guarded = Object.assign(
|
|
54
141
|
(input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
55
142
|
const running = scope.getStore()
|
|
56
143
|
if (running === undefined) {
|
|
57
144
|
return current(input, init)
|
|
58
145
|
}
|
|
146
|
+
if (running.open !== null) {
|
|
147
|
+
return publicFetch(send, running.site, running.open, input, init)
|
|
148
|
+
}
|
|
59
149
|
const origin = originOf(input)
|
|
60
150
|
if (origin !== null && running.origins.has(origin)) {
|
|
61
|
-
|
|
151
|
+
// Only the standard fields: a runtime's own (Bun's `unix`, `proxy`, `tls`) would send the request elsewhere.
|
|
152
|
+
return current(input, standardInit(init))
|
|
62
153
|
}
|
|
63
154
|
return Promise.reject(
|
|
64
155
|
new CapabilityRefusedError(
|
|
@@ -69,12 +160,47 @@ export function installEgressGuard(): void {
|
|
|
69
160
|
),
|
|
70
161
|
)
|
|
71
162
|
},
|
|
72
|
-
{
|
|
163
|
+
{
|
|
164
|
+
// Inside a site's scope a preconnect reaches only a declared origin; public egress, which cannot check a hop
|
|
165
|
+
// before it connects, refuses every one.
|
|
166
|
+
preconnect: (...args: Parameters<typeof current.preconnect>): void => {
|
|
167
|
+
const running = scope.getStore()
|
|
168
|
+
if (running !== undefined) {
|
|
169
|
+
const origin = originOf(args[0])
|
|
170
|
+
if (running.open !== null || origin === null || !running.origins.has(origin)) {
|
|
171
|
+
throw new CapabilityRefusedError(
|
|
172
|
+
running.open === null ? 'undeclared_egress' : 'refused_egress',
|
|
173
|
+
`${running.site} preconnects to ${origin ?? 'a URL that is not absolute'}, which its egress does not reach`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (typeof current.preconnect === 'function') current.preconnect(...args)
|
|
178
|
+
},
|
|
179
|
+
},
|
|
73
180
|
)
|
|
74
181
|
guards.add(guarded)
|
|
75
182
|
globalThis.fetch = guarded
|
|
76
183
|
}
|
|
77
184
|
|
|
185
|
+
/** `init` with the fields of the Fetch standard only. */
|
|
186
|
+
function standardInit(init: RequestInit | undefined): RequestInit | undefined {
|
|
187
|
+
if (init === undefined) return undefined
|
|
188
|
+
return {
|
|
189
|
+
...(init.method === undefined ? {} : { method: init.method }),
|
|
190
|
+
...(init.headers === undefined ? {} : { headers: init.headers }),
|
|
191
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
192
|
+
...(init.redirect === undefined ? {} : { redirect: init.redirect }),
|
|
193
|
+
...(init.signal === undefined ? {} : { signal: init.signal }),
|
|
194
|
+
...(init.keepalive === undefined ? {} : { keepalive: init.keepalive }),
|
|
195
|
+
...(init.integrity === undefined ? {} : { integrity: init.integrity }),
|
|
196
|
+
...(init.referrer === undefined ? {} : { referrer: init.referrer }),
|
|
197
|
+
...(init.referrerPolicy === undefined ? {} : { referrerPolicy: init.referrerPolicy }),
|
|
198
|
+
...(init.cache === undefined ? {} : { cache: init.cache }),
|
|
199
|
+
...(init.credentials === undefined ? {} : { credentials: init.credentials }),
|
|
200
|
+
...(init.mode === undefined ? {} : { mode: init.mode }),
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
78
204
|
function originOf(input: string | URL | Request): string | null {
|
|
79
205
|
try {
|
|
80
206
|
return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).origin
|
|
@@ -82,3 +208,210 @@ function originOf(input: string | URL | Request): string | null {
|
|
|
82
208
|
return null
|
|
83
209
|
}
|
|
84
210
|
}
|
|
211
|
+
|
|
212
|
+
const REDIRECTS: ReadonlySet<number> = new Set([301, 302, 303, 307, 308])
|
|
213
|
+
/** The headers a redirect that turns a request into a `GET` drops with its body (Fetch, "HTTP-redirect fetch"). */
|
|
214
|
+
const BODY_HEADERS = ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location']
|
|
215
|
+
/**
|
|
216
|
+
* The request headers public egress never sends, on any hop: the Fetch standard's forbidden request headers (`Host`
|
|
217
|
+
* among them, which would route a request for a public name to another site at the same address), the forwarding
|
|
218
|
+
* headers a proxy trusts, and the platform's own `X-Fabrika-*`.
|
|
219
|
+
*/
|
|
220
|
+
const FORBIDDEN_HEADERS = [
|
|
221
|
+
'accept-charset',
|
|
222
|
+
'accept-encoding',
|
|
223
|
+
'access-control-request-headers',
|
|
224
|
+
'access-control-request-method',
|
|
225
|
+
'connection',
|
|
226
|
+
'content-length',
|
|
227
|
+
'cookie2',
|
|
228
|
+
'date',
|
|
229
|
+
'dnt',
|
|
230
|
+
'expect',
|
|
231
|
+
'forwarded',
|
|
232
|
+
'host',
|
|
233
|
+
'keep-alive',
|
|
234
|
+
'origin',
|
|
235
|
+
'referer',
|
|
236
|
+
'set-cookie',
|
|
237
|
+
'te',
|
|
238
|
+
'trailer',
|
|
239
|
+
'transfer-encoding',
|
|
240
|
+
'upgrade',
|
|
241
|
+
'via',
|
|
242
|
+
'x-http-method',
|
|
243
|
+
'x-http-method-override',
|
|
244
|
+
'x-method-override',
|
|
245
|
+
'x-real-ip',
|
|
246
|
+
]
|
|
247
|
+
const FORBIDDEN_HEADER_PREFIXES = ['proxy-', 'sec-', 'x-forwarded-', 'x-fabrika-']
|
|
248
|
+
|
|
249
|
+
function withoutForbiddenHeaders(headers: Headers): Headers {
|
|
250
|
+
for (const name of [...headers.keys()]) {
|
|
251
|
+
if (FORBIDDEN_HEADERS.includes(name) || FORBIDDEN_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
252
|
+
headers.delete(name)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return headers
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The credentials a redirect to another origin drops. */
|
|
259
|
+
const CREDENTIAL_HEADERS = ['authorization', 'proxy-authorization', 'cookie']
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One request under public egress. Every hop is sent with `redirect: 'manual'`, so the runtime follows no redirect the
|
|
263
|
+
* guard has not checked: the caller's `follow` is done here, at most `maxRedirects` hops, each host checked again;
|
|
264
|
+
* `manual` hands the caller the redirect, whose next `fetch` passes the guard again; `error` refuses one. The request
|
|
265
|
+
* is bounded in time and its answer's body in size.
|
|
266
|
+
*/
|
|
267
|
+
async function publicFetch(
|
|
268
|
+
send: Fetch,
|
|
269
|
+
site: string,
|
|
270
|
+
rules: PublicEgressRules,
|
|
271
|
+
input: string | URL | Request,
|
|
272
|
+
init?: RequestInit,
|
|
273
|
+
): Promise<Response> {
|
|
274
|
+
const request = new Request(input, init)
|
|
275
|
+
// A timer the guard clears once the body is read, not `AbortSignal.timeout`: on workerd an uncleared timer keeps the
|
|
276
|
+
// Durable Object awake until it fires.
|
|
277
|
+
const deadline = new AbortController()
|
|
278
|
+
const timer = setTimeout(() => deadline.abort(new DOMException('public egress request timed out', 'TimeoutError')), PUBLIC_EGRESS_LIMITS.timeoutMs)
|
|
279
|
+
const done = (): void => clearTimeout(timer)
|
|
280
|
+
try {
|
|
281
|
+
return await follow(send, site, rules, request, AbortSignal.any([request.signal, deadline.signal]), done)
|
|
282
|
+
} catch (error) {
|
|
283
|
+
done()
|
|
284
|
+
throw error
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The hops of one request; `done` clears its deadline once its last answer's body is read. */
|
|
289
|
+
async function follow(
|
|
290
|
+
send: Fetch,
|
|
291
|
+
site: string,
|
|
292
|
+
rules: PublicEgressRules,
|
|
293
|
+
request: Request,
|
|
294
|
+
signal: AbortSignal,
|
|
295
|
+
done: () => void,
|
|
296
|
+
): Promise<Response> {
|
|
297
|
+
const mode = request.redirect
|
|
298
|
+
let url = new URL(request.url)
|
|
299
|
+
let method = request.method
|
|
300
|
+
const headers = withoutForbiddenHeaders(new Headers(request.headers))
|
|
301
|
+
// Read once, so that a 307 or a 308 sends it again.
|
|
302
|
+
let body: ArrayBuffer | null = request.body === null ? null : await request.arrayBuffer()
|
|
303
|
+
for (let hop = 0;; hop++) {
|
|
304
|
+
await admit(site, rules, url)
|
|
305
|
+
const response = await send(url.href, { method, headers: new Headers(headers), body, redirect: 'manual', signal })
|
|
306
|
+
const location = REDIRECTS.has(response.status) ? response.headers.get('location') : null
|
|
307
|
+
if (location === null || mode === 'manual') {
|
|
308
|
+
return await capped(site, response, url, hop > 0, done)
|
|
309
|
+
}
|
|
310
|
+
await response.body?.cancel()
|
|
311
|
+
if (mode === 'error') {
|
|
312
|
+
throw new TypeError(`${site}: ${url.origin} redirects, and the request's redirect mode is 'error'`)
|
|
313
|
+
}
|
|
314
|
+
if (hop >= PUBLIC_EGRESS_LIMITS.maxRedirects) {
|
|
315
|
+
throw new TypeError(`${site}: more than ${PUBLIC_EGRESS_LIMITS.maxRedirects} redirects, the last from ${url.origin}`)
|
|
316
|
+
}
|
|
317
|
+
const next = new URL(location, url)
|
|
318
|
+
const toGet = (response.status === 303 && method !== 'GET' && method !== 'HEAD')
|
|
319
|
+
|| ((response.status === 301 || response.status === 302) && method === 'POST')
|
|
320
|
+
if (toGet) {
|
|
321
|
+
method = 'GET'
|
|
322
|
+
body = null
|
|
323
|
+
for (const name of BODY_HEADERS) headers.delete(name)
|
|
324
|
+
}
|
|
325
|
+
if (next.origin !== url.origin) {
|
|
326
|
+
for (const name of CREDENTIAL_HEADERS) headers.delete(name)
|
|
327
|
+
}
|
|
328
|
+
url = next
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Refuses a hop public egress does not reach: by the URL, by the installation's hosts, by what its name resolves to. */
|
|
333
|
+
async function admit(site: string, rules: PublicEgressRules, url: URL): Promise<void> {
|
|
334
|
+
const refusal = publicEgressRefusal(url, rules.refusedHosts)
|
|
335
|
+
if (refusal !== null) {
|
|
336
|
+
throw new CapabilityRefusedError('refused_egress', `${site} calls ${url.origin}, which public egress refuses: ${refusal}`)
|
|
337
|
+
}
|
|
338
|
+
const resolve = rules.resolve
|
|
339
|
+
if (resolve === null) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const host = normalizedHost(url.hostname)
|
|
343
|
+
const addresses = await platformCall(() => resolve(host))
|
|
344
|
+
const internal = addresses.find((address) => !isPublicAddress(address))
|
|
345
|
+
if (addresses.length === 0 || internal !== undefined) {
|
|
346
|
+
throw new CapabilityRefusedError(
|
|
347
|
+
'refused_egress',
|
|
348
|
+
`${site} calls ${url.origin}, which public egress refuses: ${
|
|
349
|
+
internal === undefined ? `${host} resolves to no address` : `${host} resolves to ${internal}, which is not a public address`
|
|
350
|
+
}`,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `response` with its body bounded by `maxResponseBytes`, and the URL and redirect flag of the request it answers,
|
|
357
|
+
* which a response the guard builds would otherwise lose.
|
|
358
|
+
*/
|
|
359
|
+
async function capped(site: string, response: Response, url: URL, redirected: boolean, done: () => void): Promise<Response> {
|
|
360
|
+
const max = PUBLIC_EGRESS_LIMITS.maxResponseBytes
|
|
361
|
+
const tooLarge = () =>
|
|
362
|
+
new CapabilityRefusedError(
|
|
363
|
+
'oversized_egress',
|
|
364
|
+
`${site}: ${url.origin} answers more than ${max} bytes, the most public egress reads of one response`,
|
|
365
|
+
)
|
|
366
|
+
const declared = Number(response.headers.get('content-length') ?? '0')
|
|
367
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
368
|
+
await response.body?.cancel()
|
|
369
|
+
throw tooLarge()
|
|
370
|
+
}
|
|
371
|
+
if (response.body === null || response.status < 200 || response.status > 599) {
|
|
372
|
+
done()
|
|
373
|
+
return withRequestUrl(response, url, redirected)
|
|
374
|
+
}
|
|
375
|
+
const reader = response.body.getReader()
|
|
376
|
+
let seen = 0
|
|
377
|
+
const bound = new ReadableStream<Uint8Array>({
|
|
378
|
+
async pull(controller) {
|
|
379
|
+
const read = await reader.read().catch((error: unknown) => {
|
|
380
|
+
done()
|
|
381
|
+
throw error
|
|
382
|
+
})
|
|
383
|
+
if (read.done) {
|
|
384
|
+
done()
|
|
385
|
+
controller.close()
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
seen += read.value.byteLength
|
|
389
|
+
if (seen > max) {
|
|
390
|
+
done()
|
|
391
|
+
await reader.cancel()
|
|
392
|
+
controller.error(tooLarge())
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
controller.enqueue(read.value)
|
|
396
|
+
},
|
|
397
|
+
async cancel(reason) {
|
|
398
|
+
done()
|
|
399
|
+
await reader.cancel(reason)
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
const answer = new Response(bound, {
|
|
403
|
+
status: response.status,
|
|
404
|
+
statusText: response.statusText,
|
|
405
|
+
headers: response.headers,
|
|
406
|
+
})
|
|
407
|
+
return withRequestUrl(answer, url, redirected)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function withRequestUrl(response: Response, url: URL, redirected: boolean): Response {
|
|
411
|
+
try {
|
|
412
|
+
Object.defineProperties(response, { url: { value: url.href }, redirected: { value: redirected } })
|
|
413
|
+
} catch {
|
|
414
|
+
// A runtime whose responses refuse own properties keeps its own `url`; the body bound holds either way.
|
|
415
|
+
}
|
|
416
|
+
return response
|
|
417
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// commits it after the handler returned and its output passed validation, and rolls it back otherwise. Every handle
|
|
4
4
|
// is bound to the invocation: called from another request, another `boot`, or after it ended, it refuses.
|
|
5
5
|
|
|
6
|
-
import { type ContractArtifact, JOB_MAILERS, type JobMailer } from '@fabrika/auth-core'
|
|
6
|
+
import { type ContractArtifact, type DeclaredEgress, JOB_MAILERS, type JobMailer, PUBLIC_EGRESS } from '@fabrika/auth-core'
|
|
7
7
|
import {
|
|
8
8
|
type DataAccess,
|
|
9
9
|
type DataHandles,
|
|
@@ -34,8 +34,8 @@ export interface SiteDeclaration {
|
|
|
34
34
|
readonly binds: string | null
|
|
35
35
|
/** The token purposes `ctx.tokens.sign` may sign. */
|
|
36
36
|
readonly signs: readonly string[]
|
|
37
|
-
/** The HTTPS origins the global `fetch` reaches while the site's handler runs. */
|
|
38
|
-
readonly egress:
|
|
37
|
+
/** The HTTPS origins the global `fetch` reaches while the site's handler runs, or `'public'`: every public host (a cron job). */
|
|
38
|
+
readonly egress: DeclaredEgress
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** A public procedure's reach: the texts the verified signed tokens of its request carry, which name the rows it changes. */
|
|
@@ -166,10 +166,11 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
166
166
|
problems.push(`${where}: signs is not a list of token purposes`)
|
|
167
167
|
}
|
|
168
168
|
const egressField = ownField(declared, 'egress')
|
|
169
|
-
const
|
|
170
|
-
if (egressField !== undefined && (!Array.isArray(egressField) ||
|
|
171
|
-
problems.push(`${where}: egress is not a list of origins`)
|
|
169
|
+
const origins = Array.isArray(egressField) ? egressField.filter((origin): origin is string => typeof origin === 'string') : []
|
|
170
|
+
if (egressField !== undefined && egressField !== PUBLIC_EGRESS && (!Array.isArray(egressField) || origins.length !== egressField.length)) {
|
|
171
|
+
problems.push(`${where}: egress is not a list of origins or '${PUBLIC_EGRESS}'`)
|
|
172
172
|
}
|
|
173
|
+
const egress: DeclaredEgress = egressField === PUBLIC_EGRESS ? PUBLIC_EGRESS : Object.freeze([...new Set(origins)].sort())
|
|
173
174
|
return Object.freeze({
|
|
174
175
|
sql: sql === 'read' || sql === 'write' ? sql : null,
|
|
175
176
|
unscoped: Object.freeze(unscoped),
|
|
@@ -179,7 +180,7 @@ export function parseDeclaration(where: string, declared: unknown, kind: DataKin
|
|
|
179
180
|
kicks: Object.freeze([...new Set(kicks)].sort()),
|
|
180
181
|
binds: typeof binds === 'string' ? binds : null,
|
|
181
182
|
signs: Object.freeze([...new Set(signs)].sort()),
|
|
182
|
-
egress
|
|
183
|
+
egress,
|
|
183
184
|
})
|
|
184
185
|
}
|
|
185
186
|
|
|
@@ -224,7 +225,7 @@ export function approvedReach(site: string, artifact: ContractArtifact): SiteDec
|
|
|
224
225
|
kicks: Object.freeze([...(reach.kicks ?? [])]),
|
|
225
226
|
binds: reach.binds ?? null,
|
|
226
227
|
signs: Object.freeze([...(reach.signs ?? [])]),
|
|
227
|
-
egress: Object.freeze([...(reach.egress ?? [])]),
|
|
228
|
+
egress: reach.egress === PUBLIC_EGRESS ? PUBLIC_EGRESS : Object.freeze([...(reach.egress ?? [])]),
|
|
228
229
|
})
|
|
229
230
|
}
|
|
230
231
|
|
|
@@ -243,7 +244,7 @@ function describeDeclaration(declaration: SiteDeclaration): string {
|
|
|
243
244
|
...(declaration.kicks.length === 0 ? {} : { kicks: declaration.kicks }),
|
|
244
245
|
...(declaration.binds === null ? {} : { binds: declaration.binds }),
|
|
245
246
|
...(declaration.signs.length === 0 ? {} : { signs: declaration.signs }),
|
|
246
|
-
...(declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
|
|
247
|
+
...(declaration.egress !== PUBLIC_EGRESS && declaration.egress.length === 0 ? {} : { egress: declaration.egress }),
|
|
247
248
|
})
|
|
248
249
|
}
|
|
249
250
|
|
package/src/jobs/runner.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
runInRequest,
|
|
23
23
|
type Within,
|
|
24
24
|
} from '../implement/capability'
|
|
25
|
-
import { runAsSite } from '../implement/egress'
|
|
25
|
+
import { type PublicEgressRules, runAsSite, siteEgress } from '../implement/egress'
|
|
26
26
|
import { failureCode, type FailureReason, failureReason } from '../implement/failures'
|
|
27
27
|
import { ServerFault } from '../implement/faults'
|
|
28
28
|
import type { ContractDatabase } from '../implement/implement'
|
|
@@ -188,6 +188,8 @@ interface RunnerContext {
|
|
|
188
188
|
/** The environment's signed tokens: `ctx.tokens` of a job that declares `signs`. */
|
|
189
189
|
readonly tokens: SignedTokens | null
|
|
190
190
|
readonly url: AppUrl
|
|
191
|
+
/** What a job that declares `egress: 'public'` reaches; such a job reaches nothing without it. */
|
|
192
|
+
readonly publicEgress: PublicEgressRules | null
|
|
191
193
|
}
|
|
192
194
|
|
|
193
195
|
/** Where `fabrika app dev` shows what a handler threw, on the developer's own machine; nothing else sets one. */
|
|
@@ -227,6 +229,8 @@ export interface JobBindingInput {
|
|
|
227
229
|
readonly tokens?: SignedTokens
|
|
228
230
|
/** `ctx.url`: absolute links to the application's own procedures; without it every link is refused. */
|
|
229
231
|
readonly url?: AppUrl
|
|
232
|
+
/** The rules of public egress, which `boot` reads from the environment; without them a public job reaches nothing. */
|
|
233
|
+
readonly publicEgress?: PublicEgressRules
|
|
230
234
|
}
|
|
231
235
|
|
|
232
236
|
interface BoundFrameworkJob {
|
|
@@ -326,6 +330,7 @@ export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs
|
|
|
326
330
|
mail: input.mail,
|
|
327
331
|
tokens: input.tokens ?? null,
|
|
328
332
|
url: input.url ?? appUrl(input.artifact, null),
|
|
333
|
+
publicEgress: input.publicEgress ?? null,
|
|
329
334
|
// A job's kicks run only after the job is bound, so `kick` below is defined by then.
|
|
330
335
|
kick: (name: string) => kick(name),
|
|
331
336
|
})
|
|
@@ -698,7 +703,7 @@ async function attemptOnce(
|
|
|
698
703
|
if (site === null) {
|
|
699
704
|
await job.handler({ ctx })
|
|
700
705
|
} else {
|
|
701
|
-
await runAsSite(`job '${job.name}'`, site.egress, async () => await job.handler({ ctx }))
|
|
706
|
+
await runAsSite(`job '${job.name}'`, siteEgress(site.egress, context.publicEgress), async () => await job.handler({ ctx }))
|
|
702
707
|
}
|
|
703
708
|
kicked.push(...kicks.kicked())
|
|
704
709
|
}).then(() => null, (error: unknown) => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Which request an SPA mount answers with its `index.html` when it names no file: a route of the page, not a missing
|
|
2
|
+
// asset. Both asset servers apply it (`diskAssets`, `bindingAssets`), so a missing script stays a 404 and never arrives
|
|
3
|
+
// as an HTML page with a 200.
|
|
4
|
+
|
|
5
|
+
import { rawRequestTarget } from '@fabrika/auth-core'
|
|
6
|
+
|
|
7
|
+
/** A GET or HEAD whose last path segment has no extension, or one that asks for HTML (a navigation to `/notes/v1.2`). */
|
|
8
|
+
export function isPageRoute(request: Request): boolean {
|
|
9
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return false
|
|
10
|
+
const last = rawRequestTarget(request.url).pathname.split('/').pop() ?? ''
|
|
11
|
+
return !last.includes('.') || (request.headers.get('Accept') ?? '').includes('text/html')
|
|
12
|
+
}
|