@fabrika/proxy-core 0.6.0 → 0.8.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.
- package/package.json +4 -4
- package/src/authorize.ts +47 -9
- package/src/constants.ts +7 -0
- package/src/credentials.ts +21 -0
- package/src/headers.ts +27 -1
- package/src/index.ts +2 -1
- package/src/manifest.ts +46 -8
- package/src/service.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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.
|
|
41
|
-
"@fabrika/jwks": "0.
|
|
40
|
+
"@fabrika/auth-core": "0.8.0",
|
|
41
|
+
"@fabrika/jwks": "0.8.0",
|
|
42
42
|
"jose": "^5.9.0",
|
|
43
|
-
"@fabrika/platform": "0.
|
|
43
|
+
"@fabrika/platform": "0.8.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/authorize.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
callerOf,
|
|
28
28
|
type CompiledEndpointTable,
|
|
29
29
|
FORM_MULTIPART_TYPE,
|
|
30
|
+
FORM_URLENCODED_TYPE,
|
|
30
31
|
formatEndpointHeader,
|
|
31
32
|
planEndpoint,
|
|
32
33
|
satisfies,
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
} from '@fabrika/auth-core'
|
|
37
38
|
import { cacheKey, type TokenCache } from './cache'
|
|
38
39
|
import { LOGIN_REDIRECT_PARAM } from './constants'
|
|
39
|
-
import { readBearer, readCookie } from './credentials'
|
|
40
|
+
import { carriesPlatformKey, readBearer, readCookie } from './credentials'
|
|
40
41
|
import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
|
|
41
42
|
import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
|
|
42
43
|
import {
|
|
@@ -109,6 +110,11 @@ export type Decision =
|
|
|
109
110
|
delegation: string | null
|
|
110
111
|
/** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
|
|
111
112
|
crossOrigin: string | null
|
|
113
|
+
/**
|
|
114
|
+
* Whether the request's own `Authorization` reaches the application: on a webhook row that forwards it, unless it
|
|
115
|
+
* carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
|
|
116
|
+
*/
|
|
117
|
+
forwardsAuthorization: boolean
|
|
112
118
|
}
|
|
113
119
|
| {
|
|
114
120
|
outcome: 'deny'
|
|
@@ -306,7 +312,15 @@ export class Authorizer {
|
|
|
306
312
|
const endpoint = formatEndpointHeader(row.id, row.digest)
|
|
307
313
|
if (row.requirement === null) {
|
|
308
314
|
return admitBody(row, request.headers) ?? admitFormOrigin(app, row, request, crossOrigin)
|
|
309
|
-
?? {
|
|
315
|
+
?? {
|
|
316
|
+
outcome: 'allow',
|
|
317
|
+
token: null,
|
|
318
|
+
subject: null,
|
|
319
|
+
endpoint,
|
|
320
|
+
delegation: null,
|
|
321
|
+
crossOrigin,
|
|
322
|
+
forwardsAuthorization: forwardsAuthorization(row, request),
|
|
323
|
+
}
|
|
310
324
|
}
|
|
311
325
|
const oauth = oauthBindingOf(app, row)
|
|
312
326
|
if (oauth === 'unregistered') {
|
|
@@ -344,6 +358,7 @@ export class Authorizer {
|
|
|
344
358
|
endpoint,
|
|
345
359
|
delegation: delegation?.grant ?? null,
|
|
346
360
|
crossOrigin,
|
|
361
|
+
forwardsAuthorization: false,
|
|
347
362
|
}
|
|
348
363
|
}
|
|
349
364
|
|
|
@@ -661,27 +676,41 @@ function withBearerChallenge(denial: Denial, oauth: OAuthBinding | null): Denial
|
|
|
661
676
|
return { ...denial, challenge: denial.status === 401 ? { ...challenge, error: 'invalid_token' } : challenge }
|
|
662
677
|
}
|
|
663
678
|
|
|
679
|
+
/** The media types a browser sends from any page without a CORS preflight, which therefore carry no origin check of their own. */
|
|
680
|
+
const PREFLIGHTLESS_TYPES: readonly string[] = [FORM_MULTIPART_TYPE, FORM_URLENCODED_TYPE]
|
|
681
|
+
|
|
664
682
|
/**
|
|
665
|
-
*
|
|
666
|
-
* "Cross-origin endpoints"
|
|
667
|
-
* at the request what it refuses at the preflight of a JSON
|
|
668
|
-
* cross-origin request.
|
|
683
|
+
* An anonymous body a browser sends without a preflight is admitted only from the application's own origin or one the
|
|
684
|
+
* row admits (ADR-0022 "Cross-origin endpoints", the browser admission matrix): a multipart form, and a public
|
|
685
|
+
* procedure's multipart or urlencoded body. The proxy refuses at the request what it refuses at the preflight of a JSON
|
|
686
|
+
* body. A request without `Origin` is no browser's cross-origin request; `Origin: null` names no origin and is refused.
|
|
669
687
|
*/
|
|
670
688
|
function admitFormOrigin(app: ProxyApp, row: ManifestEndpointRow, request: ForwardedRequest, crossOrigin: string | null): Denial | null {
|
|
671
689
|
const origin = request.headers.get(ORIGIN_HEADER)
|
|
672
|
-
if (
|
|
690
|
+
if (origin === null || crossOrigin !== null || !preflightless(row, request.headers)) {
|
|
673
691
|
return null
|
|
674
692
|
}
|
|
675
693
|
return origin === `${app.scheme}://${request.url.host}` ? null : { outcome: 'deny', status: 403, reason: 'cross_origin' }
|
|
676
694
|
}
|
|
677
695
|
|
|
696
|
+
/** A form that takes files (whatever this request sends), or a public procedure's request of a preflightless type. */
|
|
697
|
+
function preflightless(row: ManifestEndpointRow, headers: Headers): boolean {
|
|
698
|
+
if (row.kind === 'form') {
|
|
699
|
+
return row.admission?.contentTypes.includes(FORM_MULTIPART_TYPE) ?? false
|
|
700
|
+
}
|
|
701
|
+
return row.kind === 'public' && PREFLIGHTLESS_TYPES.includes(mediaTypeOf(headers))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function mediaTypeOf(headers: Headers): string {
|
|
705
|
+
return headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() ?? ''
|
|
706
|
+
}
|
|
707
|
+
|
|
678
708
|
/** An endpoint that admits a body admits only its declared media types (ADR-0022 rule 3, admission). */
|
|
679
709
|
function admitBody(row: ManifestEndpointRow, headers: Headers): Denial | null {
|
|
680
710
|
if (row.admission === null) {
|
|
681
711
|
return null
|
|
682
712
|
}
|
|
683
|
-
|
|
684
|
-
return row.admission.contentTypes.includes(type) ? null : { outcome: 'deny', status: 400, reason: 'content_type' }
|
|
713
|
+
return row.admission.contentTypes.includes(mediaTypeOf(headers)) ? null : { outcome: 'deny', status: 400, reason: 'content_type' }
|
|
685
714
|
}
|
|
686
715
|
|
|
687
716
|
/**
|
|
@@ -707,3 +736,12 @@ function isDocumentNavigation(headers: Headers): boolean {
|
|
|
707
736
|
const mode = headers.get(FETCH_MODE_HEADER)
|
|
708
737
|
return mode === null || mode === '' || mode === 'navigate'
|
|
709
738
|
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* The `Authorization` of an anonymous request is the application's only on a row that forwards it, and never when it
|
|
742
|
+
* carries a platform key. The proxy reads no credential from it either way: no key lookup, no session.
|
|
743
|
+
*/
|
|
744
|
+
function forwardsAuthorization(row: ManifestEndpointRow, request: ForwardedRequest): boolean {
|
|
745
|
+
const authorization = request.headers.get('Authorization')
|
|
746
|
+
return 'authorization' in row && row.authorization === 'forward' && authorization !== null && !carriesPlatformKey(authorization, API_KEY_PREFIX)
|
|
747
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -27,6 +27,13 @@ export const DEFAULT_VERIFY_PATH = '/verify'
|
|
|
27
27
|
*/
|
|
28
28
|
export const PREFLIGHT_HEADER = 'X-Fabrika-Preflight'
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Set on the auth service's 2xx when the request's own `Authorization` goes on to the application: an anonymous webhook
|
|
32
|
+
* row that forwards it. Every composition deletes `Authorization` from an anonymous request without it, and passes the
|
|
33
|
+
* marker on to no one.
|
|
34
|
+
*/
|
|
35
|
+
export const FORWARD_AUTHORIZATION_HEADER = 'X-Fabrika-Forward-Authorization'
|
|
36
|
+
|
|
30
37
|
/** Liveness endpoint on the auth service itself — never routed publicly. */
|
|
31
38
|
export const DEFAULT_HEALTH_PATH = '/healthz'
|
|
32
39
|
/**
|
package/src/credentials.ts
CHANGED
|
@@ -9,6 +9,27 @@ export function readBearer(header: string | null): string | null {
|
|
|
9
9
|
return match ? (match[1]?.trim() ?? null) : null
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Whether an `Authorization` value carries a platform key anywhere: the header itself, or the user or password of Basic
|
|
14
|
+
* credentials. Such a value is never forwarded to an application, whatever its row says: a key sent to a webhook URL
|
|
15
|
+
* by mistake must not reach application code.
|
|
16
|
+
*/
|
|
17
|
+
export function carriesPlatformKey(header: string, keyPrefix: string): boolean {
|
|
18
|
+
if (header.includes(keyPrefix)) {
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(header.trim())
|
|
22
|
+
if (basic?.[1] === undefined) {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return atob(basic[1]).includes(keyPrefix)
|
|
27
|
+
} catch {
|
|
28
|
+
// Not base64: the application refuses it as a verifier would, and it holds no readable key.
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
/** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
|
|
13
34
|
export function readCookie(header: string | null, name: string): string | null {
|
|
14
35
|
if (!header) {
|
package/src/headers.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// "Response headers"). The proxy sets them on every answer, its own denials included; the framework sets the same
|
|
3
3
|
// definition in-process. Exported as `@fabrika/proxy-core/headers`, which imports nothing at runtime.
|
|
4
4
|
|
|
5
|
-
import type { CspExtensions } from '@fabrika/auth-core'
|
|
5
|
+
import type { CspExtensions, EndpointCache } from '@fabrika/auth-core'
|
|
6
6
|
|
|
7
7
|
/** Every powerful feature off. ADR-0022 lists the same names; change both together. */
|
|
8
8
|
export const PERMISSIONS_POLICY_FEATURES: readonly string[] = [
|
|
@@ -54,8 +54,17 @@ export interface CspOrigins {
|
|
|
54
54
|
readonly frameSrc: readonly string[]
|
|
55
55
|
readonly styleSrc: readonly string[]
|
|
56
56
|
readonly fontSrc: readonly string[]
|
|
57
|
+
/**
|
|
58
|
+
* `worker-src 'self' blob:` is emitted only when it holds `blob:`, its one source (a challenge widget that starts its
|
|
59
|
+
* workers from `Blob` URLs); without it workers fall back to `script-src`. Absent from every manifest without it, so a
|
|
60
|
+
* proxy that does not know the directive still parses those.
|
|
61
|
+
*/
|
|
62
|
+
readonly workerSrc?: readonly string[]
|
|
57
63
|
}
|
|
58
64
|
|
|
65
|
+
/** The one source a contract's `workerSrc` holds: a worker the page's own script built from a `Blob`. */
|
|
66
|
+
export const WORKER_BLOB_SOURCE = 'blob:'
|
|
67
|
+
|
|
59
68
|
const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
60
69
|
|
|
61
70
|
/**
|
|
@@ -93,6 +102,7 @@ export function cspOrigins(csp: CspExtensions): CspOrigins {
|
|
|
93
102
|
frameSrc: (csp.frameSrc ?? []).map((source) => source.origin),
|
|
94
103
|
styleSrc: (csp.styleSrc ?? []).map((source) => source.origin),
|
|
95
104
|
fontSrc: (csp.fontSrc ?? []).map((source) => source.origin),
|
|
105
|
+
...(csp.workerSrc === undefined ? {} : { workerSrc: csp.workerSrc.map((source) => source.origin) }),
|
|
96
106
|
}
|
|
97
107
|
}
|
|
98
108
|
|
|
@@ -116,6 +126,7 @@ export function headerPolicy(origins: CspOrigins, operationsOrigin: string | nul
|
|
|
116
126
|
`frame-ancestors ${frameAncestors.join(' ')}`,
|
|
117
127
|
// Without it frames fall back to `default-src 'self'`; a declared origin only extends that, so `'self'` stays.
|
|
118
128
|
...(origins.frameSrc.length === 0 ? [] : [`frame-src ${["'self'", ...origins.frameSrc].join(' ')}`]),
|
|
129
|
+
...(origins.workerSrc?.includes(WORKER_BLOB_SOURCE) === true ? [`worker-src 'self' ${WORKER_BLOB_SOURCE}`] : []),
|
|
119
130
|
]
|
|
120
131
|
return Object.freeze([
|
|
121
132
|
['Content-Security-Policy', directives.join('; ')],
|
|
@@ -257,6 +268,21 @@ export function applyFileTreatment(headers: Headers, treatment: HeaderPolicy = F
|
|
|
257
268
|
headers.set('Content-Disposition', `${fileDisposition(headers.get('Content-Type'))}${parameters}`)
|
|
258
269
|
}
|
|
259
270
|
|
|
271
|
+
/**
|
|
272
|
+
* A cacheable row's successful answer (`http.cache`): its `Cache-Control` replaces the `no-store` of the policy or the
|
|
273
|
+
* file treatment. Every other answer, an error or a redirect included, keeps `no-store`.
|
|
274
|
+
*/
|
|
275
|
+
export function applyAnswerCache(headers: Headers, row: { readonly cache?: EndpointCache }, status: number): void {
|
|
276
|
+
if (row.cache !== undefined && status === 200) {
|
|
277
|
+
headers.set('Cache-Control', cacheControlOf(row.cache))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The `Cache-Control` of a cacheable row's successful answer: `immutable` is reused for a year. */
|
|
282
|
+
export function cacheControlOf(cache: EndpointCache): string {
|
|
283
|
+
return cache === 'immutable' ? 'public, max-age=31536000, immutable' : `public, max-age=${cache.maxAgeSeconds}`
|
|
284
|
+
}
|
|
285
|
+
|
|
260
286
|
/** A copy of `response` whose headers `apply` rewrote; status and body are unchanged. */
|
|
261
287
|
export function withResponseHeaders(response: Response, apply: (headers: Headers) => void): Response {
|
|
262
288
|
const headers = new Headers(response.headers)
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
DEFAULT_VERIFY_PATH,
|
|
23
23
|
DELEGATION_HEADER,
|
|
24
24
|
ENDPOINT_HEADER,
|
|
25
|
+
FORWARD_AUTHORIZATION_HEADER,
|
|
25
26
|
FORWARDED_HOST_HEADER,
|
|
26
27
|
FORWARDED_METHOD_HEADER,
|
|
27
28
|
FORWARDED_PROTO_HEADER,
|
|
@@ -33,7 +34,7 @@ export {
|
|
|
33
34
|
REQUEST_ID_HEADER,
|
|
34
35
|
UNTRUSTED_FORWARD_HEADERS,
|
|
35
36
|
} from './constants'
|
|
36
|
-
export { readBearer, readCookie } from './credentials'
|
|
37
|
+
export { carriesPlatformKey, readBearer, readCookie } from './credentials'
|
|
37
38
|
export { stripFabrikaCookies } from './handoff'
|
|
38
39
|
export { HttpIamGateway, IamUnavailableError } from './iam'
|
|
39
40
|
export type { DelegationIssuer, DelegationRequest, HttpIamGatewayOptions, IamGateway } from './iam'
|
package/src/manifest.ts
CHANGED
|
@@ -12,14 +12,16 @@ import {
|
|
|
12
12
|
endpointDelegation,
|
|
13
13
|
type EndpointRow,
|
|
14
14
|
EndpointTableError,
|
|
15
|
+
FILE_RESPONSE_KINDS,
|
|
15
16
|
type InstallationEndpointRow,
|
|
16
17
|
type InstallationEndpointTable,
|
|
17
18
|
isDigest,
|
|
19
|
+
MAX_CACHE_AGE_SECONDS,
|
|
18
20
|
parseAppEnvironmentId,
|
|
19
21
|
requirementDigest,
|
|
20
22
|
} from '@fabrika/auth-core'
|
|
21
23
|
import { z } from 'zod'
|
|
22
|
-
import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS } from './headers'
|
|
24
|
+
import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from './headers'
|
|
23
25
|
import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
|
|
24
26
|
|
|
25
27
|
export * from './oauth-resource'
|
|
@@ -92,9 +94,15 @@ interface ProxyEndpointExtras {
|
|
|
92
94
|
|
|
93
95
|
/** An endpoint row as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
|
|
94
96
|
export type ProxyEndpointRow =
|
|
95
|
-
| (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'> & ProxyEndpointExtras)
|
|
96
97
|
| (
|
|
97
|
-
& Pick<
|
|
98
|
+
& Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin' | 'response'>
|
|
99
|
+
& ProxyEndpointExtras
|
|
100
|
+
)
|
|
101
|
+
| (
|
|
102
|
+
& Pick<
|
|
103
|
+
Extract<EndpointRow, { requirement: null }>,
|
|
104
|
+
'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin' | 'response' | 'cache' | 'authorization'
|
|
105
|
+
>
|
|
98
106
|
& {
|
|
99
107
|
digest: string
|
|
100
108
|
delegation: null
|
|
@@ -156,9 +164,19 @@ const accessFormSchema = z.discriminatedUnion('form', [
|
|
|
156
164
|
z.object({ form: z.literal('creates'), dimension: nameSchema }).strict(),
|
|
157
165
|
z.object({ form: z.literal('app') }).strict(),
|
|
158
166
|
])
|
|
167
|
+
const capabilityFormSchema = z.discriminatedUnion('form', [
|
|
168
|
+
z.object({ form: z.literal('over'), dimension: nameSchema }).strict(),
|
|
169
|
+
z.object({ form: z.literal('in'), dimension: nameSchema, from: nameSchema }).strict(),
|
|
170
|
+
z.object({ form: z.literal('on'), dimension: nameSchema, from: nameSchema }).strict(),
|
|
171
|
+
])
|
|
172
|
+
const capabilitySchema = z.object({ action: nameSchema, access: capabilityFormSchema }).strict()
|
|
159
173
|
const requirementSchema = z.discriminatedUnion('kind', [
|
|
160
174
|
z.object({ kind: z.literal('member') }).strict(),
|
|
161
175
|
z.object({ kind: z.literal('action'), action: nameSchema, access: accessFormSchema }).strict(),
|
|
176
|
+
z.object({
|
|
177
|
+
kind: z.literal('capabilities'),
|
|
178
|
+
capabilities: z.record(nameSchema, capabilitySchema).refine((capabilities) => Object.keys(capabilities).length >= 2, 'at least two capabilities'),
|
|
179
|
+
}).strict(),
|
|
162
180
|
])
|
|
163
181
|
const admissionSchema = z.object({
|
|
164
182
|
contentTypes: z.array(nameSchema).nonempty(),
|
|
@@ -180,14 +198,18 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
180
198
|
requirement: requirementSchema,
|
|
181
199
|
delegation: z.enum(['members', 'creates']).nullable(),
|
|
182
200
|
crossOrigin: z.literal('environment').optional(),
|
|
201
|
+
response: z.literal('file').optional(),
|
|
183
202
|
}).strict(),
|
|
184
203
|
z.object({
|
|
185
204
|
...endpointRuleFields,
|
|
186
|
-
kind: z.enum(['health', 'form', 'webhook', 'link-spa']),
|
|
205
|
+
kind: z.enum(['health', 'form', 'webhook', 'link-spa', 'public']),
|
|
187
206
|
callers: z.tuple([]),
|
|
188
207
|
requirement: z.null(),
|
|
189
208
|
delegation: z.null(),
|
|
190
209
|
crossOrigin: z.enum(['environment', 'any']).optional(),
|
|
210
|
+
response: z.literal('file').optional(),
|
|
211
|
+
cache: z.union([z.literal('immutable'), z.object({ maxAgeSeconds: z.number().int().min(1).max(MAX_CACHE_AGE_SECONDS) }).strict()]).optional(),
|
|
212
|
+
authorization: z.literal('forward').optional(),
|
|
191
213
|
}).strict(),
|
|
192
214
|
// A public SPA is a fixed static row: it serves GET only and admits no body.
|
|
193
215
|
z.object({
|
|
@@ -202,6 +224,15 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
202
224
|
]).refine(
|
|
203
225
|
(row) => crossOriginAllowed(row.kind, 'crossOrigin' in row ? row.crossOrigin : undefined),
|
|
204
226
|
'this endpoint kind cannot be called cross-origin',
|
|
227
|
+
).refine(
|
|
228
|
+
(row) => !('response' in row) || row.response === undefined || FILE_RESPONSE_KINDS.includes(row.kind),
|
|
229
|
+
'only a procedure answers a stored file',
|
|
230
|
+
).refine(
|
|
231
|
+
(row) => !('cache' in row) || row.cache === undefined || (row.kind === 'public' && row.method === 'GET'),
|
|
232
|
+
'only a public GET query caches its answer',
|
|
233
|
+
).refine(
|
|
234
|
+
(row) => !('authorization' in row) || row.authorization === undefined || row.kind === 'webhook',
|
|
235
|
+
"only a webhook forwards the request's Authorization",
|
|
205
236
|
).refine(
|
|
206
237
|
(row) => row.digest === requirementDigest(row),
|
|
207
238
|
'endpoint digest does not match its rules',
|
|
@@ -211,7 +242,7 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
211
242
|
unknown
|
|
212
243
|
>
|
|
213
244
|
|
|
214
|
-
/** Only an RPC procedure and a form open to another origin, and only
|
|
245
|
+
/** Only an RPC procedure, a public procedure and a form open to another origin, and only the anonymous two to any (ADR-0022). */
|
|
215
246
|
function crossOriginAllowed(kind: ProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
|
|
216
247
|
return form === undefined || (CROSS_ORIGIN_KINDS.includes(kind) && (form !== 'any' || ANY_ORIGIN_KINDS.includes(kind)))
|
|
217
248
|
}
|
|
@@ -236,6 +267,8 @@ const cspOriginsSchema = z.object({
|
|
|
236
267
|
frameSrc: cspDirectiveSchema.default([]),
|
|
237
268
|
styleSrc: cspDirectiveSchema.default([]),
|
|
238
269
|
fontSrc: cspDirectiveSchema.default([]),
|
|
270
|
+
// Only a challenge widget's workers need it, and its one source is `blob:`; absent everywhere else.
|
|
271
|
+
workerSrc: z.tuple([z.literal(WORKER_BLOB_SOURCE)]).optional(),
|
|
239
272
|
}).strict() satisfies z.ZodType<CspOrigins, z.ZodTypeDef, unknown>
|
|
240
273
|
|
|
241
274
|
// Strict: a key the proxy does not read (`gates`, or an installation entry's field) is refused, never ignored.
|
|
@@ -339,8 +372,10 @@ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxy
|
|
|
339
372
|
/**
|
|
340
373
|
* The manifest form of a compiled contract: every endpoint's rules with their digest and delegation, the reserved
|
|
341
374
|
* prefixes, the contract digest and the named CSP origins. Producers call this; they never assemble rows by hand.
|
|
375
|
+
* `digest` is the one the application's own release computes; Control passes it for an artifact it upcast from an
|
|
376
|
+
* older release's (ADR-0062).
|
|
342
377
|
*/
|
|
343
|
-
export function contractProxyTable(artifact: ContractArtifact): ContractProxyTable {
|
|
378
|
+
export function contractProxyTable(artifact: ContractArtifact, digest: string = contractDigest(artifact)): ContractProxyTable {
|
|
344
379
|
return {
|
|
345
380
|
endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
|
|
346
381
|
const rules = {
|
|
@@ -349,9 +384,12 @@ export function contractProxyTable(artifact: ContractArtifact): ContractProxyTab
|
|
|
349
384
|
path: row.path,
|
|
350
385
|
admission: row.admission,
|
|
351
386
|
...(row.crossOrigin === undefined ? {} : { crossOrigin: row.crossOrigin }),
|
|
387
|
+
...(row.response === undefined ? {} : { response: row.response }),
|
|
352
388
|
}
|
|
353
389
|
if (row.requirement === null) {
|
|
354
|
-
const
|
|
390
|
+
const cache = row.cache === undefined ? {} : { cache: row.cache }
|
|
391
|
+
const authorization = row.authorization === undefined ? {} : { authorization: row.authorization }
|
|
392
|
+
const anonymous = { ...rules, ...cache, ...authorization, kind: row.kind, callers: row.callers, requirement: null, delegation: null }
|
|
355
393
|
return { ...anonymous, digest: requirementDigest(anonymous) }
|
|
356
394
|
}
|
|
357
395
|
const credentialed = {
|
|
@@ -364,7 +402,7 @@ export function contractProxyTable(artifact: ContractArtifact): ContractProxyTab
|
|
|
364
402
|
return { ...credentialed, digest: requirementDigest(credentialed) }
|
|
365
403
|
}),
|
|
366
404
|
reservedPrefixes: [...artifact.reservedPrefixes],
|
|
367
|
-
snapshot:
|
|
405
|
+
snapshot: digest,
|
|
368
406
|
csp: cspOrigins(artifact.csp),
|
|
369
407
|
}
|
|
370
408
|
}
|
package/src/service.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
|
+
answersFile,
|
|
25
26
|
AUTH_CALLBACK_PATH,
|
|
26
27
|
compileEndpointTable,
|
|
27
28
|
DELEGATION_HEADER,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
APP_QUERY_PARAM,
|
|
40
41
|
DEFAULT_HEALTH_PATH,
|
|
41
42
|
DEFAULT_VERIFY_PATH,
|
|
43
|
+
FORWARD_AUTHORIZATION_HEADER,
|
|
42
44
|
FORWARDED_HOST_HEADER,
|
|
43
45
|
FORWARDED_METHOD_HEADER,
|
|
44
46
|
FORWARDED_PROTO_HEADER,
|
|
@@ -183,6 +185,9 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
183
185
|
if (decision.delegation !== null) {
|
|
184
186
|
headers.set(DELEGATION_HEADER, decision.delegation)
|
|
185
187
|
}
|
|
188
|
+
if (decision.forwardsAuthorization) {
|
|
189
|
+
headers.set(FORWARD_AUTHORIZATION_HEADER, '1')
|
|
190
|
+
}
|
|
186
191
|
// The composition writes it onto the application's answer, after the policy deleted the application's own.
|
|
187
192
|
if (decision.crossOrigin !== null) {
|
|
188
193
|
applyCrossOriginHeaders(headers, crossOriginHeaders(decision.crossOrigin))
|
|
@@ -244,10 +249,13 @@ function withFileTreatment(response: Response, treatment: HeaderPolicy): Respons
|
|
|
244
249
|
return response
|
|
245
250
|
}
|
|
246
251
|
|
|
247
|
-
/**
|
|
252
|
+
/**
|
|
253
|
+
* Whether the request selects a download endpoint or a procedure that answers a stored file, whose every answer gets
|
|
254
|
+
* the file treatment instead of the policy.
|
|
255
|
+
*/
|
|
248
256
|
function isDownload(resolved: ResolvedApp, forwarded: ForwardedRequest): boolean {
|
|
249
257
|
const plan = planEndpoint(resolved.table, forwarded.method, forwarded.rawPath)
|
|
250
|
-
return plan.outcome === 'endpoint' && plan.endpoint
|
|
258
|
+
return plan.outcome === 'endpoint' && answersFile(plan.endpoint)
|
|
251
259
|
}
|
|
252
260
|
|
|
253
261
|
/**
|