@fabrika/proxy-core 0.5.2 → 0.5.3
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 +4 -4
- package/src/authorize.ts +84 -6
- package/src/constants.ts +6 -0
- package/src/headers.ts +56 -1
- package/src/index.ts +1 -0
- package/src/manifest.ts +49 -8
- package/src/service.ts +21 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"zod": "^3.25.76",
|
|
40
|
-
"@fabrika/auth-core": "0.5.
|
|
41
|
-
"@fabrika/jwks": "0.5.
|
|
40
|
+
"@fabrika/auth-core": "0.5.3",
|
|
41
|
+
"@fabrika/jwks": "0.5.3",
|
|
42
42
|
"jose": "^5.9.0",
|
|
43
|
-
"@fabrika/platform": "0.5.
|
|
43
|
+
"@fabrika/platform": "0.5.3"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/authorize.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
|
|
3
3
|
*
|
|
4
4
|
* Every entry — a contract application or an installation service — is protected by its endpoint table, in one pass
|
|
5
|
-
* over ONE selected endpoint
|
|
5
|
+
* over ONE selected endpoint. A contract application's preflight is decided first, from the endpoint the requested
|
|
6
|
+
* method selects, and never reaches a credential. Then: `planEndpoint` → a `px_` key whose caller kind the endpoint admits, or else the session
|
|
6
7
|
* when `human` may call → the requirement (`satisfies`) → admission → the delegation grant. There is no fall-through
|
|
7
8
|
* to another endpoint. On an installation row that admits `oauth`, a bearer that is not a `px_` key is an OAuth
|
|
8
9
|
* resource token, verified locally. A key or a session is exchanged with IAM once, cached, and thereafter verified
|
|
@@ -37,7 +38,14 @@ import { LOGIN_REDIRECT_PARAM } from './constants'
|
|
|
37
38
|
import { readBearer, readCookie } from './credentials'
|
|
38
39
|
import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
|
|
39
40
|
import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
|
|
40
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
isApplicationEntry,
|
|
43
|
+
type ManifestEndpointRow,
|
|
44
|
+
type OAuthBearerChallenge,
|
|
45
|
+
type OAuthProtectedResource,
|
|
46
|
+
oauthResourceOfRow,
|
|
47
|
+
type ProxyApp,
|
|
48
|
+
} from './manifest'
|
|
41
49
|
import { TokenVerifier } from './verifier'
|
|
42
50
|
|
|
43
51
|
/** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
|
|
@@ -69,6 +77,8 @@ export type DenyReason =
|
|
|
69
77
|
| 'cross_site'
|
|
70
78
|
/** The request's `Content-Type` is not one the endpoint admits. */
|
|
71
79
|
| 'content_type'
|
|
80
|
+
/** A preflight for an endpoint the contract does not open to another origin, or from an origin it does not admit. */
|
|
81
|
+
| 'cross_origin'
|
|
72
82
|
/** IAM refused the delegation grant a membership or scope-creating endpoint needs. */
|
|
73
83
|
| 'delegation_refused'
|
|
74
84
|
/** The `Host` maps to no app in the manifest. */
|
|
@@ -93,6 +103,8 @@ export type Decision =
|
|
|
93
103
|
endpoint: string
|
|
94
104
|
/** The single-use delegation grant for `X-Fabrika-Delegation`, when the endpoint spends one. */
|
|
95
105
|
delegation: string | null
|
|
106
|
+
/** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
|
|
107
|
+
crossOrigin: string | null
|
|
96
108
|
}
|
|
97
109
|
| {
|
|
98
110
|
outcome: 'deny'
|
|
@@ -101,7 +113,14 @@ export type Decision =
|
|
|
101
113
|
clearHandoffState?: string
|
|
102
114
|
/** Set on an `oauth` row's 401/403: rendered as the RFC 6750 `WWW-Authenticate` challenge. */
|
|
103
115
|
challenge?: OAuthBearerChallenge
|
|
116
|
+
/** Set when the request's origin is admitted, so the page can read why it was refused. */
|
|
117
|
+
crossOrigin?: string
|
|
104
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* A contract application's preflight that the selected endpoint admits (ADR-0022 "Cross-origin endpoints"). The
|
|
121
|
+
* proxy answers it itself; it never reaches the application. `method` is the endpoint's.
|
|
122
|
+
*/
|
|
123
|
+
| { outcome: 'preflight'; allowOrigin: string; method: string }
|
|
105
124
|
/**
|
|
106
125
|
* A human reached an endpoint `human` may call without a usable session. `location` is where they must go to sign
|
|
107
126
|
* in; `status` is only how they are TOLD — 302 for a document navigation, 401 for anything else,
|
|
@@ -159,6 +178,12 @@ const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
|
|
|
159
178
|
/** Fetch metadata the browser writes and page script cannot: `same-origin` only for a request from this origin. */
|
|
160
179
|
const FETCH_SITE_HEADER = 'Sec-Fetch-Site'
|
|
161
180
|
|
|
181
|
+
/** The origin a browser names on every CORS request; page script cannot set it. */
|
|
182
|
+
const ORIGIN_HEADER = 'Origin'
|
|
183
|
+
|
|
184
|
+
/** What makes an `OPTIONS` request a preflight: the method the page wants to send. */
|
|
185
|
+
const PREFLIGHT_METHOD_HEADER = 'Access-Control-Request-Method'
|
|
186
|
+
|
|
162
187
|
/** Why a session attempt failed — mirrors `mintToken`'s reasons. */
|
|
163
188
|
type SessionFailureReason = 'no_session' | 'invalid_session' | 'unknown_principal' | 'disabled'
|
|
164
189
|
|
|
@@ -230,6 +255,10 @@ export class Authorizer {
|
|
|
230
255
|
table: CompiledEndpointTable<ManifestEndpointRow>,
|
|
231
256
|
request: ForwardedRequest,
|
|
232
257
|
): Promise<Decision> {
|
|
258
|
+
const preflight = this.decidePreflight(table, app, request)
|
|
259
|
+
if (preflight !== null) {
|
|
260
|
+
return preflight
|
|
261
|
+
}
|
|
233
262
|
const plan = planEndpoint(table, request.method, request.rawPath)
|
|
234
263
|
if (plan.outcome === 'non-canonical') {
|
|
235
264
|
return { outcome: 'deny', status: 400, reason: 'non_canonical_path' }
|
|
@@ -238,9 +267,41 @@ export class Authorizer {
|
|
|
238
267
|
return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
|
|
239
268
|
}
|
|
240
269
|
const row = plan.endpoint
|
|
270
|
+
const origin = request.headers.get(ORIGIN_HEADER)
|
|
271
|
+
const crossOrigin = origin === null ? null : admittedOrigin(app, row, origin)
|
|
272
|
+
const decision = await this.decideRow(app, row, request, crossOrigin)
|
|
273
|
+
return crossOrigin === null || decision.outcome !== 'deny' ? decision : { ...decision, crossOrigin }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A preflight is decided from the endpoint the REQUESTED method selects, before any credential: it carries none
|
|
278
|
+
* (ADR-0022 "Cross-origin endpoints"). Only a contract application's: an installation row of method `*` serves
|
|
279
|
+
* `OPTIONS` itself.
|
|
280
|
+
*/
|
|
281
|
+
private decidePreflight(table: CompiledEndpointTable<ManifestEndpointRow>, app: ProxyApp, request: ForwardedRequest): Decision | null {
|
|
282
|
+
const origin = request.headers.get(ORIGIN_HEADER)
|
|
283
|
+
const method = request.headers.get(PREFLIGHT_METHOD_HEADER)
|
|
284
|
+
if (!isApplicationEntry(app) || request.method !== 'OPTIONS' || origin === null || method === null) {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
const plan = planEndpoint(table, method, request.rawPath)
|
|
288
|
+
if (plan.outcome === 'non-canonical') {
|
|
289
|
+
return { outcome: 'deny', status: 400, reason: 'non_canonical_path' }
|
|
290
|
+
}
|
|
291
|
+
if (plan.outcome === 'no-endpoint') {
|
|
292
|
+
return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
|
|
293
|
+
}
|
|
294
|
+
const allowOrigin = admittedOrigin(app, plan.endpoint, origin)
|
|
295
|
+
return allowOrigin === null
|
|
296
|
+
? { outcome: 'deny', status: 403, reason: 'cross_origin' }
|
|
297
|
+
: { outcome: 'preflight', allowOrigin, method: plan.endpoint.method }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The selected row's checks. `crossOrigin` is the admitted origin of a cross-origin request, `*` or none. */
|
|
301
|
+
private async decideRow(app: ProxyApp, row: ManifestEndpointRow, request: ForwardedRequest, crossOrigin: string | null): Promise<Decision> {
|
|
241
302
|
const endpoint = formatEndpointHeader(row.id, row.digest)
|
|
242
303
|
if (row.requirement === null) {
|
|
243
|
-
return admitBody(row, request.headers) ?? { outcome: 'allow', token: null, subject: null, endpoint, delegation: null }
|
|
304
|
+
return admitBody(row, request.headers) ?? { outcome: 'allow', token: null, subject: null, endpoint, delegation: null, crossOrigin }
|
|
244
305
|
}
|
|
245
306
|
const oauth = oauthBindingOf(app, row)
|
|
246
307
|
if (oauth === 'unregistered') {
|
|
@@ -248,7 +309,8 @@ export class Authorizer {
|
|
|
248
309
|
return { outcome: 'deny', status: 403, reason: 'internal_error' }
|
|
249
310
|
}
|
|
250
311
|
const callers: readonly TokenCaller[] = row.callers
|
|
251
|
-
|
|
312
|
+
// A listed origin's request is never session-authenticated: a session does not cross origins.
|
|
313
|
+
const caller = await this.endpointCaller(app, oauth, callers, request, crossOrigin !== null)
|
|
252
314
|
if (!caller.ok) {
|
|
253
315
|
return caller.decision
|
|
254
316
|
}
|
|
@@ -276,6 +338,7 @@ export class Authorizer {
|
|
|
276
338
|
subject: caller.claims.sub,
|
|
277
339
|
endpoint,
|
|
278
340
|
delegation: delegation?.grant ?? null,
|
|
341
|
+
crossOrigin,
|
|
279
342
|
}
|
|
280
343
|
}
|
|
281
344
|
|
|
@@ -288,6 +351,7 @@ export class Authorizer {
|
|
|
288
351
|
oauth: OAuthBinding | null,
|
|
289
352
|
callers: readonly TokenCaller[],
|
|
290
353
|
request: ForwardedRequest,
|
|
354
|
+
crossOrigin: boolean,
|
|
291
355
|
): Promise<EndpointCaller> {
|
|
292
356
|
const authorization = request.headers.get('Authorization')
|
|
293
357
|
if (authorization !== null && authorization.trim() !== '') {
|
|
@@ -301,8 +365,9 @@ export class Authorizer {
|
|
|
301
365
|
? { ok: true, token: key.token, claims: key.claims, session: false }
|
|
302
366
|
: { ok: false, decision: withBearerChallenge(key.denial, oauth) }
|
|
303
367
|
}
|
|
304
|
-
if (!callers.includes('human')) {
|
|
305
|
-
// No session reaches an endpoint a person may not call,
|
|
368
|
+
if (!callers.includes('human') || crossOrigin) {
|
|
369
|
+
// No session reaches an endpoint a person may not call, nor one called from another origin, so there is
|
|
370
|
+
// nothing to sign in to.
|
|
306
371
|
const denial: Denial = { outcome: 'deny', status: 401, reason: 'no_credential' }
|
|
307
372
|
return { ok: false, decision: oauth === null ? denial : { ...denial, challenge: { resource_metadata: oauth.resource.resourceMetadata } } }
|
|
308
373
|
}
|
|
@@ -549,6 +614,19 @@ export class Authorizer {
|
|
|
549
614
|
}
|
|
550
615
|
}
|
|
551
616
|
|
|
617
|
+
/**
|
|
618
|
+
* The `Access-Control-Allow-Origin` a request from `origin` gets on `row`: `*` on a row open to any origin, the origin
|
|
619
|
+
* itself when the row takes the environment's list and the entry lists it, and `null` otherwise. An installation row
|
|
620
|
+
* has no cross-origin form.
|
|
621
|
+
*/
|
|
622
|
+
function admittedOrigin(app: ProxyApp, row: ManifestEndpointRow, origin: string): string | null {
|
|
623
|
+
const form = 'crossOrigin' in row ? row.crossOrigin : undefined
|
|
624
|
+
if (form === 'any') {
|
|
625
|
+
return '*'
|
|
626
|
+
}
|
|
627
|
+
return form === 'environment' && isApplicationEntry(app) && (app.crossOrigins ?? []).includes(origin) ? origin : null
|
|
628
|
+
}
|
|
629
|
+
|
|
552
630
|
function invalidKey(): Denial {
|
|
553
631
|
return { outcome: 'deny', status: 401, reason: 'invalid_key' }
|
|
554
632
|
}
|
package/src/constants.ts
CHANGED
|
@@ -21,6 +21,12 @@ export const REQUEST_ID_HEADER = 'X-Request-Id'
|
|
|
21
21
|
|
|
22
22
|
/** The auth service's decision endpoint (Caddy's `uri` subdirective). */
|
|
23
23
|
export const DEFAULT_VERIFY_PATH = '/verify'
|
|
24
|
+
/**
|
|
25
|
+
* Set on the auth service's 2xx to a preflight it admitted (ADR-0022 "Cross-origin endpoints"). The answer is the
|
|
26
|
+
* preflight's whole answer: the composition returns it to the browser, without the marker, and never dials the app.
|
|
27
|
+
*/
|
|
28
|
+
export const PREFLIGHT_HEADER = 'X-Fabrika-Preflight'
|
|
29
|
+
|
|
24
30
|
/** Liveness endpoint on the auth service itself — never routed publicly. */
|
|
25
31
|
export const DEFAULT_HEALTH_PATH = '/healthz'
|
|
26
32
|
/**
|
package/src/headers.ts
CHANGED
|
@@ -175,7 +175,62 @@ export function appFileTreatment(app: { readonly scheme: PublicScheme }): Header
|
|
|
175
175
|
* Headers an application may not send at all: each names a reporting endpoint the policy never approved, and a
|
|
176
176
|
* report-only CSP reports there whatever the enforced one says (`document-uri`, `blocked-uri`).
|
|
177
177
|
*/
|
|
178
|
-
export const REMOVED_RESPONSE_HEADERS: readonly string[] = [
|
|
178
|
+
export const REMOVED_RESPONSE_HEADERS: readonly string[] = [
|
|
179
|
+
'Content-Security-Policy-Report-Only',
|
|
180
|
+
'Report-To',
|
|
181
|
+
'Reporting-Endpoints',
|
|
182
|
+
'NEL',
|
|
183
|
+
// Only the proxy opens an endpoint to another origin (ADR-0022 "Cross-origin endpoints"); a handler's own never passes.
|
|
184
|
+
'Access-Control-Allow-Origin',
|
|
185
|
+
'Access-Control-Allow-Credentials',
|
|
186
|
+
'Access-Control-Allow-Methods',
|
|
187
|
+
'Access-Control-Allow-Headers',
|
|
188
|
+
'Access-Control-Expose-Headers',
|
|
189
|
+
'Access-Control-Max-Age',
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
/** The most origins one environment may list for its cross-origin endpoints: Control and the manifest parser refuse more. */
|
|
193
|
+
export const MAX_CROSS_ORIGINS = 16
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* An origin an environment may list for its cross-origin endpoints: exact, a literal host, `https:` (or `http:`, which
|
|
197
|
+
* only a local host is served over). `null`, an opaque origin, never matches.
|
|
198
|
+
*/
|
|
199
|
+
export function isCrossOrigin(value: string): boolean {
|
|
200
|
+
return isPolicyOrigin(value, ['https:', 'http:'])
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** What a cross-origin page may send beside the safelisted headers: the key, and the JSON body's type. */
|
|
204
|
+
export const CROSS_ORIGIN_REQUEST_HEADERS = 'Authorization, Content-Type'
|
|
205
|
+
|
|
206
|
+
/** How long a browser may reuse an answered preflight, in seconds. */
|
|
207
|
+
export const CROSS_ORIGIN_MAX_AGE = '600'
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The headers an answer to an admitted cross-origin request carries: the admitted origin, and `Vary: Origin` unless every
|
|
211
|
+
* origin is admitted. Never `Access-Control-Allow-Credentials`: a session does not cross origins.
|
|
212
|
+
*/
|
|
213
|
+
export function crossOriginHeaders(allowOrigin: string): HeaderPolicy {
|
|
214
|
+
return allowOrigin === '*' ? [['Access-Control-Allow-Origin', '*']] : [['Access-Control-Allow-Origin', allowOrigin], ['Vary', 'Origin']]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A preflight's answer on top of `crossOriginHeaders`: the endpoint's one method, the two headers, the cache lifetime. */
|
|
218
|
+
export function preflightHeaders(allowOrigin: string, method: string): HeaderPolicy {
|
|
219
|
+
return [
|
|
220
|
+
...crossOriginHeaders(allowOrigin),
|
|
221
|
+
['Access-Control-Allow-Methods', method],
|
|
222
|
+
['Access-Control-Allow-Headers', CROSS_ORIGIN_REQUEST_HEADERS],
|
|
223
|
+
['Access-Control-Max-Age', CROSS_ORIGIN_MAX_AGE],
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `crossOriginHeaders` over headers that already carry the answer's own policy; `Vary` is added to, never replaced. */
|
|
228
|
+
export function applyCrossOriginHeaders(headers: Headers, policy: HeaderPolicy): void {
|
|
229
|
+
for (const [name, value] of policy) {
|
|
230
|
+
if (name === 'Vary') headers.append(name, value)
|
|
231
|
+
else headers.set(name, value)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
179
234
|
|
|
180
235
|
/** `null` and anything but one allowlisted value is an attachment; `Headers` joins repeated fields with `, `, so two never match. */
|
|
181
236
|
export function fileDisposition(contentType: string | null): 'inline' | 'attachment' {
|
package/src/index.ts
CHANGED
package/src/manifest.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ANY_ORIGIN_KINDS,
|
|
2
3
|
APP_ENVIRONMENT_SEPARATOR,
|
|
3
4
|
type Caller,
|
|
4
5
|
compileEndpointTable,
|
|
5
6
|
type ContractArtifact,
|
|
6
7
|
contractDigest,
|
|
7
8
|
type CredentialedEndpoint,
|
|
9
|
+
CROSS_ORIGIN_KINDS,
|
|
10
|
+
type CrossOriginForm,
|
|
8
11
|
type EndpointDelegation,
|
|
9
12
|
endpointDelegation,
|
|
10
13
|
type EndpointRow,
|
|
@@ -16,7 +19,7 @@ import {
|
|
|
16
19
|
requirementDigest,
|
|
17
20
|
} from '@fabrika/auth-core'
|
|
18
21
|
import { z } from 'zod'
|
|
19
|
-
import { type CspOrigins, cspOrigins, isPolicyOrigin, MAX_CSP_ORIGINS } from './headers'
|
|
22
|
+
import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS } from './headers'
|
|
20
23
|
import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
|
|
21
24
|
|
|
22
25
|
export * from './oauth-resource'
|
|
@@ -46,6 +49,11 @@ export interface ContractProxyApp extends ProxyAppBase {
|
|
|
46
49
|
csp: CspOrigins
|
|
47
50
|
/** The installation's Operations ingest origin, admitted by `connect-src`; absent when the producer knows none. */
|
|
48
51
|
operationsOrigin?: string
|
|
52
|
+
/**
|
|
53
|
+
* The origins the environment lists for its `crossOrigin: 'environment'` endpoints (ADR-0022 "Cross-origin
|
|
54
|
+
* endpoints"), from Control's registry, never from the contract; absent when it lists none.
|
|
55
|
+
*/
|
|
56
|
+
crossOrigins?: string[]
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -84,11 +92,14 @@ interface ProxyEndpointExtras {
|
|
|
84
92
|
|
|
85
93
|
/** An endpoint row as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
|
|
86
94
|
export type ProxyEndpointRow =
|
|
87
|
-
| (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission'> & ProxyEndpointExtras)
|
|
88
|
-
| (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
| (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'> & ProxyEndpointExtras)
|
|
96
|
+
| (
|
|
97
|
+
& Pick<Extract<EndpointRow, { requirement: null }>, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'>
|
|
98
|
+
& {
|
|
99
|
+
digest: string
|
|
100
|
+
delegation: null
|
|
101
|
+
}
|
|
102
|
+
)
|
|
92
103
|
|
|
93
104
|
/** An installation row as the proxy applies it: the row with its digest and no delegation. */
|
|
94
105
|
export type InstallationProxyEndpointRow = InstallationEndpointRow & { digest: string; delegation: null }
|
|
@@ -168,6 +179,7 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
168
179
|
callers: z.array(callerSchema).nonempty(),
|
|
169
180
|
requirement: requirementSchema,
|
|
170
181
|
delegation: z.enum(['members', 'creates']).nullable(),
|
|
182
|
+
crossOrigin: z.literal('environment').optional(),
|
|
171
183
|
}).strict(),
|
|
172
184
|
z.object({
|
|
173
185
|
...endpointRuleFields,
|
|
@@ -175,6 +187,7 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
175
187
|
callers: z.tuple([]),
|
|
176
188
|
requirement: z.null(),
|
|
177
189
|
delegation: z.null(),
|
|
190
|
+
crossOrigin: z.enum(['environment', 'any']).optional(),
|
|
178
191
|
}).strict(),
|
|
179
192
|
// A public SPA is a fixed static row: it serves GET only and admits no body.
|
|
180
193
|
z.object({
|
|
@@ -186,12 +199,32 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
186
199
|
requirement: z.null(),
|
|
187
200
|
delegation: z.null(),
|
|
188
201
|
}).strict(),
|
|
189
|
-
]).refine(
|
|
202
|
+
]).refine(
|
|
203
|
+
(row) => crossOriginAllowed(row.kind, 'crossOrigin' in row ? row.crossOrigin : undefined),
|
|
204
|
+
'this endpoint kind cannot be called cross-origin',
|
|
205
|
+
).refine(
|
|
206
|
+
(row) => row.digest === requirementDigest(row),
|
|
207
|
+
'endpoint digest does not match its rules',
|
|
208
|
+
) satisfies z.ZodType<
|
|
190
209
|
ProxyEndpointRow,
|
|
191
210
|
z.ZodTypeDef,
|
|
192
211
|
unknown
|
|
193
212
|
>
|
|
194
213
|
|
|
214
|
+
/** Only an RPC procedure and a form open to another origin, and only a form to any origin (ADR-0022). */
|
|
215
|
+
function crossOriginAllowed(kind: ProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
|
|
216
|
+
return form === undefined || (CROSS_ORIGIN_KINDS.includes(kind) && (form !== 'any' || ANY_ORIGIN_KINDS.includes(kind)))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Exact origins, each once, never one of the entry's own hosts: its own pages are not cross-origin. */
|
|
220
|
+
function checkCrossOrigins(app: Pick<ContractProxyApp, 'hosts' | 'crossOrigins'>, ctx: z.RefinementCtx): void {
|
|
221
|
+
const origins = app.crossOrigins ?? []
|
|
222
|
+
// A refinement runs over an origin its element check already refused, so the host is read without throwing.
|
|
223
|
+
if (new Set(origins).size !== origins.length || origins.some((origin) => !isCrossOrigin(origin) || app.hosts.includes(new URL(origin).hostname))) {
|
|
224
|
+
ctx.addIssue({ code: 'custom', message: 'invalid cross-origin list' })
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
195
228
|
const cspOriginSchema = z.string().refine((value) => isPolicyOrigin(value))
|
|
196
229
|
const cspDirectiveSchema = z.array(cspOriginSchema).max(MAX_CSP_ORIGINS)
|
|
197
230
|
const cspOriginsSchema = z.object({
|
|
@@ -214,7 +247,9 @@ export const contractProxyAppSchema = z.object({
|
|
|
214
247
|
snapshot: z.string().refine(isDigest),
|
|
215
248
|
csp: cspOriginsSchema,
|
|
216
249
|
operationsOrigin: z.string().refine((value) => isPolicyOrigin(value, ['https:', 'http:'])).optional(),
|
|
250
|
+
crossOrigins: z.array(z.string().refine(isCrossOrigin)).nonempty().max(MAX_CROSS_ORIGINS).optional(),
|
|
217
251
|
}).strict().superRefine((app, ctx) => {
|
|
252
|
+
checkCrossOrigins(app, ctx)
|
|
218
253
|
checkEndpointTable(app, ctx)
|
|
219
254
|
}) satisfies z.ZodType<ContractProxyApp, z.ZodTypeDef, unknown>
|
|
220
255
|
|
|
@@ -308,7 +343,13 @@ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxy
|
|
|
308
343
|
export function contractProxyTable(artifact: ContractArtifact): ContractProxyTable {
|
|
309
344
|
return {
|
|
310
345
|
endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
|
|
311
|
-
const rules = {
|
|
346
|
+
const rules = {
|
|
347
|
+
id: row.id,
|
|
348
|
+
method: row.method,
|
|
349
|
+
path: row.path,
|
|
350
|
+
admission: row.admission,
|
|
351
|
+
...(row.crossOrigin === undefined ? {} : { crossOrigin: row.crossOrigin }),
|
|
352
|
+
}
|
|
312
353
|
if (row.requirement === null) {
|
|
313
354
|
const anonymous = { ...rules, kind: row.kind, callers: row.callers, requirement: null, delegation: null }
|
|
314
355
|
return { ...anonymous, digest: requirementDigest(anonymous) }
|
package/src/service.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
FORWARDED_METHOD_HEADER,
|
|
44
44
|
FORWARDED_PROTO_HEADER,
|
|
45
45
|
FORWARDED_URI_HEADER,
|
|
46
|
+
PREFLIGHT_HEADER,
|
|
46
47
|
PROXY_TOKEN_HEADER,
|
|
47
48
|
REQUEST_ID_HEADER,
|
|
48
49
|
} from './constants'
|
|
@@ -50,10 +51,13 @@ import { type HandoffAttempt, handoffCookieName } from './handoff'
|
|
|
50
51
|
import {
|
|
51
52
|
appFileTreatment,
|
|
52
53
|
appHeaderPolicy,
|
|
54
|
+
applyCrossOriginHeaders,
|
|
53
55
|
applyFileTreatment,
|
|
54
56
|
applyHeaderPolicy,
|
|
55
57
|
BASE_HEADER_POLICY,
|
|
58
|
+
crossOriginHeaders,
|
|
56
59
|
type HeaderPolicy,
|
|
60
|
+
preflightHeaders,
|
|
57
61
|
transportPolicy,
|
|
58
62
|
} from './headers'
|
|
59
63
|
import type { DelegationIssuer, IamGateway } from './iam'
|
|
@@ -179,12 +183,26 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
179
183
|
if (decision.delegation !== null) {
|
|
180
184
|
headers.set(DELEGATION_HEADER, decision.delegation)
|
|
181
185
|
}
|
|
186
|
+
// The composition writes it onto the application's answer, after the policy deleted the application's own.
|
|
187
|
+
if (decision.crossOrigin !== null) {
|
|
188
|
+
applyCrossOriginHeaders(headers, crossOriginHeaders(decision.crossOrigin))
|
|
189
|
+
}
|
|
182
190
|
return new Response(null, { status: 204, headers })
|
|
183
191
|
}
|
|
192
|
+
const policy = policies.get(resolved.app.id) ?? BASE_HEADER_POLICY
|
|
193
|
+
if (decision.outcome === 'preflight') {
|
|
194
|
+
logger.info('preflight', fields)
|
|
195
|
+
// A 2xx the composition answers as-is, so the preflight never reaches the application.
|
|
196
|
+
const answer = withPolicy(new Response(null, { status: 204, headers: { 'cache-control': 'no-store', [PREFLIGHT_HEADER]: '1' } }), policy)
|
|
197
|
+
applyCrossOriginHeaders(answer.headers, preflightHeaders(decision.allowOrigin, decision.method))
|
|
198
|
+
return answer
|
|
199
|
+
}
|
|
184
200
|
const answer = decision.outcome === 'handoff' ? handoff(logger, fields, decision) : denied(logger, fields, decision, isCallback)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
const treated = isDownload(resolved, forwarded) ? withFileTreatment(answer, appFileTreatment(resolved.app)) : withPolicy(answer, policy)
|
|
202
|
+
if (decision.outcome === 'deny' && decision.crossOrigin !== undefined) {
|
|
203
|
+
applyCrossOriginHeaders(treated.headers, crossOriginHeaders(decision.crossOrigin))
|
|
204
|
+
}
|
|
205
|
+
return treated
|
|
188
206
|
} catch {
|
|
189
207
|
// The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
|
|
190
208
|
// error can stringify a URL, a header or a body, any of which may carry a credential.
|