@fuzdev/fuz_app 0.96.0 → 0.98.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/dist/actions/CLAUDE.md +57 -23
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_codegen.d.ts +1 -1
- package/dist/actions/action_codegen.d.ts.map +1 -1
- package/dist/actions/action_codegen.js +1 -1
- package/dist/actions/{action_peer.d.ts → action_dispatcher.d.ts} +12 -12
- package/dist/actions/action_dispatcher.d.ts.map +1 -0
- package/dist/actions/{action_peer.js → action_dispatcher.js} +2 -2
- package/dist/actions/action_event.d.ts.map +1 -1
- package/dist/actions/action_rpc.d.ts +9 -0
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/broadcast_api.d.ts +2 -2
- package/dist/actions/broadcast_api.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/frontend_rpc_client.d.ts +3 -3
- package/dist/actions/frontend_rpc_client.d.ts.map +1 -1
- package/dist/actions/frontend_rpc_client.js +3 -3
- package/dist/actions/peer_ping.d.ts +147 -0
- package/dist/actions/peer_ping.d.ts.map +1 -0
- package/dist/actions/peer_ping.js +189 -0
- package/dist/actions/peer_request.d.ts +158 -0
- package/dist/actions/peer_request.d.ts.map +1 -0
- package/dist/actions/peer_request.js +167 -0
- package/dist/actions/perform_action.d.ts +8 -0
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +2 -1
- package/dist/actions/protocol.d.ts +3 -3
- package/dist/actions/protocol.d.ts.map +1 -1
- package/dist/actions/protocol.js +9 -4
- package/dist/actions/register_action_ws.d.ts.map +1 -1
- package/dist/actions/register_action_ws.js +28 -1
- package/dist/actions/rpc_client.d.ts +4 -4
- package/dist/actions/rpc_client.d.ts.map +1 -1
- package/dist/actions/socket.svelte.d.ts.map +1 -1
- package/dist/actions/transports.d.ts +1 -1
- package/dist/actions/transports_http.d.ts.map +1 -1
- package/dist/actions/transports_ws.d.ts +12 -5
- package/dist/actions/transports_ws.d.ts.map +1 -1
- package/dist/actions/transports_ws.js +36 -6
- package/dist/actions/transports_ws_backend.d.ts +34 -1
- package/dist/actions/transports_ws_backend.d.ts.map +1 -1
- package/dist/actions/transports_ws_backend.js +62 -5
- package/dist/auth/CLAUDE.md +13 -13
- package/dist/auth/actor_lookup_action_specs.d.ts.map +1 -1
- package/dist/auth/actor_search_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/audit_log_queries.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +6 -7
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.d.ts +21 -4
- package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
- package/dist/auth/daemon_token_middleware.js +61 -10
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +6 -2
- package/dist/auth/role_grant_queries.d.ts +31 -12
- package/dist/auth/role_grant_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.js +36 -12
- package/dist/auth/role_schema.d.ts.map +1 -1
- package/dist/auth/self_service_role_actions.d.ts.map +1 -1
- package/dist/db/create_db.d.ts.map +1 -1
- package/dist/db/fact_store.d.ts.map +1 -1
- package/dist/db/status.d.ts.map +1 -1
- package/dist/http/CLAUDE.md +3 -2
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/error_schemas.d.ts +0 -6
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +0 -6
- package/dist/http/jsonrpc_helpers.d.ts +1 -1
- package/dist/http/jsonrpc_helpers.js +1 -1
- package/dist/http/origin.d.ts +18 -1
- package/dist/http/origin.d.ts.map +1 -1
- package/dist/http/origin.js +17 -0
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +11 -0
- package/dist/server/app_backend.d.ts.map +1 -1
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +7 -4
- package/dist/testing/CLAUDE.md +41 -18
- package/dist/testing/admin_integration.d.ts.map +1 -1
- package/dist/testing/adversarial_input.d.ts.map +1 -1
- package/dist/testing/adversarial_input.js +3 -1
- package/dist/testing/attack_surface.d.ts.map +1 -1
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +4 -1
- package/dist/testing/cross_backend/action_manifest.d.ts +7 -7
- package/dist/testing/cross_backend/action_manifest.js +7 -7
- package/dist/testing/cross_backend/action_manifest_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/bootstrap_backend.d.ts.map +1 -1
- package/dist/testing/cross_backend/bootstrap_backend.js +8 -0
- package/dist/testing/cross_backend/capabilities.d.ts +15 -12
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +4 -4
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_case.d.ts +42 -0
- package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_case.js +47 -0
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -1
- package/dist/testing/cross_backend/conformance_table.js +90 -3
- package/dist/testing/cross_backend/cookie_attributes.d.ts +20 -0
- package/dist/testing/cross_backend/cookie_attributes.d.ts.map +1 -0
- package/dist/testing/cross_backend/cookie_attributes.js +158 -0
- package/dist/testing/cross_backend/credential_header_robustness.d.ts +12 -0
- package/dist/testing/cross_backend/credential_header_robustness.d.ts.map +1 -0
- package/dist/testing/cross_backend/credential_header_robustness.js +179 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +5 -3
- package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
- package/dist/testing/cross_backend/fact_serving.js +5 -1
- package/dist/testing/cross_backend/full_spine_mount.d.ts.map +1 -1
- package/dist/testing/cross_backend/full_spine_mount.js +7 -0
- package/dist/testing/cross_backend/identity_parity.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/method_coverage.d.ts +3 -7
- package/dist/testing/cross_backend/method_coverage.d.ts.map +1 -1
- package/dist/testing/cross_backend/method_coverage.js +1 -2
- package/dist/testing/cross_backend/peer_ping_ws.js +1 -1
- package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts +26 -0
- package/dist/testing/cross_backend/role_grant_offer_enumeration.d.ts.map +1 -0
- package/dist/testing/cross_backend/role_grant_offer_enumeration.js +108 -0
- package/dist/testing/cross_backend/rust_spine_stub_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.d.ts +17 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +31 -8
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +0 -13
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +12 -4
- package/dist/testing/cross_backend/ts_spine_backend_config.d.ts.map +1 -1
- package/dist/testing/cross_backend/ts_spine_backend_config.js +9 -3
- package/dist/testing/data_exposure.d.ts.map +1 -1
- package/dist/testing/db.d.ts.map +1 -1
- package/dist/testing/db_entities.d.ts +15 -0
- package/dist/testing/db_entities.d.ts.map +1 -1
- package/dist/testing/db_entities.js +16 -1
- package/dist/testing/error_coverage.d.ts.map +1 -1
- package/dist/testing/error_coverage.js +1 -2
- package/dist/testing/integration_helpers.d.ts.map +1 -1
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_helpers.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/schema_generators.d.ts.map +1 -1
- package/dist/testing/schema_generators.js +3 -1
- package/dist/testing/schema_parity.d.ts.map +1 -1
- package/dist/testing/sse_round_trip.d.ts.map +1 -1
- package/dist/testing/surface_invariants.d.ts.map +1 -1
- package/dist/testing/transports/bootstrap.d.ts.map +1 -1
- package/dist/testing/transports/ws_client.d.ts +1 -1
- package/dist/testing/transports/ws_transport.d.ts +1 -1
- package/dist/testing/ws_round_trip.d.ts +1 -1
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +3 -3
- package/dist/ui/CLAUDE.md +6 -3
- package/dist/ui/auth_state.svelte.d.ts.map +1 -1
- package/dist/ui/form_state.svelte.d.ts.map +1 -1
- package/dist/ui/role_grant_offers_state.svelte.d.ts +1 -1
- package/dist/ui/role_grant_offers_state.svelte.js +1 -1
- package/dist/ui/ui_format.d.ts.map +1 -1
- package/package.json +2 -2
- package/dist/actions/action_peer.d.ts.map +0 -1
|
@@ -33,8 +33,39 @@ import { z } from 'zod';
|
|
|
33
33
|
* - `keeper` — the per-test bootstrapped keeper (holds `ROLE_KEEPER` +
|
|
34
34
|
* `ROLE_ADMIN`), session credential.
|
|
35
35
|
* - `daemon` — the keeper authenticated via the daemon-token header.
|
|
36
|
+
* - `invalid_daemon` — a *malformed/invalid* `X-Daemon-Token` carried
|
|
37
|
+
* alongside the keeper's session cookie, over a non-browser (no-Origin)
|
|
38
|
+
* transport. The middleware soft-fail-discards the invalid daemon token
|
|
39
|
+
* (matching the Rust spine's `None`), so auth falls through to the session
|
|
40
|
+
* leg: the request authenticates as the keeper-via-session and a
|
|
41
|
+
* daemon-gated action then refuses the session credential with
|
|
42
|
+
* `credential_type_required` — not a hard `invalid_daemon_token` 401. The
|
|
43
|
+
* no-Origin transport keeps the daemon token on the invalid-token path
|
|
44
|
+
* rather than the browser-context discard; the session base credential is
|
|
45
|
+
* what makes the credential-type gate (not the auth gate) the refusing
|
|
46
|
+
* layer (without it the discard would 401 anonymous).
|
|
47
|
+
* - `daemon_browser` — a *valid* `X-Daemon-Token` carried in a browser
|
|
48
|
+
* context (default `Origin` present) alongside the keeper's session cookie.
|
|
49
|
+
* Browsers attach `Origin` automatically; the daemon-token middleware
|
|
50
|
+
* discards a header-bearing daemon token as browser context (mirroring the
|
|
51
|
+
* bearer guard and the Rust spine's `is_browser_context`), so the *valid*
|
|
52
|
+
* token is dropped and auth falls through to the session leg → a daemon-gated
|
|
53
|
+
* action then refuses the session credential with `credential_type_required`.
|
|
54
|
+
* Distinct from `invalid_daemon`: here the token is well-formed and current,
|
|
55
|
+
* so a 403 (not a 400 confirm-guard hit like `daemon`) proves the
|
|
56
|
+
* browser-context discard fired — a valid daemon token does NOT authenticate
|
|
57
|
+
* when an `Origin` is present. `Origin` is deliberately NOT suppressed; its
|
|
58
|
+
* presence is the signal under test.
|
|
36
59
|
* - `token` — the keeper authenticated via a bearer api-token (non-browser
|
|
37
60
|
* context; the runner suppresses `Origin` so the token isn't discarded).
|
|
61
|
+
* - `bearer_browser` — a *valid* bearer api-token carried in a browser context
|
|
62
|
+
* (default `Origin` present), fresh jar so NO session rides alongside. The
|
|
63
|
+
* bearer middleware discards the token as browser context (mirroring the
|
|
64
|
+
* daemon guard + the Rust spine's `is_browser_context`), so the request
|
|
65
|
+
* arrives anonymous and an authed action 401s. Proves a stolen bearer cannot
|
|
66
|
+
* be replayed from a browser — wire-indistinguishable from sending no
|
|
67
|
+
* credential (the `token` principal is the honored counterpart: it suppresses
|
|
68
|
+
* `Origin`, so the same token authenticates).
|
|
38
69
|
* - `anonymous` — no credential, fresh cookie jar.
|
|
39
70
|
* - `fresh_non_admin` — a freshly minted account with no roles, session
|
|
40
71
|
* credential (via the production invite → signup → login flow).
|
|
@@ -52,7 +83,10 @@ import { z } from 'zod';
|
|
|
52
83
|
export const ConformancePrincipal = z.enum([
|
|
53
84
|
'keeper',
|
|
54
85
|
'daemon',
|
|
86
|
+
'invalid_daemon',
|
|
87
|
+
'daemon_browser',
|
|
55
88
|
'token',
|
|
89
|
+
'bearer_browser',
|
|
56
90
|
'anonymous',
|
|
57
91
|
'fresh_non_admin',
|
|
58
92
|
'role_holder',
|
|
@@ -96,6 +130,19 @@ export const ConformanceCaseExpectation = z.strictObject({
|
|
|
96
130
|
description: 'Specific field-value assertions on the success `result` (2xx) or the error ' +
|
|
97
131
|
'`error.data` (non-2xx). Each key must deep-equal the corresponding response field.',
|
|
98
132
|
}),
|
|
133
|
+
absent_fields: z
|
|
134
|
+
.array(z.string())
|
|
135
|
+
.optional()
|
|
136
|
+
.meta({
|
|
137
|
+
description: 'Sensitive field names that must NOT appear ANYWHERE (at any depth) in the ' +
|
|
138
|
+
'normalized response body — the negative-space twin of `fields`. Each name is ' +
|
|
139
|
+
'searched recursively through the success `result` / error envelope, so a ' +
|
|
140
|
+
'secret column (`password_hash`, `token_hash`) that leaks at any nesting level ' +
|
|
141
|
+
'(top-level, inside a list element) fails the case. Pins "this secret never ' +
|
|
142
|
+
'serializes" on BOTH spines — a serialization regression would otherwise leak ' +
|
|
143
|
+
'identically and silently on each. Non-vacuous only when the body is known ' +
|
|
144
|
+
'non-empty (e.g. a list that always contains the caller); note that in the case.',
|
|
145
|
+
}),
|
|
99
146
|
headers: z
|
|
100
147
|
.record(z.string(), z.string().nullable())
|
|
101
148
|
.optional()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conformance_table.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/conformance_table.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8B9B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"conformance_table.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/conformance_table.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8B9B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;AAOjE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAC,KAAK,eAAe,EAA4B,MAAM,uBAAuB,CAAC;AACtF,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,mBAAmB,CAAC;AAC3D,OAAO,KAAK,EAAC,SAAS,EAAc,MAAM,YAAY,CAAC;AAGvD;;;;;GAKG;AACH,MAAM,WAAW,0BAA0B;IAC1C,iEAAiE;IACjE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,sDAAsD;AACtD,MAAM,WAAW,uBAAuB;IACvC,8CAA8C;IAC9C,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,CAAC;IAC/C,+DAA+D;IAC/D,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,oEAAoE;IACpE,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC,0EAA0E;IAC1E,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,kEAAkE;IAClE,QAAQ,CAAC,aAAa,EAAE,uBAAuB,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACjD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,0BAA0B,CAAC;IACjD,iEAAiE;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AAuLD;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,EAAE,aAAa,CAAC,MAAM,CAIrD,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,6BAA6B,GACzC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,MAAM,KACX,IAQF,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GACnC,SAAS,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,UAAU,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,EACvC,OAAO,MAAM,KACX,IAYF,CAAC;AAuKF;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAAI,SAAS,uBAAuB,KAAG,IAkEnF,CAAC"}
|
|
@@ -25,10 +25,21 @@ import '../assert_dev_env.js';
|
|
|
25
25
|
* @module
|
|
26
26
|
*/
|
|
27
27
|
import { assert, describe, test } from 'vitest';
|
|
28
|
+
import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
|
|
28
29
|
import { find_auth_route, rest_auth_route_suffixes, } from '../integration_helpers.js';
|
|
29
30
|
import { find_rpc_action, headers_to_record, rpc_call, resolve_rpc_endpoints_for_setup, } from '../rpc_helpers.js';
|
|
30
31
|
import {} from './conformance_case.js';
|
|
31
32
|
import { xfail_until } from './xfail.js';
|
|
33
|
+
/**
|
|
34
|
+
* A deliberately malformed `X-Daemon-Token` value for the `invalid_daemon`
|
|
35
|
+
* principal. Fails the `DaemonToken` Zod schema (not 43 base64url chars), so
|
|
36
|
+
* the daemon-token middleware soft-fail-discards it rather than authenticating
|
|
37
|
+
* — the request falls through with no identity, surfacing
|
|
38
|
+
* `credential_type_required` on a daemon-gated action (matching the Rust
|
|
39
|
+
* spine's `None`). Sent over a no-Origin transport so it lands on the
|
|
40
|
+
* invalid-token path, not the browser-context discard.
|
|
41
|
+
*/
|
|
42
|
+
const INVALID_DAEMON_TOKEN_VALUE = 'not-a-valid-daemon-token';
|
|
32
43
|
/**
|
|
33
44
|
* Map a `ConformancePrincipal` onto the transport + headers it
|
|
34
45
|
* authenticates with, reading exclusively from the per-test `TestFixture`.
|
|
@@ -51,6 +62,39 @@ const resolve_principal = async (fixture, as, principals) => {
|
|
|
51
62
|
headers: fixture.create_daemon_token_headers(),
|
|
52
63
|
suppress_default_origin: true,
|
|
53
64
|
};
|
|
65
|
+
case 'invalid_daemon':
|
|
66
|
+
// A malformed `X-Daemon-Token` carried ALONGSIDE the keeper's session
|
|
67
|
+
// cookie, over a non-browser (no-Origin) transport. The invalid daemon
|
|
68
|
+
// token is soft-fail-discarded (matching the Rust spine's `None`), so
|
|
69
|
+
// auth falls through to the session leg: the request authenticates as
|
|
70
|
+
// the keeper-via-session (clearing `require_auth`), then the daemon-gated
|
|
71
|
+
// action's credential-type gate refuses the session credential →
|
|
72
|
+
// `403 credential_type_required`. No-Origin keeps the daemon token on
|
|
73
|
+
// the invalid-token path, NOT the browser-context discard. Without the
|
|
74
|
+
// base credential this would 401 (anonymous) — the session is what makes
|
|
75
|
+
// the *credential-type* gate, not the auth gate, the refusing layer.
|
|
76
|
+
return {
|
|
77
|
+
transport: fixture.fresh_transport({ origin: null }),
|
|
78
|
+
headers: fixture.create_session_headers({
|
|
79
|
+
[DAEMON_TOKEN_HEADER]: INVALID_DAEMON_TOKEN_VALUE,
|
|
80
|
+
}),
|
|
81
|
+
suppress_default_origin: true,
|
|
82
|
+
};
|
|
83
|
+
case 'daemon_browser':
|
|
84
|
+
// A VALID `X-Daemon-Token` carried in a browser context (default Origin
|
|
85
|
+
// present) ALONGSIDE the keeper's session cookie. The daemon-token
|
|
86
|
+
// middleware discards a header-bearing daemon token as browser context
|
|
87
|
+
// (mirroring the bearer guard + the Rust spine's `is_browser_context`),
|
|
88
|
+
// so the valid token is dropped and auth falls through to the session
|
|
89
|
+
// leg → keeper-via-session → a daemon-gated action refuses the session
|
|
90
|
+
// credential with `credential_type_required`. Origin is NOT suppressed —
|
|
91
|
+
// its presence is the browser-context signal under test. Unlike the
|
|
92
|
+
// `daemon` principal (no Origin → token honored → reaches the 400 confirm
|
|
93
|
+
// guard), the 403 here proves the valid token was discarded, not honored.
|
|
94
|
+
return {
|
|
95
|
+
transport: fixture.transport,
|
|
96
|
+
headers: fixture.create_session_headers(fixture.create_daemon_token_headers()),
|
|
97
|
+
};
|
|
54
98
|
case 'token':
|
|
55
99
|
// Bearer is discarded in a browser context — empty jar + no Origin.
|
|
56
100
|
return {
|
|
@@ -58,6 +102,20 @@ const resolve_principal = async (fixture, as, principals) => {
|
|
|
58
102
|
headers: fixture.create_bearer_headers(),
|
|
59
103
|
suppress_default_origin: true,
|
|
60
104
|
};
|
|
105
|
+
case 'bearer_browser':
|
|
106
|
+
// A VALID bearer api-token in a browser context (default Origin
|
|
107
|
+
// present), fresh jar so no session rides alongside. The bearer
|
|
108
|
+
// middleware discards a header-bearing token as browser context
|
|
109
|
+
// (mirroring the daemon guard + the Rust spine's `is_browser_context`),
|
|
110
|
+
// so the request arrives anonymous → an authed action 401s. Origin is
|
|
111
|
+
// NOT suppressed (its presence is the browser-context signal) — the
|
|
112
|
+
// inverse of the `token` principal, which suppresses it so the same
|
|
113
|
+
// token is honored. The 401 proves the valid token was discarded, not
|
|
114
|
+
// honored (a non-discarding leg would authenticate → 200).
|
|
115
|
+
return {
|
|
116
|
+
transport: fixture.fresh_transport(),
|
|
117
|
+
headers: fixture.create_bearer_headers(),
|
|
118
|
+
};
|
|
61
119
|
case 'anonymous':
|
|
62
120
|
// Fresh jar so the keeper cookie (cross-process) can't leak in.
|
|
63
121
|
return { transport: fixture.fresh_transport(), headers: {} };
|
|
@@ -97,6 +155,29 @@ const assert_fields = (actual, fields, label) => {
|
|
|
97
155
|
assert.deepEqual(record[key], expected, `${label}: field '${key}'`);
|
|
98
156
|
}
|
|
99
157
|
};
|
|
158
|
+
/**
|
|
159
|
+
* Whether `key` is present as an own property of `value` or any object nested
|
|
160
|
+
* within it (arrays and objects walked recursively). The shape-agnostic test
|
|
161
|
+
* behind `expect.absent_fields` — a secret column leaking at any depth (inside
|
|
162
|
+
* a list element, a nested record) is caught, not just a top-level field.
|
|
163
|
+
*/
|
|
164
|
+
const has_key_anywhere = (value, key) => {
|
|
165
|
+
if (Array.isArray(value))
|
|
166
|
+
return value.some((v) => has_key_anywhere(v, key));
|
|
167
|
+
if (value !== null && typeof value === 'object') {
|
|
168
|
+
if (Object.hasOwn(value, key))
|
|
169
|
+
return true;
|
|
170
|
+
return Object.values(value).some((v) => has_key_anywhere(v, key));
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
};
|
|
174
|
+
/** Assert none of the sensitive field names appear anywhere in the response body. */
|
|
175
|
+
const assert_absent_fields = (body, absent, label) => {
|
|
176
|
+
for (const key of absent) {
|
|
177
|
+
assert.ok(!has_key_anywhere(body, key), `${label}: response must not serialize the sensitive field '${key}' anywhere in its ` +
|
|
178
|
+
`body — a secret column leaked into the wire shape.`);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
100
181
|
/**
|
|
101
182
|
* Response headers that fingerprint the backend implementation or framework.
|
|
102
183
|
* Neither spine emits these; the runner asserts they stay absent on EVERY
|
|
@@ -157,10 +238,16 @@ const is_success_status = (status) => status >= 200 && status < 300;
|
|
|
157
238
|
const run_case = async (c, options, resolved_rpc_endpoints) => {
|
|
158
239
|
const fixture = await options.setup_test();
|
|
159
240
|
const { transport, headers, suppress_default_origin } = await resolve_principal(fixture, c.request.as, options.principals);
|
|
160
|
-
|
|
161
|
-
|
|
241
|
+
const normalized = c.request.method.startsWith('/')
|
|
242
|
+
? await run_rest_case(c, options, transport, headers)
|
|
243
|
+
: await run_rpc_case(c, transport, headers, suppress_default_origin, resolved_rpc_endpoints);
|
|
244
|
+
// Negative-space field check, uniform across REST + RPC and success + error
|
|
245
|
+
// bodies: a sensitive column (`password_hash`, `token_hash`) must not appear
|
|
246
|
+
// anywhere in what the prober sees.
|
|
247
|
+
if (c.expect.absent_fields) {
|
|
248
|
+
assert_absent_fields(normalized.body, c.expect.absent_fields, c.name);
|
|
162
249
|
}
|
|
163
|
-
return
|
|
250
|
+
return normalized;
|
|
164
251
|
};
|
|
165
252
|
/** Dispatch + assert a case targeting an RPC method. */
|
|
166
253
|
const run_rpc_case = async (c, transport, headers, suppress_default_origin, resolved_rpc_endpoints) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { SetupTest } from './setup.js';
|
|
3
|
+
/** Options for the session-cookie-attribute parity suite. */
|
|
4
|
+
export interface CookieAttributesCrossTestOptions {
|
|
5
|
+
/** Per-test fixture producer (cross-process only — see the module doc). */
|
|
6
|
+
readonly setup_test: SetupTest;
|
|
7
|
+
/**
|
|
8
|
+
* The spine's session cookie name (`handle.config.cookie_name`, e.g.
|
|
9
|
+
* `fuz_session`). The `Set-Cookie` under test is matched by this name so a
|
|
10
|
+
* future cookie-name change surfaces here as "no session cookie set" rather
|
|
11
|
+
* than a silent miss.
|
|
12
|
+
*/
|
|
13
|
+
readonly cookie_name: string;
|
|
14
|
+
/** REST login route path. Default `/api/account/login`. */
|
|
15
|
+
readonly login_path?: string;
|
|
16
|
+
/** REST logout route path. Default `/api/account/logout`. */
|
|
17
|
+
readonly logout_path?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const describe_cookie_attributes_cross_tests: (options: CookieAttributesCrossTestOptions) => void;
|
|
20
|
+
//# sourceMappingURL=cookie_attributes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookie_attributes.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cookie_attributes.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA+C9B,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,YAAY,CAAC;AAE1C,6DAA6D;AAC7D,MAAM,WAAW,gCAAgC;IAChD,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAoED,eAAO,MAAM,sCAAsC,GAClD,SAAS,gCAAgC,KACvC,IAmFF,CAAC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for **session cookie attributes** over real HTTP.
|
|
4
|
+
*
|
|
5
|
+
* The session cookie's `Set-Cookie` attributes are the load-bearing browser
|
|
6
|
+
* security boundary: `HttpOnly` keeps the token out of JS (XSS can't read it),
|
|
7
|
+
* `Secure` keeps it off plaintext HTTP, `SameSite=Strict` is the primary CSRF
|
|
8
|
+
* defense (the Origin allowlist is only defense-in-depth), and `Path=/` scopes
|
|
9
|
+
* it to the whole app. A regression dropping any one of these is a real
|
|
10
|
+
* downgrade — and it's wire-observable, so both spines must emit the same
|
|
11
|
+
* hardened set. The cross-backend conformance table deliberately keeps
|
|
12
|
+
* `Set-Cookie` *out* of its byte-identity comparison (the signed value
|
|
13
|
+
* legitimately differs per request and per impl), so the attribute contract
|
|
14
|
+
* had no cross-backend pin: the TS spine's attributes were covered by
|
|
15
|
+
* `session_cookie.ts`'s own tests, but the Rust spine's `sign_session_cookie`
|
|
16
|
+
* / `clear_session_cookie` strings were asserted nowhere. This suite closes
|
|
17
|
+
* that gap on both impls by parsing the raw `Set-Cookie` and asserting the
|
|
18
|
+
* attributes directly. Three properties:
|
|
19
|
+
*
|
|
20
|
+
* - **successful login sets a hardened cookie** — `HttpOnly; Secure;
|
|
21
|
+
* SameSite=Strict; Path=/` plus a positive integer `Max-Age` (the session
|
|
22
|
+
* lifetime). The signed value is opaque here; only the attributes are pinned.
|
|
23
|
+
* - **a failed login sets no session cookie** — a denial mints no credential,
|
|
24
|
+
* so there is no `Set-Cookie` for the session name at all. A spine that
|
|
25
|
+
* leaked a (signed-but-unauthenticated) cookie on the 401 would fail here.
|
|
26
|
+
* - **logout clears the cookie with `Max-Age=0` and the same hardened flags** —
|
|
27
|
+
* the clear must not silently drop `Secure` / `HttpOnly` / `SameSite=Strict`
|
|
28
|
+
* (a cleared-but-unhardened `Set-Cookie` is a downgrade window).
|
|
29
|
+
*
|
|
30
|
+
* Both surfaces are flat REST (`POST /api/account/{login,logout}`) on every
|
|
31
|
+
* spine, so this is an imperative suite (not a `conformance_table` row) — the
|
|
32
|
+
* sibling of `origin.ts` / `login_security.ts`. Cross-process only: reading the
|
|
33
|
+
* raw `Set-Cookie` attributes needs the wire response (the in-process
|
|
34
|
+
* `parse_session` / `session_cookie.rs` unit tests cover each impl's cookie
|
|
35
|
+
* codec directly). Cited property: `docs/security.md` §"Session Security"
|
|
36
|
+
* (the "Cookie attributes" bullet — `HttpOnly; Secure; SameSite=Strict; Path=/`).
|
|
37
|
+
*
|
|
38
|
+
* `$lib`-free by contract (relative specifiers only), like the sibling
|
|
39
|
+
* cross-backend suites.
|
|
40
|
+
*
|
|
41
|
+
* @module
|
|
42
|
+
*/
|
|
43
|
+
import { describe, test, assert } from 'vitest';
|
|
44
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
45
|
+
/**
|
|
46
|
+
* Parse a single `Set-Cookie` header value into `{name, value, attributes}`.
|
|
47
|
+
* Returns `null` for a malformed head (no `=`, empty name). Unlike the
|
|
48
|
+
* transport jar's `parse_set_cookie` (which drops everything after the first
|
|
49
|
+
* `;`), this retains the attributes — they are exactly what the suite asserts.
|
|
50
|
+
*/
|
|
51
|
+
const parse_set_cookie = (raw) => {
|
|
52
|
+
const parts = raw.split(';');
|
|
53
|
+
const head = (parts[0] ?? '').trim();
|
|
54
|
+
const eq = head.indexOf('=');
|
|
55
|
+
if (eq <= 0)
|
|
56
|
+
return null;
|
|
57
|
+
const name = head.slice(0, eq).trim();
|
|
58
|
+
if (!name)
|
|
59
|
+
return null;
|
|
60
|
+
const value = head.slice(eq + 1);
|
|
61
|
+
const attributes = new Map();
|
|
62
|
+
for (let i = 1; i < parts.length; i++) {
|
|
63
|
+
const attr = parts[i].trim();
|
|
64
|
+
if (!attr)
|
|
65
|
+
continue;
|
|
66
|
+
const attr_eq = attr.indexOf('=');
|
|
67
|
+
if (attr_eq === -1) {
|
|
68
|
+
attributes.set(attr.toLowerCase(), '');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
attributes.set(attr.slice(0, attr_eq).trim().toLowerCase(), attr.slice(attr_eq + 1).trim());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { name, value, attributes };
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Find the `Set-Cookie` for the session cookie among a response's headers.
|
|
78
|
+
* `getSetCookie()` returns each `Set-Cookie` as its own string (the only way
|
|
79
|
+
* to read attributes — `Headers.get()` collapses multiple cookies to one
|
|
80
|
+
* comma-joined string). Returns `undefined` when no session cookie was set.
|
|
81
|
+
*/
|
|
82
|
+
const find_session_cookie = (res, cookie_name) => {
|
|
83
|
+
for (const raw of res.headers.getSetCookie()) {
|
|
84
|
+
const parsed = parse_set_cookie(raw);
|
|
85
|
+
if (parsed && parsed.name === cookie_name)
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
};
|
|
90
|
+
/** Assert the hardened attribute set shared by the set + cleared cookies. */
|
|
91
|
+
const assert_hardened_flags = (cookie, label) => {
|
|
92
|
+
assert.ok(cookie.attributes.has('httponly'), `${label}: cookie must be HttpOnly (no JS read)`);
|
|
93
|
+
assert.ok(cookie.attributes.has('secure'), `${label}: cookie must be Secure (HTTPS-only)`);
|
|
94
|
+
assert.strictEqual(cookie.attributes.get('samesite')?.toLowerCase(), 'strict', `${label}: cookie must be SameSite=Strict (CSRF backstop)`);
|
|
95
|
+
assert.strictEqual(cookie.attributes.get('path'), '/', `${label}: cookie Path must be '/'`);
|
|
96
|
+
};
|
|
97
|
+
export const describe_cookie_attributes_cross_tests = (options) => {
|
|
98
|
+
const { setup_test, cookie_name } = options;
|
|
99
|
+
const login_path = options.login_path ?? '/api/account/login';
|
|
100
|
+
const logout_path = options.logout_path ?? '/api/account/logout';
|
|
101
|
+
// Fresh-keeper-per-test wipes the DB between tests, so a literal username
|
|
102
|
+
// never collides (see `setup.ts`). Each test mints it fresh.
|
|
103
|
+
const username = 'cookie_attr_user';
|
|
104
|
+
describe('session cookie attribute parity', () => {
|
|
105
|
+
test('successful login sets a hardened session cookie (HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age>0)', async () => {
|
|
106
|
+
const fixture = await setup_test();
|
|
107
|
+
await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
|
|
108
|
+
// Fresh transport so no pre-existing cookie rides along; the default
|
|
109
|
+
// Origin (= base_url) is allowlisted, so the login clears the Origin gate.
|
|
110
|
+
const res = await fixture.fresh_transport()(login_path, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD }),
|
|
114
|
+
});
|
|
115
|
+
assert.strictEqual(res.status, 200, 'valid credentials must log in');
|
|
116
|
+
const cookie = find_session_cookie(res, cookie_name);
|
|
117
|
+
assert.ok(cookie, `login must Set-Cookie the '${cookie_name}' session cookie`);
|
|
118
|
+
assert.ok(cookie.value.length > 0, 'the session cookie must carry a signed value');
|
|
119
|
+
assert_hardened_flags(cookie, 'login Set-Cookie');
|
|
120
|
+
const max_age = Number(cookie.attributes.get('max-age'));
|
|
121
|
+
assert.ok(Number.isInteger(max_age) && max_age > 0, `login cookie must carry a positive integer Max-Age (got ${cookie.attributes.get('max-age')})`);
|
|
122
|
+
});
|
|
123
|
+
test('failed login (wrong password) sets no session cookie', async () => {
|
|
124
|
+
const fixture = await setup_test();
|
|
125
|
+
await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
|
|
126
|
+
const res = await fixture.fresh_transport()(login_path, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
body: JSON.stringify({ username, password: `wrong-${DEFAULT_TEST_PASSWORD}` }),
|
|
130
|
+
});
|
|
131
|
+
assert.strictEqual(res.status, 401, 'wrong password must be rejected');
|
|
132
|
+
assert.strictEqual(find_session_cookie(res, cookie_name), undefined, 'a failed login must not Set-Cookie a session — no credential is minted on denial');
|
|
133
|
+
});
|
|
134
|
+
test('logout clears the session cookie with Max-Age=0 and hardened flags', async () => {
|
|
135
|
+
const fixture = await setup_test();
|
|
136
|
+
await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
|
|
137
|
+
// Log in on a fresh transport so its jar carries the session cookie into
|
|
138
|
+
// the logout request (logout is session-authenticated).
|
|
139
|
+
const transport = fixture.fresh_transport();
|
|
140
|
+
const login_res = await transport(login_path, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD }),
|
|
144
|
+
});
|
|
145
|
+
assert.strictEqual(login_res.status, 200, 'precondition: login must succeed');
|
|
146
|
+
const res = await transport(logout_path, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: { 'content-type': 'application/json' },
|
|
149
|
+
body: JSON.stringify({}),
|
|
150
|
+
});
|
|
151
|
+
assert.strictEqual(res.status, 200, 'logout must succeed for an authenticated session');
|
|
152
|
+
const cookie = find_session_cookie(res, cookie_name);
|
|
153
|
+
assert.ok(cookie, `logout must Set-Cookie a cleared '${cookie_name}'`);
|
|
154
|
+
assert.strictEqual(cookie.attributes.get('max-age'), '0', 'logout must clear the cookie with Max-Age=0');
|
|
155
|
+
assert_hardened_flags(cookie, 'logout Set-Cookie');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/** Options for the credential-header robustness probe — needs the raw URL + a valid daemon token. */
|
|
3
|
+
export interface CredentialHeaderRobustnessCrossTestOptions {
|
|
4
|
+
/** Base URL the spawned backend is reachable at (e.g. `http://localhost:1178`). */
|
|
5
|
+
readonly base_url: string;
|
|
6
|
+
/** RPC endpoint path to target. Default `/api/rpc`. */
|
|
7
|
+
readonly rpc_path?: string;
|
|
8
|
+
/** A valid daemon token (keeper) — `handle.daemon_token`. */
|
|
9
|
+
readonly daemon_token: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const describe_credential_header_robustness_cross_tests: (options: CredentialHeaderRobustnessCrossTestOptions) => void;
|
|
12
|
+
//# sourceMappingURL=credential_header_robustness.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential_header_robustness.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/credential_header_robustness.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA2D9B,qGAAqG;AACrG,MAAM,WAAW,0CAA0C;IAC1D,mFAAmF;IACnF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,uDAAuD;IACvD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC9B;AAwFD,eAAO,MAAM,iDAAiD,GAC7D,SAAS,0CAA0C,KACjD,IAkHF,CAAC"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend **credential-header robustness** probe — the auth sibling of
|
|
4
|
+
* `body_size_smuggling.ts`.
|
|
5
|
+
*
|
|
6
|
+
* The auth middleware reads a single credential per header (`Authorization`,
|
|
7
|
+
* `X-Daemon-Token`). What a *duplicated*, *oversized*, or *control-char-injected*
|
|
8
|
+
* credential header does is framework-territory — Hono reads its headers via the
|
|
9
|
+
* Web `Headers` API, axum via `http::HeaderMap` — and the two could resolve a
|
|
10
|
+
* duplicate differently (first-vs-last), cap header size differently, or parse a
|
|
11
|
+
* malformed value differently. None of that is exercised over `fetch` (the
|
|
12
|
+
* `FetchTransport` can't emit a duplicate or malformed header), so it had no
|
|
13
|
+
* cross-impl pin. This raw-socket suite sends hand-framed requests and asserts
|
|
14
|
+
* the **security invariants that must hold regardless of how each framework
|
|
15
|
+
* resolves the ambiguity** — so it pins a real property without blessing one
|
|
16
|
+
* resolution over the other:
|
|
17
|
+
*
|
|
18
|
+
* - **a duplicated credential header never escalates** — a request carrying a
|
|
19
|
+
* valid keeper `X-Daemon-Token` *and* a second, conflicting one must not
|
|
20
|
+
* perform a keeper operation. The target omits `confirm`, so the documented
|
|
21
|
+
* confirm guard (`purge_not_confirmed`) makes the honored-token branch a
|
|
22
|
+
* guaranteed non-2xx with **zero side effect**, and the discarded-token
|
|
23
|
+
* branch is a non-2xx auth/credential refusal — so whichever copy the
|
|
24
|
+
* framework picks, the outcome is non-2xx. The same shape with two garbage
|
|
25
|
+
* `Authorization` headers pins the bearer path can't be escalated either.
|
|
26
|
+
* - **a control-char-injected credential header can't smuggle or authenticate**
|
|
27
|
+
* — embedded `CRLF` / bare `CR` in the value never frames a second request
|
|
28
|
+
* (no smuggle) and never authenticates (the truncated / rejected value is no
|
|
29
|
+
* token).
|
|
30
|
+
* - **no response desync** — each crafted request yields exactly one HTTP
|
|
31
|
+
* response (a duplicate header that reframed the request would surface as a
|
|
32
|
+
* second status line, like the body-size smuggling probe).
|
|
33
|
+
* - **an oversized credential header can't wedge the server** — after a request
|
|
34
|
+
* with a 64 KiB `X-Daemon-Token` (well past both frameworks' header caps), a
|
|
35
|
+
* subsequent normal request on a fresh connection still gets a response. The
|
|
36
|
+
* oversized request's own outcome (4xx / 431 / connection close) is
|
|
37
|
+
* don't-care; surviving it is the property.
|
|
38
|
+
*
|
|
39
|
+
* Raw-socket by necessity (`fetch` can't emit duplicate or malformed headers),
|
|
40
|
+
* so — like `body_size_smuggling.ts` — this is **cross-process only** and
|
|
41
|
+
* fixture-free: it needs only the base URL, the RPC path, and a valid daemon
|
|
42
|
+
* token (`handle.daemon_token`, kept current for the run by the same rotation
|
|
43
|
+
* the `_testing_reset` channel relies on). Cited property: `docs/security.md`
|
|
44
|
+
* §"Credential Type Hierarchy" (a leaked / conflicting credential header can't
|
|
45
|
+
* escalate the credential ceiling) + §"API Token Security" (bearer handling).
|
|
46
|
+
*
|
|
47
|
+
* `$lib`-free by contract (relative + `node:` specifiers only).
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
import { connect } from 'node:net';
|
|
52
|
+
import { describe, test, assert } from 'vitest';
|
|
53
|
+
import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
|
|
54
|
+
import { SPINE_RPC_PATH } from './default_spine_surface.js';
|
|
55
|
+
/** A well-formed UUID that never names a real row. */
|
|
56
|
+
const NIL_UUID = '00000000-0000-0000-0000-000000000000';
|
|
57
|
+
/** A value that is never a real credential — used for the conflicting / garbage copies. */
|
|
58
|
+
const GARBAGE_TOKEN = 'not-a-real-credential-value';
|
|
59
|
+
/**
|
|
60
|
+
* Open a raw TCP socket to `base_url`, write `request_bytes` once, and collect
|
|
61
|
+
* everything the server sends back until it closes or `read_timeout_ms`
|
|
62
|
+
* elapses. Write errors are swallowed (a server closing mid-write on a rejected
|
|
63
|
+
* request is expected behavior); what matters is what we read back. Mirrors
|
|
64
|
+
* `body_size_smuggling.ts`'s `send_raw`.
|
|
65
|
+
*/
|
|
66
|
+
const send_raw = (base_url, request_bytes, read_timeout_ms) => new Promise((resolve) => {
|
|
67
|
+
const url = new URL(base_url);
|
|
68
|
+
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
|
|
69
|
+
const socket = connect({ host: url.hostname, port });
|
|
70
|
+
let received = '';
|
|
71
|
+
let settled = false;
|
|
72
|
+
const finish = () => {
|
|
73
|
+
if (settled)
|
|
74
|
+
return;
|
|
75
|
+
settled = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
socket.destroy();
|
|
78
|
+
resolve(received);
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(finish, read_timeout_ms);
|
|
81
|
+
socket.setEncoding('latin1');
|
|
82
|
+
socket.on('connect', () => socket.write(request_bytes));
|
|
83
|
+
socket.on('data', (chunk) => {
|
|
84
|
+
received += chunk;
|
|
85
|
+
});
|
|
86
|
+
socket.on('error', () => { }); // EPIPE/ECONNRESET on mid-write close is expected
|
|
87
|
+
socket.on('close', finish);
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* Count HTTP response status lines in a raw byte stream. Unanchored so a
|
|
91
|
+
* second (smuggled) response concatenated after the first body — which carries
|
|
92
|
+
* no trailing newline — is still counted; see `body_size_smuggling.ts`.
|
|
93
|
+
*/
|
|
94
|
+
const count_responses = (raw) => (raw.match(/HTTP\/1\.[01] \d{3}/g) ?? []).length;
|
|
95
|
+
/** The status code of the first HTTP response in a raw stream, or `null` if none. */
|
|
96
|
+
const first_status = (raw) => {
|
|
97
|
+
const match = /HTTP\/1\.[01] (\d{3})/.exec(raw);
|
|
98
|
+
return match ? Number(match[1]) : null;
|
|
99
|
+
};
|
|
100
|
+
const is_success_status = (status) => status >= 200 && status < 300;
|
|
101
|
+
/**
|
|
102
|
+
* Frame a raw HTTP/1.1 POST with the given extra header lines + JSON body.
|
|
103
|
+
* `Connection: close` so the server closes after one response (the response
|
|
104
|
+
* count is then unambiguous). The body is ASCII JSON, so `body.length` is the
|
|
105
|
+
* byte length — no `Buffer` needed.
|
|
106
|
+
*/
|
|
107
|
+
const rpc_post = (host, path, header_lines, body) => `POST ${path} HTTP/1.1\r\n` +
|
|
108
|
+
`Host: ${host}\r\n` +
|
|
109
|
+
`Content-Type: application/json\r\n` +
|
|
110
|
+
header_lines.map((h) => `${h}\r\n`).join('') +
|
|
111
|
+
`Content-Length: ${body.length}\r\n` +
|
|
112
|
+
`Connection: close\r\n` +
|
|
113
|
+
`\r\n` +
|
|
114
|
+
body;
|
|
115
|
+
/**
|
|
116
|
+
* A keeper-gated `account_purge` body with `confirm` OMITTED — the confirm
|
|
117
|
+
* guard (`purge_not_confirmed`, before any deletion) guarantees the
|
|
118
|
+
* valid-credential branch is a non-2xx with zero side effect, so the
|
|
119
|
+
* no-escalation assertion holds regardless of which duplicate header wins.
|
|
120
|
+
*/
|
|
121
|
+
const purge_body = (id) => JSON.stringify({ jsonrpc: '2.0', method: 'account_purge', id, params: { account_id: NIL_UUID } });
|
|
122
|
+
export const describe_credential_header_robustness_cross_tests = (options) => {
|
|
123
|
+
const { base_url, daemon_token } = options;
|
|
124
|
+
const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
|
|
125
|
+
const host = new URL(base_url).host;
|
|
126
|
+
describe('credential-header robustness', () => {
|
|
127
|
+
test('duplicate X-Daemon-Token (valid + conflicting) never escalates → one non-2xx response', async () => {
|
|
128
|
+
const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${daemon_token}`, `${DAEMON_TOKEN_HEADER}: ${GARBAGE_TOKEN}`], purge_body('dup-daemon'));
|
|
129
|
+
const raw = await send_raw(base_url, req, 2000);
|
|
130
|
+
assert.strictEqual(count_responses(raw), 1, `a duplicated X-Daemon-Token must yield exactly one response (no desync). Raw head: ${raw.slice(0, 120)}`);
|
|
131
|
+
const status = first_status(raw);
|
|
132
|
+
assert.ok(status !== null, `expected an HTTP response. Raw head: ${raw.slice(0, 120)}`);
|
|
133
|
+
assert.ok(!is_success_status(status), `a duplicated daemon token must not perform a keeper operation; whichever copy wins, ` +
|
|
134
|
+
`the result must be non-2xx (got ${status})`);
|
|
135
|
+
});
|
|
136
|
+
test('duplicate Authorization bearer headers (both garbage) do not authenticate → one non-2xx response', async () => {
|
|
137
|
+
const req = rpc_post(host, rpc_path, [`Authorization: Bearer ${GARBAGE_TOKEN}`, `Authorization: Bearer ${GARBAGE_TOKEN}-2`], purge_body('dup-bearer'));
|
|
138
|
+
const raw = await send_raw(base_url, req, 2000);
|
|
139
|
+
assert.strictEqual(count_responses(raw), 1, `duplicate Authorization headers must yield exactly one response (no desync). Raw head: ${raw.slice(0, 120)}`);
|
|
140
|
+
const status = first_status(raw);
|
|
141
|
+
assert.ok(status !== null, `expected an HTTP response. Raw head: ${raw.slice(0, 120)}`);
|
|
142
|
+
assert.ok(!is_success_status(status), `garbage bearer tokens (even duplicated) must not authenticate (got ${status})`);
|
|
143
|
+
});
|
|
144
|
+
test('control-char-injected credential header (CR/LF) cannot smuggle or authenticate', async () => {
|
|
145
|
+
// Embedded CRLF and a bare CR (header-splitting attempts) in a garbage
|
|
146
|
+
// `X-Daemon-Token` value. A strict HTTP parser rejects the request
|
|
147
|
+
// (400 / connection close); a lenient one truncates the value to a
|
|
148
|
+
// non-token or treats the injected line as an extra (harmless) header.
|
|
149
|
+
// Either way the framework-agnostic invariants hold: the injected bytes
|
|
150
|
+
// never frame a SECOND request (no smuggle), and the garbage credential
|
|
151
|
+
// never authenticates (no 2xx). `<= 1` (not `=== 1`) because a strict
|
|
152
|
+
// parser may close without a readable response — that's also "no smuggle".
|
|
153
|
+
const malformed = [
|
|
154
|
+
{ label: 'CRLF header-split', value: `${GARBAGE_TOKEN}\r\nX-Injected: 1` },
|
|
155
|
+
{ label: 'bare CR', value: `${GARBAGE_TOKEN}\rX-Injected: 1` },
|
|
156
|
+
];
|
|
157
|
+
for (const { label, value } of malformed) {
|
|
158
|
+
const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${value}`], purge_body('ctrl-char'));
|
|
159
|
+
const raw = await send_raw(base_url, req, 2000);
|
|
160
|
+
assert.ok(count_responses(raw) <= 1, `${label}: a control-char-injected credential header must not frame a second ` +
|
|
161
|
+
`request (no smuggle). Raw head: ${raw.slice(0, 120)}`);
|
|
162
|
+
const status = first_status(raw);
|
|
163
|
+
assert.ok(status === null || !is_success_status(status), `${label}: a malformed credential header must never authenticate (got ${status})`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
test('oversized credential header does not crash or wedge the server', async () => {
|
|
167
|
+
// 64 KiB — well past both frameworks' header-size caps, so the request is
|
|
168
|
+
// rejected (4xx / 431 / connection close). Its own outcome is don't-care.
|
|
169
|
+
const oversized = 'a'.repeat(64 * 1024);
|
|
170
|
+
const req = rpc_post(host, rpc_path, [`${DAEMON_TOKEN_HEADER}: ${oversized}`], purge_body('oversized'));
|
|
171
|
+
await send_raw(base_url, req, 2000);
|
|
172
|
+
// The property: the server survives it. A normal request on a fresh
|
|
173
|
+
// connection must still get a response (no crash, no wedged accept loop).
|
|
174
|
+
const probe = `GET ${rpc_path} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`;
|
|
175
|
+
const raw = await send_raw(base_url, probe, 2000);
|
|
176
|
+
assert.ok(first_status(raw) !== null, `server must still respond after an oversized-header request (no crash / wedge). Raw head: ${raw.slice(0, 120)}`);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,
|
|
1
|
+
{"version":3,"file":"default_backend_configs.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_backend_configs.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAC,MAAM,qBAAqB,CAAC;AAC/E,OAAO,KAAK,EAAC,mBAAmB,EAAE,iBAAiB,EAAC,MAAM,mBAAmB,CAAC;AAC9E,OAAO,EAA2B,KAAK,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAQ9F;;;;;;;;;;GAUG;AACH,eAAO,MAAM,4BAA4B,iCAAiC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,mBAuBpC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,mBAmBtC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,EAAE,iBAInC,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBAIrC,CAAC;AAeH,MAAM,WAAW,iCAAiC;IACjD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,oDAAoD;IACpD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,6CAA6C;IAC7C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,GAC1C,MAAM,iCAAiC,KACrC,aAoCF,CAAC;AAEF,MAAM,WAAW,mCAAmC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,QAAQ,CAAC,aAAa,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,+CAA+C;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,mBAAmB,CAAC;IAC5C,wEAAwE;IACxE,QAAQ,CAAC,KAAK,CAAC,EAAE,gBAAgB,CAAC;IAClC,sEAAsE;IACtE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAC/D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,gCAAgC,GAC5C,MAAM,mCAAmC,KACvC,aA4CF,CAAC"}
|
|
@@ -35,9 +35,11 @@ export const ts_default_capabilities = Object.freeze({
|
|
|
35
35
|
// posture). A Bun-served consumer overrides to `false` (see the bun spine
|
|
36
36
|
// config) — fail-loud rather than silently skipping the smuggle detector.
|
|
37
37
|
oversized_reject_closes_connection: true,
|
|
38
|
-
//
|
|
39
|
-
// `BackendWebsocketTransport.
|
|
40
|
-
//
|
|
38
|
+
// Off by default like `sse`/`ready`: the WS backend transport *can* initiate
|
|
39
|
+
// server→client requests (`BackendWebsocketTransport.request_connection`),
|
|
40
|
+
// but driving the full `peer/ping` suite also needs `peer/ping` mounted on
|
|
41
|
+
// the HTTP RPC endpoint (the no-transport case). The TS spine config opts in;
|
|
42
|
+
// a generic consumer enables it once it wires that mount.
|
|
41
43
|
peer_request: false,
|
|
42
44
|
});
|
|
43
45
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fact_serving.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/fact_serving.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiD9B,OAAO,KAAK,EAAC,wBAAwB,EAAE,SAAS,EAAC,MAAM,YAAY,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,WAAW,2BAA4B,SAAQ,wBAAwB;IAC5E,QAAQ,CAAC,sBAAsB,CAAC,EAAE,SAAS,CAAC;CAC5C;AA8BD,eAAO,MAAM,iCAAiC,GAAI,SAAS,2BAA2B,KAAG,
|
|
1
|
+
{"version":3,"file":"fact_serving.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/fact_serving.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAiD9B,OAAO,KAAK,EAAC,wBAAwB,EAAE,SAAS,EAAC,MAAM,YAAY,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,WAAW,2BAA4B,SAAQ,wBAAwB;IAC5E,QAAQ,CAAC,sBAAsB,CAAC,EAAE,SAAS,CAAC;CAC5C;AA8BD,eAAO,MAAM,iCAAiC,GAAI,SAAS,2BAA2B,KAAG,IAiOxF,CAAC"}
|