@fuzdev/fuz_app 0.131.0 → 0.132.1
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/dist/actions/transports_http.d.ts +7 -0
- package/dist/actions/transports_http.d.ts.map +1 -1
- package/dist/actions/transports_http.js +18 -1
- package/dist/auth/CLAUDE.md +1 -1
- package/dist/auth/middleware.d.ts +13 -10
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +14 -10
- package/dist/http/CLAUDE.md +3 -2
- package/dist/http/error_schemas.d.ts +16 -1
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +14 -1
- package/dist/http/origin.d.ts +10 -6
- package/dist/http/origin.d.ts.map +1 -1
- package/dist/http/origin.js +10 -6
- package/dist/http/route_spec.d.ts +19 -12
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +33 -6
- package/dist/http/schema_helpers.d.ts +3 -1
- package/dist/http/schema_helpers.d.ts.map +1 -1
- package/dist/http/schema_helpers.js +3 -2
- package/dist/http/simple_request.d.ts +63 -0
- package/dist/http/simple_request.d.ts.map +1 -0
- package/dist/http/simple_request.js +84 -0
- package/dist/server/app_server.d.ts +2 -2
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +5 -1
- package/dist/testing/CLAUDE.md +17 -0
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/admin_integration.js +4 -2
- package/dist/testing/adversarial_404.d.ts.map +1 -1
- package/dist/testing/adversarial_404.js +5 -3
- package/dist/testing/adversarial_input.d.ts.map +1 -1
- package/dist/testing/adversarial_input.js +9 -3
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/attack_surface.js +15 -16
- package/dist/testing/audit_completeness.js +1 -1
- package/dist/testing/cross_backend/simple_request.d.ts +11 -0
- package/dist/testing/cross_backend/simple_request.d.ts.map +1 -0
- package/dist/testing/cross_backend/simple_request.js +142 -0
- package/dist/testing/cross_backend/standard.d.ts +6 -0
- package/dist/testing/cross_backend/standard.d.ts.map +1 -1
- package/dist/testing/cross_backend/standard.js +2 -1
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/data_exposure.js +11 -8
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +40 -11
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +3 -9
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.js +19 -6
- package/dist/testing/schema_generators.d.ts +12 -0
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +10 -0
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +3 -0
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/auth_state.svelte.js +5 -1
- package/package.json +3 -3
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refusing CORS simple requests — the cross-origin POSTs a browser sends
|
|
3
|
+
* without asking first.
|
|
4
|
+
*
|
|
5
|
+
* A cross-origin `fetch` or form `POST` skips the CORS preflight when its
|
|
6
|
+
* `Content-Type` is absent or one of the three CORS-safelisted types
|
|
7
|
+
* (`text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`).
|
|
8
|
+
* The browser sends such a request as-is, with whatever cookies apply — only
|
|
9
|
+
* the response is withheld from the page. Any other content type makes the
|
|
10
|
+
* browser send an `OPTIONS` preflight first, and since fuz_app answers no
|
|
11
|
+
* preflight with CORS headers, the real request is never sent.
|
|
12
|
+
*
|
|
13
|
+
* The session cookie's `SameSite=Strict` doesn't close this on its own:
|
|
14
|
+
* same-site ignores the port, so a page on `http://localhost:8000` is
|
|
15
|
+
* same-site with a server on `http://localhost:4460` and its requests carry
|
|
16
|
+
* the cookie. The origin allowlist (`http/origin.ts`) refuses such a page
|
|
17
|
+
* unless a wildcard (`http://localhost:*`) admits it; this gate refuses it
|
|
18
|
+
* whatever the allowlist says.
|
|
19
|
+
*
|
|
20
|
+
* So every `POST` route carries a content-type check, run first by
|
|
21
|
+
* `apply_route_specs` (`is_post_content_type_refused`, 415
|
|
22
|
+
* `unsupported_content_type`): a JSON route takes only `application/json`,
|
|
23
|
+
* bodyless or not, and a `raw_body` route takes any type that isn't
|
|
24
|
+
* safelisted. Only `POST` needs it: `PUT`, `PATCH`, and `DELETE` are never
|
|
25
|
+
* simple methods, so the browser always preflights them.
|
|
26
|
+
*
|
|
27
|
+
* The check rests on the server answering no preflight. A consumer that adds
|
|
28
|
+
* CORS middleware granting cross-origin requests reopens what this closes.
|
|
29
|
+
*
|
|
30
|
+
* Twin of the Rust spine's `fuz_http::simple_request`: the same predicates,
|
|
31
|
+
* `is_json_content_type` checked in each spine JSON handler and
|
|
32
|
+
* `is_cors_simple_content_type` by the `simple_request_layer` consumers mount
|
|
33
|
+
* over routes with other bodies.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* True when a browser could have sent a `POST` with this `Content-Type`
|
|
39
|
+
* cross-origin without a preflight: none, or a CORS-safelisted one.
|
|
40
|
+
*
|
|
41
|
+
* Matches more than browsers do (a value they'd refuse to call safelisted —
|
|
42
|
+
* one with odd bytes, say — counts as safelisted here), so it only ever
|
|
43
|
+
* refuses too much, never too little.
|
|
44
|
+
*
|
|
45
|
+
* @param content_type - the request's `Content-Type` header, if any
|
|
46
|
+
*/
|
|
47
|
+
export declare const is_cors_simple_content_type: (content_type: string | undefined) => boolean;
|
|
48
|
+
/**
|
|
49
|
+
* True when `Content-Type` is `application/json` (any parameters, such as
|
|
50
|
+
* `charset`, allowed).
|
|
51
|
+
*
|
|
52
|
+
* @param content_type - the request's `Content-Type` header, if any
|
|
53
|
+
*/
|
|
54
|
+
export declare const is_json_content_type: (content_type: string | undefined) => boolean;
|
|
55
|
+
/**
|
|
56
|
+
* True when a `POST` route refuses this `Content-Type`: a JSON route anything
|
|
57
|
+
* but `application/json`, a `raw_body` route a CORS-safelisted type or none.
|
|
58
|
+
*
|
|
59
|
+
* @param content_type - the request's `Content-Type` header, if any
|
|
60
|
+
* @param raw_body - the route's `RouteSpec.raw_body`
|
|
61
|
+
*/
|
|
62
|
+
export declare const is_post_content_type_refused: (content_type: string | undefined, raw_body: boolean) => boolean;
|
|
63
|
+
//# sourceMappingURL=simple_request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"simple_request.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/http/simple_request.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAqBH;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GAAI,cAAc,MAAM,GAAG,SAAS,KAAG,OAI9E,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,GAAI,cAAc,MAAM,GAAG,SAAS,KAAG,OACmB,CAAC;AAE5F;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GACxC,cAAc,MAAM,GAAG,SAAS,EAChC,UAAU,OAAO,KACf,OACwF,CAAC"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refusing CORS simple requests — the cross-origin POSTs a browser sends
|
|
3
|
+
* without asking first.
|
|
4
|
+
*
|
|
5
|
+
* A cross-origin `fetch` or form `POST` skips the CORS preflight when its
|
|
6
|
+
* `Content-Type` is absent or one of the three CORS-safelisted types
|
|
7
|
+
* (`text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`).
|
|
8
|
+
* The browser sends such a request as-is, with whatever cookies apply — only
|
|
9
|
+
* the response is withheld from the page. Any other content type makes the
|
|
10
|
+
* browser send an `OPTIONS` preflight first, and since fuz_app answers no
|
|
11
|
+
* preflight with CORS headers, the real request is never sent.
|
|
12
|
+
*
|
|
13
|
+
* The session cookie's `SameSite=Strict` doesn't close this on its own:
|
|
14
|
+
* same-site ignores the port, so a page on `http://localhost:8000` is
|
|
15
|
+
* same-site with a server on `http://localhost:4460` and its requests carry
|
|
16
|
+
* the cookie. The origin allowlist (`http/origin.ts`) refuses such a page
|
|
17
|
+
* unless a wildcard (`http://localhost:*`) admits it; this gate refuses it
|
|
18
|
+
* whatever the allowlist says.
|
|
19
|
+
*
|
|
20
|
+
* So every `POST` route carries a content-type check, run first by
|
|
21
|
+
* `apply_route_specs` (`is_post_content_type_refused`, 415
|
|
22
|
+
* `unsupported_content_type`): a JSON route takes only `application/json`,
|
|
23
|
+
* bodyless or not, and a `raw_body` route takes any type that isn't
|
|
24
|
+
* safelisted. Only `POST` needs it: `PUT`, `PATCH`, and `DELETE` are never
|
|
25
|
+
* simple methods, so the browser always preflights them.
|
|
26
|
+
*
|
|
27
|
+
* The check rests on the server answering no preflight. A consumer that adds
|
|
28
|
+
* CORS middleware granting cross-origin requests reopens what this closes.
|
|
29
|
+
*
|
|
30
|
+
* Twin of the Rust spine's `fuz_http::simple_request`: the same predicates,
|
|
31
|
+
* `is_json_content_type` checked in each spine JSON handler and
|
|
32
|
+
* `is_cors_simple_content_type` by the `simple_request_layer` consumers mount
|
|
33
|
+
* over routes with other bodies.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
/** The content types a browser sends cross-origin without a preflight. */
|
|
38
|
+
const CORS_SAFELISTED_CONTENT_TYPES = new Set([
|
|
39
|
+
'text/plain',
|
|
40
|
+
'application/x-www-form-urlencoded',
|
|
41
|
+
'multipart/form-data'
|
|
42
|
+
]);
|
|
43
|
+
/** Any character outside visible ASCII and tab — what Rust's `HeaderValue::to_str` refuses. */
|
|
44
|
+
const NON_VISIBLE_ASCII = /[^\t\x20-\x7e]/;
|
|
45
|
+
/**
|
|
46
|
+
* The MIME essence (`type/subtype`, lowercased, parameters dropped) of a
|
|
47
|
+
* `Content-Type` value, or `null` when it holds anything but visible ASCII.
|
|
48
|
+
*/
|
|
49
|
+
const to_content_type_essence = (content_type) => {
|
|
50
|
+
if (NON_VISIBLE_ASCII.test(content_type))
|
|
51
|
+
return null;
|
|
52
|
+
return (content_type.split(';')[0] ?? '').trim().toLowerCase();
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* True when a browser could have sent a `POST` with this `Content-Type`
|
|
56
|
+
* cross-origin without a preflight: none, or a CORS-safelisted one.
|
|
57
|
+
*
|
|
58
|
+
* Matches more than browsers do (a value they'd refuse to call safelisted —
|
|
59
|
+
* one with odd bytes, say — counts as safelisted here), so it only ever
|
|
60
|
+
* refuses too much, never too little.
|
|
61
|
+
*
|
|
62
|
+
* @param content_type - the request's `Content-Type` header, if any
|
|
63
|
+
*/
|
|
64
|
+
export const is_cors_simple_content_type = (content_type) => {
|
|
65
|
+
if (content_type === undefined)
|
|
66
|
+
return true;
|
|
67
|
+
const essence = to_content_type_essence(content_type);
|
|
68
|
+
return essence === null || CORS_SAFELISTED_CONTENT_TYPES.has(essence);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* True when `Content-Type` is `application/json` (any parameters, such as
|
|
72
|
+
* `charset`, allowed).
|
|
73
|
+
*
|
|
74
|
+
* @param content_type - the request's `Content-Type` header, if any
|
|
75
|
+
*/
|
|
76
|
+
export const is_json_content_type = (content_type) => content_type !== undefined && to_content_type_essence(content_type) === 'application/json';
|
|
77
|
+
/**
|
|
78
|
+
* True when a `POST` route refuses this `Content-Type`: a JSON route anything
|
|
79
|
+
* but `application/json`, a `raw_body` route a CORS-safelisted type or none.
|
|
80
|
+
*
|
|
81
|
+
* @param content_type - the request's `Content-Type` header, if any
|
|
82
|
+
* @param raw_body - the route's `RouteSpec.raw_body`
|
|
83
|
+
*/
|
|
84
|
+
export const is_post_content_type_refused = (content_type, raw_body) => raw_body ? is_cors_simple_content_type(content_type) : !is_json_content_type(content_type);
|
|
@@ -63,13 +63,13 @@ export interface BootstrapDisabledOptions {
|
|
|
63
63
|
}
|
|
64
64
|
export interface BootstrapSurfaceOnlyOptions {
|
|
65
65
|
mode: 'surface_only';
|
|
66
|
-
/** Route prefix for surface generation. Default `'/api/account'
|
|
66
|
+
/** Route prefix for surface generation. Default `'/api/account'`; must sit under `/api/`. */
|
|
67
67
|
route_prefix?: string;
|
|
68
68
|
}
|
|
69
69
|
export interface BootstrapLiveOptions {
|
|
70
70
|
mode: 'live';
|
|
71
71
|
token_path: string;
|
|
72
|
-
/** Route prefix for bootstrap routes. Default `'/api/account'
|
|
72
|
+
/** Route prefix for bootstrap routes. Default `'/api/account'`; must sit under `/api/`. */
|
|
73
73
|
route_prefix?: string;
|
|
74
74
|
/**
|
|
75
75
|
* Called after successful bootstrap (account + session created).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,IAAI,EAAE,KAAK,OAAO,EAAE,MAAM,MAAM,CAAC;AAG1C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAChD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,MAAM,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AAC5E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EAIN,KAAK,WAAW,EAChB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,WAAW,EAMX,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAGhE,OAAO,oBAAoB,CAAC;AAE5B,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEhF,OAAO,EAEN,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,eAAe,EACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAIN,KAAK,SAAS,EACd,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAGN,KAAK,eAAe,EACpB,MAAM,6BAA6B,CAAC;AAarC,OAAO,EAGN,KAAK,cAAc,EACnB,MAAM,gCAAgC,CAAC;AAExC,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,qCAAqC,CAAC;AACrF,OAAO,EAAsB,KAAK,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;AAEzF;;GAEG;AACH,MAAM,WAAW,kBAAkB;IAClC,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;CACb;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,sBAAsB,GACjC,wBAAwB,GAAG,2BAA2B,GAAG,oBAAoB,CAAC;AAE/E,MAAM,WAAW,wBAAwB;IACxC,IAAI,EAAE,UAAU,CAAC;CACjB;AAED,MAAM,WAAW,2BAA2B;IAC3C,IAAI,EAAE,cAAc,CAAC;IACrB,
|
|
1
|
+
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server.ts"],"names":[],"mappings":"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"}
|
|
@@ -333,7 +333,11 @@ const assemble_app_server = async (options, releases) => {
|
|
|
333
333
|
bootstrap_ip_rate_limiter
|
|
334
334
|
});
|
|
335
335
|
const prefix = options.bootstrap.route_prefix ?? '/api/account';
|
|
336
|
-
|
|
336
|
+
const prefixed = prefix_route_specs(prefix, bootstrap_routes);
|
|
337
|
+
for (const route of prefixed) {
|
|
338
|
+
assert_endpoint_in_auth_scope('bootstrap.route_prefix', route.path);
|
|
339
|
+
}
|
|
340
|
+
factory_routes.push(...prefixed);
|
|
337
341
|
}
|
|
338
342
|
// RPC endpoint auto-mount — resolve specs then append their routes so
|
|
339
343
|
// surface generation and live dispatch share one source of truth.
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -383,6 +383,7 @@ Walks Zod schemas to generate valid values for adversarial/round-trip tests.
|
|
|
383
383
|
- `detect_format(field_schema)` — reads `format` / `pattern` from the JSON Schema representation.
|
|
384
384
|
- `generate_valid_value(field, field_schema)` — base-type switch producing a valid sample (UUIDs → nil UUID, strings → `'xxxxxxxxxx'`, numbers → `1`, objects → recurse, enums → first entry, etc.). For branded-string refinements, walks a fallback chain synthesized from the `pattern` string the JSON Schema representation exposes: fixed-length hex (`^[0-9a-f]{N}$` — blake3 / sha256 / md5 digests; `0`.repeat(N)), prefix-lengthed slug (`^<prefix>_[A-Za-z0-9_-]{N}$` — `ApiTokenId`-style ids; `<prefix>_` + `x`.repeat(N)), absolute path prefix, URL prefix. First candidate that `safeParse` accepts is used.
|
|
385
385
|
- `resolve_valid_path(path, params_schema?)` — swaps `:param` for a value the params schema accepts. Prefers `test_<name>` wherever the field schema admits it (the param name keeps failure messages readable); otherwise a UUID param takes the nil UUID and anything else is synthesized from the schema, percent-encoded. A synthesized value containing `/` is refused (it would route elsewhere) and falls back to `test_<name>`.
|
|
386
|
+
- `to_route_content_type(route)` — the `Content-Type` a well-formed request to the route sends: `application/json`, or `application/octet-stream` for a `raw_body` route. Every `POST` route refuses anything else with 415 before its other gates, so every probe in the harnesses (auth, params and query fuzz, 404, data exposure, round trip) sends it, body or not.
|
|
386
387
|
- `generate_valid_body(input_schema) => Record<string, unknown> | undefined` — builds a body satisfying the input schema. Throws with Zod `issues` if the generated body fails validation — surfaces broken generation logic with a descriptive error rather than a confusing 400 downstream.
|
|
387
388
|
|
|
388
389
|
### `integration_helpers.ts` — route lookup + body checks
|
|
@@ -1564,6 +1565,22 @@ in-process `auth/origin_parity.db.test.ts` + the cross-process
|
|
|
1564
1565
|
Rust spine returned a plain-text body — now converged to the canonical TS
|
|
1565
1566
|
`{error: "forbidden_origin"}` via `fuz_http::forbidden_origin_response()`.
|
|
1566
1567
|
|
|
1568
|
+
### CORS simple-request parity — `cross_backend/simple_request.ts`
|
|
1569
|
+
|
|
1570
|
+
`describe_simple_request_cross_tests({setup_test, rpc_path?, login_path?, logout_path?})`
|
|
1571
|
+
— a JSON route refuses any `POST` that isn't `application/json` with a flat
|
|
1572
|
+
415 `{error: 'unsupported_content_type'}` before reading a credential or the
|
|
1573
|
+
body: the CORS-safelisted types in any spelling (`text/plain`, `Text/Plain`,
|
|
1574
|
+
form-encoded, multipart) on RPC with a session, untyped RPC (a byte body, so
|
|
1575
|
+
`fetch` adds no type), `application/octet-stream` / `+json` / empty-typed RPC
|
|
1576
|
+
and login, form-encoded login, a forged `text/plain` logout with a session
|
|
1577
|
+
(which must leave the session valid), and an untyped logout with no session
|
|
1578
|
+
(415, not 401); a bodyless JSON logout reaches the auth check (401), and
|
|
1579
|
+
`application/json` with a charset is dispatched. The TS spine checks per route
|
|
1580
|
+
(`apply_route_specs`), the Rust spine in each JSON handler. Runs both legs:
|
|
1581
|
+
the in-process `auth/simple_request_parity.db.test.ts` and the cross-process
|
|
1582
|
+
`simple_request.cross.test.ts`.
|
|
1583
|
+
|
|
1567
1584
|
### Query-string shape parity — `cross_backend/query_shape.ts`
|
|
1568
1585
|
|
|
1569
1586
|
`describe_query_shape_cross_tests({setup_test, capabilities, rpc_path?})` —
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"admin_integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/admin_integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAgC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAW3E,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"admin_integration.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/admin_integration.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAgC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAW3E,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAavE;;GAEG;AACH,MAAM,WAAW,mCAAmC;IACnD;;;;OAIG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;OAIG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,uFAAuF;IACvF,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,4GAA4G;IAC5G,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;IACvC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAiBD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yCAAyC,GACrD,SAAS,mCAAmC,KAC1C,IAm5BF,CAAC"}
|
|
@@ -36,6 +36,7 @@ import { rpc_call_for_spec, require_rpc_endpoint_path, resolve_rpc_endpoints_for
|
|
|
36
36
|
import { role_grant_offer_create_action_spec, role_grant_revoke_action_spec } from '../auth/role_grant_offer_action_specs.js';
|
|
37
37
|
import { admin_account_list_action_spec, ADMIN_ACCOUNT_LIST_LIMIT_MAX, admin_session_list_action_spec, admin_session_revoke_all_action_spec, admin_token_revoke_all_action_spec, audit_log_list_action_spec, audit_log_role_grant_history_action_spec } from '../auth/admin_action_specs.js';
|
|
38
38
|
import { account_token_create_action_spec, account_verify_action_spec } from '../auth/account_action_specs.js';
|
|
39
|
+
import { to_route_content_type } from './schema_generators.js';
|
|
39
40
|
/**
|
|
40
41
|
* How long a failure-audit read waits for the pool-routed row to land.
|
|
41
42
|
*/
|
|
@@ -595,7 +596,8 @@ export const describe_standard_admin_integration_tests = (options) => {
|
|
|
595
596
|
headers: {
|
|
596
597
|
host: 'localhost',
|
|
597
598
|
origin: 'http://localhost:5173',
|
|
598
|
-
cookie: `${cookie_name}=${user_two_cookie}
|
|
599
|
+
cookie: `${cookie_name}=${user_two_cookie}`,
|
|
600
|
+
'content-type': 'application/json'
|
|
599
601
|
}
|
|
600
602
|
});
|
|
601
603
|
}
|
|
@@ -804,7 +806,7 @@ export const describe_standard_admin_integration_tests = (options) => {
|
|
|
804
806
|
for (const route of admin_routes) {
|
|
805
807
|
const res = await fixture.transport(route.path, {
|
|
806
808
|
method: route.method,
|
|
807
|
-
headers: { host: 'localhost' }
|
|
809
|
+
headers: { host: 'localhost', 'content-type': to_route_content_type(route) }
|
|
808
810
|
});
|
|
809
811
|
if (res.status === 401 || res.status === 403) {
|
|
810
812
|
error_collector.record(route_specs, route.method, route.path, res.status);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adversarial_404.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_404.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"adversarial_404.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_404.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AA6BlE;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,GAAI,SAAS,sBAAsB,KAAG,IA+E1E,CAAC"}
|
|
@@ -14,7 +14,7 @@ import { test, assert, describe } from 'vitest';
|
|
|
14
14
|
import { z } from 'zod';
|
|
15
15
|
import { is_null_schema } from '../http/schema_helpers.js';
|
|
16
16
|
import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
|
|
17
|
-
import { resolve_valid_path, generate_valid_body } from './schema_generators.js';
|
|
17
|
+
import { resolve_valid_path, generate_valid_body, to_route_content_type } from './schema_generators.js';
|
|
18
18
|
/**
|
|
19
19
|
* Extract the error code from a 404 Zod schema for use in the stub handler.
|
|
20
20
|
*
|
|
@@ -98,12 +98,14 @@ export const describe_adversarial_404 = (options) => {
|
|
|
98
98
|
const route = surface.routes.find((r) => `${r.method} ${r.path}` === key);
|
|
99
99
|
const app = select_auth_app(apps, route.auth);
|
|
100
100
|
const url = resolve_valid_path(route.path, spec.params);
|
|
101
|
-
const request_init = {
|
|
101
|
+
const request_init = {
|
|
102
|
+
method: route.method,
|
|
103
|
+
headers: { 'content-type': to_route_content_type(spec) }
|
|
104
|
+
};
|
|
102
105
|
// Send valid body for routes with input
|
|
103
106
|
if (!is_null_schema(spec.input)) {
|
|
104
107
|
const body = generate_valid_body(spec.input);
|
|
105
108
|
if (body !== undefined) {
|
|
106
|
-
request_init.headers = { 'Content-Type': 'application/json' };
|
|
107
109
|
request_init.body = JSON.stringify(body);
|
|
108
110
|
}
|
|
109
111
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"adversarial_input.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_input.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAUxB,OAAO,EAEN,0BAA0B,EAC1B,uBAAuB,EAGvB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"adversarial_input.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/adversarial_input.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAiB7B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAUxB,OAAO,EAEN,0BAA0B,EAC1B,uBAAuB,EAGvB,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAmDlE,UAAU,aAAa;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,OAAO,CAAC;IACd,cAAc,EAAE,OAAO,0BAA0B,GAAG,OAAO,uBAAuB,CAAC;CACnF;AAED,UAAU,cAAc;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,UAAU,aAAa;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,CAAC,CAAC,OAAO,KAAG,KAAK,CAAC,aAAa,CAsMtF,CAAC;AAIF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,eAAe,CAAC,CAAC,SAAS,KAAG,KAAK,CAAC,cAAc,CA+B3F,CAAC;AAIF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,GAAI,cAAc,CAAC,CAAC,SAAS,KAAG,KAAK,CAAC,aAAa,CA4CxF,CAAC;AAqBF;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,SAAS,sBAAsB,KAAG,IAyN5E,CAAC"}
|
|
@@ -19,7 +19,7 @@ import { is_null_schema } from '../http/schema_helpers.js';
|
|
|
19
19
|
import { filter_routes_with_input, filter_routes_with_params, filter_routes_with_query } from '../http/surface_query.js';
|
|
20
20
|
import { ValidationError, ERROR_INVALID_REQUEST_BODY, ERROR_INVALID_JSON_BODY, ERROR_INVALID_ROUTE_PARAMS, ERROR_INVALID_QUERY_PARAMS } from '../http/error_schemas.js';
|
|
21
21
|
import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
|
|
22
|
-
import { detect_format, generate_valid_value, resolve_valid_path } from './schema_generators.js';
|
|
22
|
+
import { detect_format, generate_valid_value, resolve_valid_path, to_route_content_type } from './schema_generators.js';
|
|
23
23
|
// --- Payload generation ---
|
|
24
24
|
/** One wrong-type value per base type — one representative is sufficient. */
|
|
25
25
|
const wrong_type_for = (base_type) => {
|
|
@@ -466,7 +466,10 @@ export const describe_adversarial_input = (options) => {
|
|
|
466
466
|
for (const tc of test_cases) {
|
|
467
467
|
test(tc.label, async () => {
|
|
468
468
|
const url = build_fuzz_url(route.path, tc.params);
|
|
469
|
-
const res = await app.request(url, {
|
|
469
|
+
const res = await app.request(url, {
|
|
470
|
+
method: route.method,
|
|
471
|
+
headers: { 'content-type': to_route_content_type(route) }
|
|
472
|
+
});
|
|
470
473
|
assert.strictEqual(res.status, 400, `Expected 400 for ${key} [${tc.label}], got ${res.status}`);
|
|
471
474
|
const body = await res.json();
|
|
472
475
|
assert.strictEqual(body.error, ERROR_INVALID_ROUTE_PARAMS, `Expected ${ERROR_INVALID_ROUTE_PARAMS} for ${key} [${tc.label}], got: ${body.error}`);
|
|
@@ -504,7 +507,10 @@ export const describe_adversarial_input = (options) => {
|
|
|
504
507
|
for (const tc of test_cases) {
|
|
505
508
|
test(tc.label, async () => {
|
|
506
509
|
const url = build_query_url(base_url, tc.query);
|
|
507
|
-
const res = await app.request(url, {
|
|
510
|
+
const res = await app.request(url, {
|
|
511
|
+
method: route.method,
|
|
512
|
+
headers: { 'content-type': to_route_content_type(route) }
|
|
513
|
+
});
|
|
508
514
|
assert.strictEqual(res.status, 400, `Expected 400 for ${key} [${tc.label}], got ${res.status}`);
|
|
509
515
|
const body = await res.json();
|
|
510
516
|
assert.strictEqual(body.error, ERROR_INVALID_QUERY_PARAMS, `Expected ${ERROR_INVALID_QUERY_PARAMS} for ${key} [${tc.label}], got: ${body.error}`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,EAQN,KAAK,4BAA4B,EACjC,KAAK,2BAA2B,EAChC,MAAM,yBAAyB,CAAC;AAejC,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAsDpF,oFAAoF;AACpF,MAAM,WAAW,sBAAsB;IACtC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,sBAAsB,KAAG,
|
|
1
|
+
{"version":3,"file":"attack_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/attack_surface.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AAoB7B,OAAO,EAQN,KAAK,4BAA4B,EACjC,KAAK,2BAA2B,EAChC,MAAM,yBAAyB,CAAC;AAejC,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAsDpF,oFAAoF;AACpF,MAAM,WAAW,sBAAsB;IACtC,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,yBAAyB,GAAI,SAAS,sBAAsB,KAAG,IA6J3E,CAAC;AAIF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,uCAAuC,GACnD,UAAU,2BAA2B,GAAG,IAAI,GAAG,SAAS,KACtD,2BAA2B,GAAG,IAWhC,CAAC;AAEF,0DAA0D;AAC1D,MAAM,WAAW,4BAA4B;IAC5C,+EAA+E;IAC/E,KAAK,EAAE,MAAM,cAAc,CAAC;IAC5B,yDAAyD;IACzD,aAAa,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,sBAAsB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACtC,iIAAiI;IACjI,uBAAuB,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACvC,yDAAyD;IACzD,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACrB,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iEAAiE;IACjE,eAAe,CAAC,EAAE,4BAA4B,CAAC;IAC/C;;;;;;;;;;;OAWG;IACH,sBAAsB,CAAC,EAAE,2BAA2B,GAAG,IAAI,CAAC;IAC5D;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,sCAAsC,GAClD,SAAS,4BAA4B,KACnC,IA4EF,CAAC"}
|
|
@@ -19,7 +19,7 @@ import { assert_surface_invariants, assert_rpc_ws_surface_invariants, assert_sur
|
|
|
19
19
|
import { describe_adversarial_input } from './adversarial_input.js';
|
|
20
20
|
import { describe_adversarial_404 } from './adversarial_404.js';
|
|
21
21
|
import { create_auth_test_apps, create_route_skip_filter, select_auth_app } from './auth_apps.js';
|
|
22
|
-
import { resolve_valid_path } from './schema_generators.js';
|
|
22
|
+
import { resolve_valid_path, to_route_content_type } from './schema_generators.js';
|
|
23
23
|
import { assert_surface_matches_snapshot, assert_surface_deterministic, assert_only_expected_public_routes, assert_full_middleware_stack, assert_error_schema_valid } from './assertions.js';
|
|
24
24
|
import { merge_error_schemas } from '../http/schema_helpers.js';
|
|
25
25
|
import { collect_middleware_errors } from '../http/surface.js';
|
|
@@ -91,13 +91,17 @@ export const describe_adversarial_auth = (options) => {
|
|
|
91
91
|
const spec_lookup = build_route_spec_lookup(route_specs);
|
|
92
92
|
const test_path = (route) => resolve_valid_path(route.path, spec_lookup.get(`${route.method} ${route.path}`)?.params);
|
|
93
93
|
const apps = create_auth_test_apps(route_specs, roles);
|
|
94
|
+
// every probe carries the content type a real client sends, so the
|
|
95
|
+
// per-route content-type check (415) never answers ahead of the auth gate
|
|
96
|
+
const probe_init = (route) => ({
|
|
97
|
+
method: route.method,
|
|
98
|
+
headers: { 'content-type': to_route_content_type(route) }
|
|
99
|
+
});
|
|
94
100
|
describe('adversarial HTTP auth enforcement', () => {
|
|
95
101
|
describe('unauthenticated → 401', () => {
|
|
96
102
|
for (const route of protected_routes) {
|
|
97
103
|
test(`${route.method} ${route.path}`, async () => {
|
|
98
|
-
const res = await apps.public.request(test_path(route),
|
|
99
|
-
method: route.method
|
|
100
|
-
});
|
|
104
|
+
const res = await apps.public.request(test_path(route), probe_init(route));
|
|
101
105
|
assert.strictEqual(res.status, 401, `${route.method} ${route.path}`);
|
|
102
106
|
const body = await res.json();
|
|
103
107
|
assert.strictEqual(body.error, ERROR_AUTHENTICATION_REQUIRED);
|
|
@@ -126,9 +130,7 @@ export const describe_adversarial_auth = (options) => {
|
|
|
126
130
|
const app = apps.by_role.get(wrong_role);
|
|
127
131
|
if (!app)
|
|
128
132
|
throw new Error(`No test app for role '${wrong_role}'`);
|
|
129
|
-
const res = await app.request(test_path(route),
|
|
130
|
-
method: route.method
|
|
131
|
-
});
|
|
133
|
+
const res = await app.request(test_path(route), probe_init(route));
|
|
132
134
|
assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
|
|
133
135
|
const body = await res.json();
|
|
134
136
|
assert.strictEqual(body.error, ERROR_INSUFFICIENT_PERMISSIONS);
|
|
@@ -141,9 +143,7 @@ export const describe_adversarial_auth = (options) => {
|
|
|
141
143
|
describe('authenticated without role → 403', () => {
|
|
142
144
|
for (const route of role_only_routes) {
|
|
143
145
|
test(`${route.method} ${route.path}`, async () => {
|
|
144
|
-
const res = await apps.authed.request(test_path(route),
|
|
145
|
-
method: route.method
|
|
146
|
-
});
|
|
146
|
+
const res = await apps.authed.request(test_path(route), probe_init(route));
|
|
147
147
|
assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
|
|
148
148
|
const body = await res.json();
|
|
149
149
|
assert.strictEqual(body.error, ERROR_INSUFFICIENT_PERMISSIONS);
|
|
@@ -175,9 +175,9 @@ export const describe_adversarial_auth = (options) => {
|
|
|
175
175
|
continue;
|
|
176
176
|
}
|
|
177
177
|
test(`${route.method} ${route.path} (${refused})`, async () => {
|
|
178
|
-
const res = await apps.by_credential_type
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
const res = await apps.by_credential_type
|
|
179
|
+
.get(refused)
|
|
180
|
+
.request(test_path(route), probe_init(route));
|
|
181
181
|
assert.strictEqual(res.status, 403, `${route.method} ${route.path}`);
|
|
182
182
|
const body = await res.json();
|
|
183
183
|
assert.strictEqual(body.error, ERROR_CREDENTIAL_TYPE_REQUIRED);
|
|
@@ -190,12 +190,11 @@ export const describe_adversarial_auth = (options) => {
|
|
|
190
190
|
describe('correct auth passes guard', () => {
|
|
191
191
|
for (const route of protected_routes) {
|
|
192
192
|
test(`${route.method} ${route.path}`, async () => {
|
|
193
|
-
const res = await select_auth_app(apps, route.auth).request(test_path(route),
|
|
194
|
-
method: route.method
|
|
195
|
-
});
|
|
193
|
+
const res = await select_auth_app(apps, route.auth).request(test_path(route), probe_init(route));
|
|
196
194
|
// handler may error (500) or return 404 (stub deps) — that's fine, we only verify auth passed
|
|
197
195
|
assert.notStrictEqual(res.status, 401, 'should not be 401 (auth rejected)');
|
|
198
196
|
assert.notStrictEqual(res.status, 403, 'should not be 403 (role rejected)');
|
|
197
|
+
assert.notStrictEqual(res.status, 415, 'should not be 415 (content type refused)');
|
|
199
198
|
// handler-level 404 (resource not found with stub deps) is fine —
|
|
200
199
|
// only reject router-level 404 (route not registered)
|
|
201
200
|
if (res.status === 404) {
|
|
@@ -155,7 +155,7 @@ export const describe_audit_completeness_tests = (options) => {
|
|
|
155
155
|
assert.ok(logout_route, 'Expected POST /logout route');
|
|
156
156
|
const res = await fixture.transport(logout_route.path, {
|
|
157
157
|
method: 'POST',
|
|
158
|
-
headers: fixture.create_session_headers()
|
|
158
|
+
headers: fixture.create_session_headers({ 'content-type': 'application/json' })
|
|
159
159
|
});
|
|
160
160
|
assert.strictEqual(res.status, 200);
|
|
161
161
|
const events = await list_audit_events({ request: fixture.transport }, rpc_path, observer);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { RpcPathCrossSuiteOptions } from './setup.js';
|
|
3
|
+
/** Options for the simple-request parity suite. */
|
|
4
|
+
export interface SimpleRequestCrossTestOptions extends RpcPathCrossSuiteOptions {
|
|
5
|
+
/** REST login route path. Default `/api/account/login` (the spine convention). */
|
|
6
|
+
readonly login_path?: string;
|
|
7
|
+
/** REST logout route path. Default `/api/account/logout` (the spine convention). */
|
|
8
|
+
readonly logout_path?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const describe_simple_request_cross_tests: (options: SimpleRequestCrossTestOptions) => void;
|
|
11
|
+
//# sourceMappingURL=simple_request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"simple_request.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/simple_request.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA6B9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AAGxE,mDAAmD;AACnD,MAAM,WAAW,6BAA8B,SAAQ,wBAAwB;IAC9E,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAaD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IAuHF,CAAC"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for refusing CORS simple requests.
|
|
4
|
+
*
|
|
5
|
+
* A `POST` with no `Content-Type`, or a `text/plain` / form / multipart one,
|
|
6
|
+
* is what a browser sends cross-origin without a preflight — carrying the
|
|
7
|
+
* session cookie when the page is same-site, which any other `localhost`
|
|
8
|
+
* port is. Every spine's JSON routes require `application/json` and refuse
|
|
9
|
+
* anything else with a flat `415` `{error: 'unsupported_content_type'}`
|
|
10
|
+
* before reading a credential or the body: the TS spine per route
|
|
11
|
+
* (`apply_route_specs`, every `POST` that isn't `raw_body`), the Rust spine
|
|
12
|
+
* in each JSON handler. The cases carry an allowed `Origin` and, where it
|
|
13
|
+
* matters, the keeper's session — the exact shape of a same-site page's
|
|
14
|
+
* forged request, which the origin allowlist alone admits when it's a
|
|
15
|
+
* wildcard — and a refused request must leave that session untouched.
|
|
16
|
+
*
|
|
17
|
+
* Runs both legs via the shared `setup_test` protocol: in-process
|
|
18
|
+
* (`auth/simple_request_parity.db.test.ts`) and cross-process
|
|
19
|
+
* (`cross_backend/simple_request.cross.test.ts`). Every spine has the gate,
|
|
20
|
+
* so the suite is ungated.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { describe, test, assert } from 'vitest';
|
|
25
|
+
import { account_verify_action_spec } from '../../auth/account_action_specs.js';
|
|
26
|
+
import { ERROR_UNSUPPORTED_CONTENT_TYPE } from '../../http/error_schemas.js';
|
|
27
|
+
import { SPINE_RPC_PATH } from './spine_surface_constants.js';
|
|
28
|
+
/** Build the JSON-RPC envelope body for a nullary `account_verify` call. */
|
|
29
|
+
const verify_envelope = (id) => JSON.stringify({ jsonrpc: '2.0', method: account_verify_action_spec.method, id });
|
|
30
|
+
/** Assert a flat `415` content-type refusal. */
|
|
31
|
+
const assert_refused = async (res, label) => {
|
|
32
|
+
assert.strictEqual(res.status, 415, `${label} must be refused with 415`);
|
|
33
|
+
const body = (await res.json().catch(() => undefined));
|
|
34
|
+
assert.strictEqual(body?.error, ERROR_UNSUPPORTED_CONTENT_TYPE, label);
|
|
35
|
+
};
|
|
36
|
+
export const describe_simple_request_cross_tests = (options) => {
|
|
37
|
+
const { setup_test } = options;
|
|
38
|
+
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
39
|
+
const login_path = options.login_path ?? '/api/account/login';
|
|
40
|
+
const logout_path = options.logout_path ?? '/api/account/logout';
|
|
41
|
+
/** POST a JSON `account_verify` with the fixture's session — 200 while it's valid. */
|
|
42
|
+
const verify_session = async (fixture, id) => {
|
|
43
|
+
const res = await fixture.transport(rpc_path, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { ...fixture.create_session_headers(), 'content-type': 'application/json' },
|
|
46
|
+
body: verify_envelope(id)
|
|
47
|
+
});
|
|
48
|
+
return res.status;
|
|
49
|
+
};
|
|
50
|
+
describe('CORS simple-request refusal parity', () => {
|
|
51
|
+
test('a CORS-safelisted RPC POST with a session → 415, in any spelling', async () => {
|
|
52
|
+
const fixture = await setup_test();
|
|
53
|
+
for (const content_type of [
|
|
54
|
+
'text/plain;charset=UTF-8',
|
|
55
|
+
'Text/Plain',
|
|
56
|
+
'application/x-www-form-urlencoded',
|
|
57
|
+
'multipart/form-data; boundary=x'
|
|
58
|
+
]) {
|
|
59
|
+
const res = await fixture.transport(rpc_path, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { ...fixture.create_session_headers(), 'content-type': content_type },
|
|
62
|
+
body: verify_envelope('simple-safelisted')
|
|
63
|
+
});
|
|
64
|
+
await assert_refused(res, `a ${content_type} RPC POST`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
test('RPC POST with no Content-Type → 415', async () => {
|
|
68
|
+
const fixture = await setup_test();
|
|
69
|
+
// a byte body, so `fetch` adds no `Content-Type` of its own (a string
|
|
70
|
+
// body would get `text/plain;charset=UTF-8`)
|
|
71
|
+
const res = await fixture.transport(rpc_path, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: fixture.create_session_headers(),
|
|
74
|
+
body: new TextEncoder().encode(verify_envelope('simple-untyped'))
|
|
75
|
+
});
|
|
76
|
+
await assert_refused(res, 'an untyped RPC POST');
|
|
77
|
+
});
|
|
78
|
+
test('a JSON route refuses any type but application/json', async () => {
|
|
79
|
+
const fixture = await setup_test();
|
|
80
|
+
for (const content_type of ['application/octet-stream', 'application/vnd.api+json', '']) {
|
|
81
|
+
const res = await fixture.transport(rpc_path, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { ...fixture.create_session_headers(), 'content-type': content_type },
|
|
84
|
+
body: verify_envelope('simple-not-json')
|
|
85
|
+
});
|
|
86
|
+
await assert_refused(res, `an RPC POST typed '${content_type}'`);
|
|
87
|
+
const login = await fixture.fresh_transport()(login_path, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'content-type': content_type },
|
|
90
|
+
body: JSON.stringify({ username: 'keeper', password: 'whatever' })
|
|
91
|
+
});
|
|
92
|
+
await assert_refused(login, `a login typed '${content_type}'`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
test('form-encoded login → 415', async () => {
|
|
96
|
+
const fixture = await setup_test();
|
|
97
|
+
const res = await fixture.fresh_transport()(login_path, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
100
|
+
body: 'username=keeper&password=whatever'
|
|
101
|
+
});
|
|
102
|
+
await assert_refused(res, 'a form-encoded login');
|
|
103
|
+
});
|
|
104
|
+
test('a forged text/plain logout with a session → 415, and the session survives', async () => {
|
|
105
|
+
const fixture = await setup_test();
|
|
106
|
+
const res = await fixture.transport(logout_path, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { ...fixture.create_session_headers(), 'content-type': 'text/plain' }
|
|
109
|
+
});
|
|
110
|
+
await assert_refused(res, 'a text/plain logout');
|
|
111
|
+
assert.strictEqual(await verify_session(fixture, 'after-forged-logout'), 200, 'the refused logout must not end the session');
|
|
112
|
+
});
|
|
113
|
+
test('an untyped logout with no session → 415, before the auth check', async () => {
|
|
114
|
+
const fixture = await setup_test();
|
|
115
|
+
const res = await fixture.fresh_transport()(logout_path, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/octet-stream' }
|
|
118
|
+
});
|
|
119
|
+
await assert_refused(res, 'an octet-stream logout');
|
|
120
|
+
});
|
|
121
|
+
test('JSON logout with no body and no session → 401, not 415', async () => {
|
|
122
|
+
const fixture = await setup_test();
|
|
123
|
+
const res = await fixture.fresh_transport()(logout_path, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'content-type': 'application/json' }
|
|
126
|
+
});
|
|
127
|
+
assert.strictEqual(res.status, 401, 'a bodyless JSON logout must reach the auth check');
|
|
128
|
+
});
|
|
129
|
+
test('application/json with a charset → dispatched', async () => {
|
|
130
|
+
const fixture = await setup_test();
|
|
131
|
+
const res = await fixture.transport(rpc_path, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
...fixture.create_session_headers(),
|
|
135
|
+
'content-type': 'application/json; charset=utf-8'
|
|
136
|
+
},
|
|
137
|
+
body: verify_envelope('json-charset')
|
|
138
|
+
});
|
|
139
|
+
assert.strictEqual(res.status, 200, 'a JSON RPC POST must be dispatched');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
};
|
|
@@ -98,6 +98,12 @@ export interface StandardCrossProcessTestOptions {
|
|
|
98
98
|
* / `info/refs`) which stream git protocol bytes.
|
|
99
99
|
*/
|
|
100
100
|
round_trip_skip_routes?: Array<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Forwarded to `describe_data_exposure_tests` as `skip_routes`
|
|
103
|
+
* (`'METHOD /path'` keys), as the in-process bundle's consumers pass it
|
|
104
|
+
* directly.
|
|
105
|
+
*/
|
|
106
|
+
data_exposure_skip_routes?: Array<string>;
|
|
101
107
|
/**
|
|
102
108
|
* Forwarded to `describe_rpc_round_trip_tests` as `success_fixtures`
|
|
103
109
|
* (method name → async params factory). Drives a populated **success**
|