@fuzdev/fuz_app 0.106.1 → 0.108.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 +42 -23
- package/dist/actions/action_bridge.d.ts +34 -3
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +32 -1
- package/dist/actions/action_rpc.d.ts +5 -4
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +4 -3
- package/dist/actions/action_spec.d.ts +8 -2
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +5 -2
- package/dist/actions/compile_action_registry.d.ts +26 -14
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +29 -14
- package/dist/actions/perform_action.d.ts +26 -16
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +69 -44
- package/dist/actions/register_action_ws.d.ts +4 -4
- package/dist/actions/register_action_ws.js +5 -5
- package/dist/actions/register_ws_endpoint.d.ts +13 -5
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +15 -6
- package/dist/auth/CLAUDE.md +16 -9
- package/dist/auth/account_route_schema.d.ts +10 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +10 -1
- package/dist/auth/account_routes.d.ts +18 -4
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +32 -29
- package/dist/auth/api_token_queries.d.ts +4 -0
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +5 -1
- package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_route_schema.js +11 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +4 -9
- package/dist/auth/auth_guard_resolver.d.ts +48 -21
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
- package/dist/auth/auth_guard_resolver.js +60 -26
- package/dist/auth/bearer_auth.d.ts +10 -7
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -22
- package/dist/auth/bootstrap_routes.d.ts +12 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -9
- package/dist/auth/middleware.d.ts +0 -3
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +11 -10
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +24 -0
- package/dist/auth/request_context.d.ts +54 -24
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +117 -39
- package/dist/auth/session_queries.d.ts +13 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +14 -3
- package/dist/auth/signup_routes.d.ts +10 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -11
- package/dist/auth/token_scope.d.ts +109 -21
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +106 -21
- package/dist/http/CLAUDE.md +23 -11
- package/dist/http/auth_shape.d.ts +58 -3
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +63 -5
- package/dist/http/error_schemas.d.ts +25 -2
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +37 -15
- package/dist/http/route_spec.d.ts +29 -26
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +12 -16
- package/dist/http/surface_query.d.ts +2 -2
- package/dist/http/surface_query.js +2 -2
- package/dist/rate_limiter.d.ts +36 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +36 -3
- package/dist/server/app_server.d.ts +27 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +30 -20
- package/dist/server/app_server_context.d.ts +10 -2
- package/dist/server/app_server_context.d.ts.map +1 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +12 -10
- package/dist/testing/CLAUDE.md +60 -11
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +6 -4
- package/dist/testing/cross_backend/conformance_case.js +1 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +9 -8
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +59 -0
- package/dist/testing/cross_backend/session_cap.d.ts +19 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/session_cap.js +93 -0
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +6 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_scope_surface.js +170 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -2
- package/dist/testing/middleware.d.ts +2 -5
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +5 -5
- package/dist/testing/rate_limiting.d.ts +10 -16
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +11 -95
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +4 -2
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +3 -1
- package/dist/testing/testing_rate_limiter.d.ts +7 -1
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
- package/dist/testing/testing_rate_limiter.js +7 -1
- package/package.json +1 -1
|
@@ -81,16 +81,23 @@ export const create_app_server = async (options) => {
|
|
|
81
81
|
const { deps } = backend;
|
|
82
82
|
const { log } = deps;
|
|
83
83
|
// Rate limiter defaults (undefined = default, null = disable)
|
|
84
|
-
|
|
84
|
+
// One instance per surface — see `AppServerOptions.login_ip_rate_limiter`
|
|
85
|
+
// for why these are not a single shared bucket.
|
|
86
|
+
const login_ip_rate_limiter = options.login_ip_rate_limiter === undefined
|
|
87
|
+
? create_rate_limiter()
|
|
88
|
+
: options.login_ip_rate_limiter;
|
|
89
|
+
const signup_ip_rate_limiter = options.signup_ip_rate_limiter === undefined
|
|
90
|
+
? create_rate_limiter()
|
|
91
|
+
: options.signup_ip_rate_limiter;
|
|
92
|
+
const bootstrap_ip_rate_limiter = options.bootstrap_ip_rate_limiter === undefined
|
|
93
|
+
? create_rate_limiter()
|
|
94
|
+
: options.bootstrap_ip_rate_limiter;
|
|
85
95
|
const login_account_rate_limiter = options.login_account_rate_limiter === undefined
|
|
86
96
|
? create_rate_limiter(default_login_account_rate_limit)
|
|
87
97
|
: options.login_account_rate_limiter;
|
|
88
98
|
const signup_account_rate_limiter = options.signup_account_rate_limiter === undefined
|
|
89
99
|
? create_rate_limiter(default_login_account_rate_limit)
|
|
90
100
|
: options.signup_account_rate_limiter;
|
|
91
|
-
const bearer_ip_rate_limiter = options.bearer_ip_rate_limiter === undefined
|
|
92
|
-
? create_rate_limiter()
|
|
93
|
-
: options.bearer_ip_rate_limiter;
|
|
94
101
|
const action_ip_rate_limiter = options.action_ip_rate_limiter === undefined
|
|
95
102
|
? create_rate_limiter(default_action_ip_rate_limit)
|
|
96
103
|
: options.action_ip_rate_limiter;
|
|
@@ -115,7 +122,6 @@ export const create_app_server = async (options) => {
|
|
|
115
122
|
const auth_middleware = await create_auth_middleware_specs(deps, {
|
|
116
123
|
allowed_origins: options.allowed_origins,
|
|
117
124
|
session_options: options.session_options,
|
|
118
|
-
bearer_ip_rate_limiter,
|
|
119
125
|
daemon_token_state: options.daemon_token_state
|
|
120
126
|
});
|
|
121
127
|
let middleware_specs = [proxy_spec, ...auth_middleware];
|
|
@@ -147,7 +153,9 @@ export const create_app_server = async (options) => {
|
|
|
147
153
|
backend,
|
|
148
154
|
bootstrap_status,
|
|
149
155
|
session_options: options.session_options,
|
|
150
|
-
|
|
156
|
+
login_ip_rate_limiter,
|
|
157
|
+
signup_ip_rate_limiter,
|
|
158
|
+
bootstrap_ip_rate_limiter,
|
|
151
159
|
login_account_rate_limiter,
|
|
152
160
|
signup_account_rate_limiter,
|
|
153
161
|
action_ip_rate_limiter,
|
|
@@ -166,7 +174,7 @@ export const create_app_server = async (options) => {
|
|
|
166
174
|
session_options: options.session_options,
|
|
167
175
|
bootstrap_status,
|
|
168
176
|
on_bootstrap: options.bootstrap.mode === 'live' ? options.bootstrap.on_bootstrap : undefined,
|
|
169
|
-
|
|
177
|
+
bootstrap_ip_rate_limiter
|
|
170
178
|
});
|
|
171
179
|
const prefix = options.bootstrap.route_prefix ?? '/api/account';
|
|
172
180
|
factory_routes.push(...prefix_route_specs(prefix, bootstrap_routes));
|
|
@@ -243,19 +251,21 @@ export const create_app_server = async (options) => {
|
|
|
243
251
|
});
|
|
244
252
|
}
|
|
245
253
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
// One diagnostic per surface — a deployment that disables login's limiter
|
|
255
|
+
// but keeps signup's is a different posture than one that disables both,
|
|
256
|
+
// and a single collapsed warning hid which surface was open.
|
|
257
|
+
for (const [name, limiter] of [
|
|
258
|
+
['login', login_ip_rate_limiter],
|
|
259
|
+
['signup', signup_ip_rate_limiter],
|
|
260
|
+
['bootstrap', bootstrap_ip_rate_limiter]
|
|
261
|
+
]) {
|
|
262
|
+
if (limiter === null) {
|
|
263
|
+
config_diagnostics.push({
|
|
264
|
+
level: 'warning',
|
|
265
|
+
category: 'config',
|
|
266
|
+
message: `${name} IP rate limiter explicitly disabled (null)`
|
|
267
|
+
});
|
|
268
|
+
}
|
|
259
269
|
}
|
|
260
270
|
if (config_diagnostics.length) {
|
|
261
271
|
surface_spec.surface.diagnostics = [...surface_spec.surface.diagnostics, ...config_diagnostics];
|
|
@@ -25,8 +25,16 @@ export interface AppServerContext {
|
|
|
25
25
|
backend: AppBackend;
|
|
26
26
|
bootstrap_status: BootstrapStatus;
|
|
27
27
|
session_options: SessionOptions<string>;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Per-IP login + password-change rate limiter (from options). `null` when
|
|
30
|
+
* not configured. One instance per auth surface — see
|
|
31
|
+
* `AppServerOptions.login_ip_rate_limiter` for why these aren't shared.
|
|
32
|
+
*/
|
|
33
|
+
login_ip_rate_limiter: RateLimiter | null;
|
|
34
|
+
/** Per-IP signup rate limiter (from options). `null` when not configured. */
|
|
35
|
+
signup_ip_rate_limiter: RateLimiter | null;
|
|
36
|
+
/** Per-IP bootstrap rate limiter (from options). `null` when not configured. */
|
|
37
|
+
bootstrap_ip_rate_limiter: RateLimiter | null;
|
|
30
38
|
/** Per-account login rate limiter (from options). `null` when not configured. */
|
|
31
39
|
login_account_rate_limiter: RateLimiter | null;
|
|
32
40
|
/** Per-account signup rate limiter (from options). `null` when not configured. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app_server_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAEjE,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,UAAU,CAAC;IACpB,gBAAgB,EAAE,eAAe,CAAC;IAClC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,
|
|
1
|
+
{"version":3,"file":"app_server_context.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/app_server_context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,6BAA6B,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAC;AAEjE,8CAA8C;AAC9C,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,UAAU,CAAC;IACpB,gBAAgB,EAAE,eAAe,CAAC;IAClC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;;OAIG;IACH,qBAAqB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1C,6EAA6E;IAC7E,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,gFAAgF;IAChF,yBAAyB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC9C,iFAAiF;IACjF,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,kFAAkF;IAClF,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD,uGAAuG;IACvG,sBAAsB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC3C,0GAA0G;IAC1G,2BAA2B,EAAE,WAAW,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,SAAS,EAAE,WAAW,GAAG,IAAI,CAAC;CAC9B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwFG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAM/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA6DjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAgHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAgDF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA0CF,CAAC"}
|
|
@@ -93,7 +93,7 @@ import { join } from 'node:path';
|
|
|
93
93
|
import { FactHashSchema } from '@fuzdev/fuz_util/hash_schemas.ts';
|
|
94
94
|
import { Uuid } from '@fuzdev/fuz_util/id.ts';
|
|
95
95
|
import { z } from 'zod';
|
|
96
|
-
import { build_request_context, get_request_context, has_role
|
|
96
|
+
import { build_request_context, get_request_context, has_role } from '../auth/request_context.js';
|
|
97
97
|
import { ROLE_ADMIN } from '../auth/role_schema.js';
|
|
98
98
|
import { ActingActor } from '../http/auth_shape.js';
|
|
99
99
|
import { ACCOUNT_ID_KEY } from '../hono_context.js';
|
|
@@ -320,7 +320,17 @@ export const create_serve_fact_route_spec = (options) => {
|
|
|
320
320
|
return {
|
|
321
321
|
method: 'GET',
|
|
322
322
|
path: '/api/facts/:hash',
|
|
323
|
-
auth: {
|
|
323
|
+
auth: {
|
|
324
|
+
account: 'required',
|
|
325
|
+
actor: 'required',
|
|
326
|
+
roles: [ROLE_ADMIN],
|
|
327
|
+
// Rule 3 — a narrowed token gets no bare-hash read. This route
|
|
328
|
+
// serves *any* stored fact by hash with no per-reference cell check
|
|
329
|
+
// (that is the per-cell route's job), so it is the broad capability
|
|
330
|
+
// of the pair; gating it keeps a narrowed token to the cell-scoped
|
|
331
|
+
// route its RPC methods can justify.
|
|
332
|
+
required_scope: 'surface:fact_bare'
|
|
333
|
+
},
|
|
324
334
|
description: 'Serve content-addressed fact bytes by bare hash — admin only. Non-admin reads go through GET /api/cells/:cell_id/facts/:hash (per-reference).',
|
|
325
335
|
params: bare_hash_params_schema,
|
|
326
336
|
// `actor: 'required'` (implied by the admin role gate) needs the
|
|
@@ -334,14 +344,6 @@ export const create_serve_fact_route_spec = (options) => {
|
|
|
334
344
|
errors: params_400_error,
|
|
335
345
|
handler: async (c, route) => {
|
|
336
346
|
const { hash } = get_route_params(c);
|
|
337
|
-
// Rule 3 — a narrowed token gets no bare-hash read. This route serves
|
|
338
|
-
// *any* stored fact by hash with no per-reference cell check (that is
|
|
339
|
-
// the per-cell route's job), so it is the broad capability of the
|
|
340
|
-
// pair; gating it keeps a narrowed token to the cell-scoped route its
|
|
341
|
-
// RPC methods can justify.
|
|
342
|
-
const denied = token_scope_surface_denial(c, 'fact_bare');
|
|
343
|
-
if (denied)
|
|
344
|
-
return denied;
|
|
345
347
|
// Defense-in-depth: the auth phase already gated this on the admin
|
|
346
348
|
// role, but re-check the resolved context so a mounting/auth-shape
|
|
347
349
|
// regression fails closed instead of serving by bare hash.
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -457,8 +457,8 @@ file that imports from `middleware.ts` gets these mocks globally. Pair with
|
|
|
457
457
|
- `BearerAuthTestOptions`, `BearerAuthTestCase` — test-case table shape for the bearer auth runner.
|
|
458
458
|
- `create_bearer_auth_mocks(tc)` — configures the module-level mocks per test case; returns spy references.
|
|
459
459
|
- `TEST_CLIENT_IP = '127.0.0.1'` — IP set by the proxy stub in `create_bearer_auth_test_app`.
|
|
460
|
-
- `create_bearer_auth_test_app(tc
|
|
461
|
-
- `describe_bearer_auth_cases(suite_name, cases
|
|
460
|
+
- `create_bearer_auth_test_app(tc)` — Hono app with bearer middleware + echo route at `/api/test` returning `{ok, account_id, credential_type, api_token_id, request_context_set}` — the account-grain identity bearer auth writes, plus a flag for tests that pre-populate `REQUEST_CONTEXT_KEY` via `pre_context`.
|
|
461
|
+
- `describe_bearer_auth_cases(suite_name, cases)` — table-driven runner; one `test()` per case; asserts status, error, body fields, `api_token_id`, context preservation.
|
|
462
462
|
- `TEST_MIDDLEWARE_PATH = '/api/test'` — path used by the echo route in the stack factory.
|
|
463
463
|
- `create_test_middleware_stack_app(options?)` — real proxy + origin + bearer middleware for integration-shape testing. Echo route returns `{ok, client_ip, has_context}`.
|
|
464
464
|
|
|
@@ -571,17 +571,19 @@ Options: `{setup_test, surface_source, capabilities, sensitive_fields?, admin_on
|
|
|
571
571
|
|
|
572
572
|
### `rate_limiting.ts` — `describe_rate_limiting_tests`
|
|
573
573
|
|
|
574
|
-
|
|
574
|
+
Two test groups:
|
|
575
575
|
|
|
576
576
|
1. IP rate limiting on login — fires `max_attempts + 1` requests; last should be 429 with `RateLimitError` body + valid `Retry-After` header.
|
|
577
577
|
2. Per-account rate limiting on login — same username exhausts the bucket; a different username is not blocked.
|
|
578
|
-
|
|
578
|
+
|
|
579
|
+
No bearer group — the bearer path carries no rate limiter on either spine
|
|
580
|
+
(`auth/bearer_auth.ts`), which is also why `rpc_endpoints` is optional here.
|
|
579
581
|
|
|
580
582
|
Each group asserts its required route exists with a descriptive message.
|
|
581
583
|
Creates a tight rate limiter (default `max_attempts: 2`, `window_ms: 60_000`)
|
|
582
584
|
per test and disposes it in `finally`.
|
|
583
585
|
|
|
584
|
-
Options: `{session_options, create_route_specs, rpc_endpoints
|
|
586
|
+
Options: `{session_options, create_route_specs, rpc_endpoints?, app_options?, db_factories?, max_attempts?}`.
|
|
585
587
|
Reads inputs directly from the options bag instead of going through the
|
|
586
588
|
`setup_test` fixture protocol — the per-test rate-limiter overrides need a
|
|
587
589
|
fresh `TestApp` per test that the single-fixture model can't carry.
|
|
@@ -1346,6 +1348,42 @@ are `src/test/auth/cell_crud_parity.db.test.ts`
|
|
|
1346
1348
|
complete action list plus its own spec. Daemon-token-gated like its siblings;
|
|
1347
1349
|
the Rust mirror is `fuz_testing::create_testing_action_manifest_action_spec`.
|
|
1348
1350
|
|
|
1351
|
+
### Token-scope surface parity — `cross_backend/token_scope_surface.ts`
|
|
1352
|
+
|
|
1353
|
+
`describe_token_scope_surface_cross_tests({setup_test, capabilities, rpc_path?,
|
|
1354
|
+
sse_path?})` — the `surface:<name>` twin of the conformance slate's
|
|
1355
|
+
`token_scope_cases`, which cover only the `rpc:<method>` arm. Token scoping's
|
|
1356
|
+
RPC-only rule (_a narrowed api token reaches no non-RPC surface_) is the
|
|
1357
|
+
load-bearing half of the design and had **no** cross-impl gate: the schema gate
|
|
1358
|
+
can't see it (no column), the action-manifest gate can't see it (no method), and
|
|
1359
|
+
the spec-derived suites can't see it (REST routes, off the declared RPC surface).
|
|
1360
|
+
|
|
1361
|
+
Mints a narrowed bearer through the production `account_token_create` path and
|
|
1362
|
+
fires it at the two surfaces whose denial **body** is observable over plain
|
|
1363
|
+
HTTP — the audit SSE stream (`capabilities.sse`) and the admin-only bare-hash
|
|
1364
|
+
fact read (`capabilities.fact_serving`) — asserting the exact flat
|
|
1365
|
+
`{error: 'token_scope_required', required_scope: 'surface:<name>'}` both spines
|
|
1366
|
+
contract on (`token_scope_denied_body` ↔
|
|
1367
|
+
`fuz_auth::token_scope_surface_denied_response`).
|
|
1368
|
+
|
|
1369
|
+
The fifth case is the one with no prior pin anywhere: **scope outranks role**. A
|
|
1370
|
+
narrowed token on an account that _also_ lacks the gating role must hear
|
|
1371
|
+
`token_scope_required`, not `insufficient_permissions` — the observable the
|
|
1372
|
+
TS↔Rust ordering divergence produced before both spines converged on running the
|
|
1373
|
+
surface gate ahead of the role gate. Every other scoped-token probe uses the
|
|
1374
|
+
keeper's token, whose account holds the role, so both orderings look identical
|
|
1375
|
+
there; that is why the divergence survived as long as it did. Verified to
|
|
1376
|
+
discriminate: dropping the route's `required_scope` turns it into a 403
|
|
1377
|
+
`insufficient_permissions` + `required_roles`, exactly the pre-convergence shape.
|
|
1378
|
+
|
|
1379
|
+
Two non-vacuity controls: the narrowed token must still reach the RPC method it
|
|
1380
|
+
_does_ name, and a full bearer must get past the scope gate on the same route.
|
|
1381
|
+
The WS upgrade is deliberately omitted — `create_ws_transport` threads cookies,
|
|
1382
|
+
not bearer headers, and a refused upgrade's body isn't readable, so it can't
|
|
1383
|
+
serve a body-parity gate (its per-spine coverage stays in the two surface
|
|
1384
|
+
censuses). Cross-process only; fuz_app's own wiring is
|
|
1385
|
+
`src/test/cross_backend/token_scope_surface.cross.test.ts`.
|
|
1386
|
+
|
|
1349
1387
|
### Origin verification parity — `cross_backend/origin.ts`
|
|
1350
1388
|
|
|
1351
1389
|
`describe_origin_cross_tests({setup_test, capabilities, rpc_path?})` — the
|
|
@@ -1396,9 +1434,9 @@ read) it fires three principals over real HTTP: **anonymous** → 401, **session
|
|
|
1396
1434
|
proving the daemon-token gate that fences each backdoor action holds end-to-end
|
|
1397
1435
|
on the real dispatcher (the spec-derived `describe_rpc_attack_surface_tests`
|
|
1398
1436
|
never enumerates them because they're off the declared surface). Each method is
|
|
1399
|
-
sent with **valid** params so
|
|
1400
|
-
|
|
1401
|
-
|
|
1437
|
+
sent with **valid** params so each case varies only the credential (validation
|
|
1438
|
+
runs after the gates — order is 401 → authz → 403 → 400); the handler never
|
|
1439
|
+
runs. Cited property: `docs/security.md`
|
|
1402
1440
|
§Test Backdoor Actions. Cross-process only (the `_testing_*` actions are
|
|
1403
1441
|
mounted on the spawned binary, not the in-process app — like the ws/sse
|
|
1404
1442
|
suites); ungated, since every cross backend that uses
|
|
@@ -1459,7 +1497,7 @@ pay the 250ms denial floor.
|
|
|
1459
1497
|
|
|
1460
1498
|
`describe_login_security_cross_tests({setup_test, login_path?})` — the imperative
|
|
1461
1499
|
suite pinning **login rate limiting + `X-Forwarded-For` client-IP resolution**
|
|
1462
|
-
over real HTTP on both spines
|
|
1500
|
+
over real HTTP on both spines:
|
|
1463
1501
|
|
|
1464
1502
|
- **per-IP limit fires** — `default_login_ip_rate_limit.max_attempts` (5, the
|
|
1465
1503
|
shared cap on both impls) failed logins from one forwarded IP → 401, the next →
|
|
@@ -1469,15 +1507,26 @@ over real HTTP on both spines. Two cases:
|
|
|
1469
1507
|
- **XFF keying** — exhaust one forwarded IP to 429, then a _distinct_ forwarded IP
|
|
1470
1508
|
→ 401 (not 429), proving the limiter keys on the resolved `X-Forwarded-For` IP,
|
|
1471
1509
|
not the loopback TCP peer (a backend ignoring XFF would 429 the fresh IP too).
|
|
1510
|
+
- **a success does not refund the per-IP budget** — the distributed-spray
|
|
1511
|
+
backstop. Failed guesses against distinct usernames from one forwarded IP,
|
|
1512
|
+
interleaved with the attacker's own successful logins, must still 429 within a
|
|
1513
|
+
bounded total-request budget. Both spines used to clear the IP bucket on
|
|
1514
|
+
success; only the account-grain bucket is forgiven now. The **budget bound** is
|
|
1515
|
+
what makes this adversarial — a decrement-instead-of-zero implementation loops
|
|
1516
|
+
to the iteration cap and fails here. Either leg may trip: the aggregate governs
|
|
1517
|
+
the address, so once it fills the attacker's own login is refused too.
|
|
1472
1518
|
|
|
1473
1519
|
**Cross-process only**, on a **dedicated dual-spawn project** (`cross_backend_security`
|
|
1474
1520
|
via `global_setup_login_security.ts`) — the limiters can't be enabled on a backend
|
|
1475
1521
|
the standard suites share (they fire many loopback logins a live limiter would
|
|
1476
1522
|
429), so the standard backends keep every limiter null. Determinism without a
|
|
1477
1523
|
limiter reset: each case uses its own forwarded IP (a fresh bucket) plus its own
|
|
1478
|
-
non-existent username
|
|
1524
|
+
non-existent username; the spray case additionally mints its attacker account
|
|
1525
|
+
over the setup path (loopback, no XFF), so minting can't touch the bucket under
|
|
1526
|
+
test. Zero Rust code change — the stub's `FUZ_LOGIN_RATE_LIMIT_ENABLED`
|
|
1479
1527
|
and `FUZ_TRUSTED_PROXIES` env-gates were already wired; the TS side env-gates the
|
|
1480
|
-
limiters in the binary, and `create_spine_route_specs` honors
|
|
1528
|
+
limiters in the binary, and `create_spine_route_specs` honors
|
|
1529
|
+
`ctx.login_ip_rate_limiter`.
|
|
1481
1530
|
The in-process counterparts already exist (`describe_rate_limiting_tests` plus the
|
|
1482
1531
|
proxy middleware tests), so there's no in-process leg. `npm run test:cross:security`.
|
|
1483
1532
|
Cited property: `docs/security.md` §"Rate Limiting" + §"Trusted Proxy / Client IP".
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"app_server.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/app_server.ts"],"names":[],"mappings":"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"}
|
|
@@ -276,10 +276,11 @@ export const create_test_app = async (options) => {
|
|
|
276
276
|
allowed_origins: [/^http:\/\/localhost/],
|
|
277
277
|
proxy: { trusted_proxies: ['127.0.0.1'], get_connection_ip: () => '127.0.0.1' },
|
|
278
278
|
env_schema: z.object({}),
|
|
279
|
-
|
|
279
|
+
login_ip_rate_limiter: null,
|
|
280
|
+
signup_ip_rate_limiter: null,
|
|
281
|
+
bootstrap_ip_rate_limiter: null,
|
|
280
282
|
login_account_rate_limiter: null,
|
|
281
283
|
signup_account_rate_limiter: null,
|
|
282
|
-
bearer_ip_rate_limiter: null,
|
|
283
284
|
await_pending_effects: true,
|
|
284
285
|
daemon_token_state,
|
|
285
286
|
rpc_endpoints: options.rpc_endpoints,
|
|
@@ -402,10 +403,11 @@ export const create_test_app_for_bootstrap = async (options) => {
|
|
|
402
403
|
allowed_origins: [/^http:\/\/localhost/],
|
|
403
404
|
proxy: { trusted_proxies: ['127.0.0.1'], get_connection_ip: () => '127.0.0.1' },
|
|
404
405
|
env_schema: z.object({}),
|
|
405
|
-
|
|
406
|
+
login_ip_rate_limiter: null,
|
|
407
|
+
signup_ip_rate_limiter: null,
|
|
408
|
+
bootstrap_ip_rate_limiter: null,
|
|
406
409
|
login_account_rate_limiter: null,
|
|
407
410
|
signup_account_rate_limiter: null,
|
|
408
|
-
bearer_ip_rate_limiter: null,
|
|
409
411
|
await_pending_effects: true,
|
|
410
412
|
daemon_token_state,
|
|
411
413
|
rpc_endpoints: options.rpc_endpoints,
|
|
@@ -141,7 +141,7 @@ export const ConformanceCaseExpectation = z.strictObject({
|
|
|
141
141
|
description: 'Expected error reason — pass the IMPORTED `ERROR_*` constant from ' +
|
|
142
142
|
'`http/error_schemas.ts`, never a string literal. Asserted against the RPC ' +
|
|
143
143
|
'`error.data.reason` (when the denial carries one) or the REST flat-body ' +
|
|
144
|
-
'`error` field. The pre-
|
|
144
|
+
'`error` field. The pre-authorization 401 carries `data.reason` too; a denial ' +
|
|
145
145
|
'that genuinely omits it falls back to the `status` assertion to pin the class.'
|
|
146
146
|
}),
|
|
147
147
|
fields: z
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAEtF,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAI1F,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAe3E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,EAAE,gBAGxB,CAAC;AAEH,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,
|
|
1
|
+
{"version":3,"file":"default_spine_surface.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/default_spine_surface.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAE9B;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAItD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAEtF,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAI1F,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oCAAoC,CAAC;AAe3E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,EAAE,cAAc,CAAC,MAAM,CAAwC,CAAC;AAElG;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,EAAE,gBAGxB,CAAC;AAEH,+CAA+C;AAC/C,MAAM,WAAW,wBAAwB;IACxC;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,gBAAgB,EACrB,UAAU,wBAAwB,KAChC,KAAK,CAAC,eAAe,CAQvB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,wBAAwB,GAAI,KAAK,gBAAgB,KAAG,KAAK,CAAC,SAAS,CAgC/E,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,GAAI,MAAM,MAAM,KAAG,SAC+B,CAAC;AAE7F;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,QAAO,cAM1C,CAAC"}
|
|
@@ -72,20 +72,21 @@ export const create_spine_route_specs = (ctx) => [
|
|
|
72
72
|
...create_account_route_specs(ctx.deps, {
|
|
73
73
|
session_options: spine_session_options,
|
|
74
74
|
// Honor the limiters the server was assembled with (on `AppServerContext`
|
|
75
|
-
// per the `
|
|
76
|
-
// than hardcoding `null`. The standard spine binary passes `null`
|
|
77
|
-
// cross suites fire many loopback logins and must not trip a
|
|
78
|
-
// this is behavior-preserving there; the dedicated
|
|
79
|
-
// backend passes real limiters to exercise the 429 +
|
|
80
|
-
// and XFF-keyed bucketing over the wire (see
|
|
81
|
-
|
|
75
|
+
// per the `login_ip_rate_limiter` / `login_account_rate_limiter` TSDoc)
|
|
76
|
+
// rather than hardcoding `null`. The standard spine binary passes `null`
|
|
77
|
+
// (the cross suites fire many loopback logins and must not trip a
|
|
78
|
+
// limiter), so this is behavior-preserving there; the dedicated
|
|
79
|
+
// login-security cross backend passes real limiters to exercise the 429 +
|
|
80
|
+
// `Retry-After` path and XFF-keyed bucketing over the wire (see
|
|
81
|
+
// `login_security.ts`).
|
|
82
|
+
login_ip_rate_limiter: ctx.login_ip_rate_limiter,
|
|
82
83
|
login_account_rate_limiter: ctx.login_account_rate_limiter,
|
|
83
84
|
login_fail_floor_ms: 0,
|
|
84
85
|
bootstrap_status: ctx.bootstrap_status
|
|
85
86
|
}),
|
|
86
87
|
...create_signup_route_specs(ctx.deps, {
|
|
87
88
|
session_options: spine_session_options,
|
|
88
|
-
|
|
89
|
+
signup_ip_rate_limiter: null,
|
|
89
90
|
signup_account_rate_limiter: null,
|
|
90
91
|
// Disable the denial floor so the identity-parity suite's accepted
|
|
91
92
|
// cases (valid username/email, no invite → 403 no-matching-invite)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"login_security.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/login_security.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA8D9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,mDAAmD;AACnD,MAAM,WAAW,6BAA6B;IAC7C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC7B;AA0BD,eAAO,MAAM,mCAAmC,GAC/C,SAAS,6BAA6B,KACpC,IA2KF,CAAC"}
|
|
@@ -24,6 +24,13 @@ import '../assert_dev_env.js';
|
|
|
24
24
|
* not `429`). A backend that ignored XFF and keyed on the (loopback) TCP
|
|
25
25
|
* peer would 429 the fresh-IP request too — so the `401` proves the limiter
|
|
26
26
|
* keys on the resolved `X-Forwarded-For` client IP.
|
|
27
|
+
* - **a success does not refund the per-IP budget** — the distributed-spray
|
|
28
|
+
* backstop. Failed guesses against *distinct* usernames from one forwarded
|
|
29
|
+
* IP, interleaved with the attacker's own successful logins, must still 429
|
|
30
|
+
* within a bounded total-request budget. Both spines used to clear the IP
|
|
31
|
+
* bucket on success, which made that budget unbounded: one credential (or,
|
|
32
|
+
* under open signup, none) bought unlimited guesses against arbitrary victims
|
|
33
|
+
* from a single address. Only the account-grain bucket is forgiven now.
|
|
27
34
|
*
|
|
28
35
|
* **Determinism without a limiter reset.** Limiter state is in-memory and the
|
|
29
36
|
* per-test `_testing_reset` wipes only the DB, never the buckets — so each
|
|
@@ -58,6 +65,7 @@ import { default_login_account_rate_limit, default_login_ip_rate_limit } from '.
|
|
|
58
65
|
const XFF_IP_LIMIT = '203.0.113.1';
|
|
59
66
|
const XFF_SEGREGATION_EXHAUST = '203.0.113.2';
|
|
60
67
|
const XFF_SEGREGATION_FRESH = '203.0.113.3';
|
|
68
|
+
const XFF_SPRAY = '203.0.113.4';
|
|
61
69
|
/** A non-routable, non-existent identifier — login resolves to the not-found path (records the bucket, returns 401). */
|
|
62
70
|
const PROBE_PASSWORD = 'login_security_wrong_password';
|
|
63
71
|
export const describe_login_security_cross_tests = (options) => {
|
|
@@ -93,6 +101,20 @@ export const describe_login_security_cross_tests = (options) => {
|
|
|
93
101
|
retry_after_header: res.headers.get('retry-after')
|
|
94
102
|
};
|
|
95
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* POST a login expected to *succeed*, on a fresh transport with the given
|
|
106
|
+
* `X-Forwarded-For`. The mirror of `attempt` — used to prove that a success
|
|
107
|
+
* does not refund the resolved IP's bucket.
|
|
108
|
+
*/
|
|
109
|
+
const succeed = async (fixture, forwarded_for, username, password) => {
|
|
110
|
+
const res = await fixture.fresh_transport()(login_path, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded_for },
|
|
113
|
+
body: JSON.stringify({ username, password })
|
|
114
|
+
});
|
|
115
|
+
await res.text();
|
|
116
|
+
return res.status;
|
|
117
|
+
};
|
|
96
118
|
describe('login rate limiting + trusted-proxy parity', () => {
|
|
97
119
|
test(`per-IP login limit fires 429 + Retry-After after ${ip_limit} attempts from one forwarded IP`, async () => {
|
|
98
120
|
const fixture = await setup_test();
|
|
@@ -126,5 +148,42 @@ export const describe_login_security_cross_tests = (options) => {
|
|
|
126
148
|
const fresh = await attempt(fixture, XFF_SEGREGATION_FRESH, 'probe_xff');
|
|
127
149
|
assert.strictEqual(fresh.status, 401, "a fresh forwarded IP must not inherit another IP's limit");
|
|
128
150
|
});
|
|
151
|
+
test('a success does not refund the per-IP budget (distributed-spray backstop)', async () => {
|
|
152
|
+
const fixture = await setup_test();
|
|
153
|
+
// The attacker's own account. Minting rides the setup path from
|
|
154
|
+
// loopback with no XFF, so it lands on a different bucket than
|
|
155
|
+
// XFF_SPRAY and can't confound the count.
|
|
156
|
+
const attacker = await fixture.create_account({
|
|
157
|
+
username: 'spray_attacker',
|
|
158
|
+
password_value: 'spray_attacker_password'
|
|
159
|
+
});
|
|
160
|
+
let budget = 0;
|
|
161
|
+
let limited = false;
|
|
162
|
+
for (let i = 0; i < ip_limit * 3; i++) {
|
|
163
|
+
// Their own successful login — the move that used to zero the
|
|
164
|
+
// whole IP window — then one guess against a fresh victim
|
|
165
|
+
// username, so no per-account bucket accumulates and only the
|
|
166
|
+
// IP aggregate can stop this.
|
|
167
|
+
const own = await succeed(fixture, XFF_SPRAY, attacker.account.username, 'spray_attacker_password');
|
|
168
|
+
budget++;
|
|
169
|
+
// Either leg may trip: the aggregate governs the *address*, so
|
|
170
|
+
// once it fills even the attacker's own login is refused. That
|
|
171
|
+
// is the property — no move from this IP buys more attempts.
|
|
172
|
+
if (own === 429) {
|
|
173
|
+
limited = true;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
assert.strictEqual(own, 200, "the attacker's own login must succeed");
|
|
177
|
+
const guess = await attempt(fixture, XFF_SPRAY, `spray_victim_${i}`);
|
|
178
|
+
budget++;
|
|
179
|
+
if (guess.status === 429) {
|
|
180
|
+
limited = true;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
assert.strictEqual(guess.status, 401, 'a guess against an unknown user must 401');
|
|
184
|
+
}
|
|
185
|
+
assert(limited, 'the per-IP aggregate must eventually 429 the spray');
|
|
186
|
+
assert(budget <= ip_limit * 2 + 2, `interleaved successes must not extend the per-IP budget (spent ${budget})`);
|
|
187
|
+
});
|
|
129
188
|
});
|
|
130
189
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
import type { SetupTest } from './setup.js';
|
|
3
|
+
/** Options for the session-cap parity suite. */
|
|
4
|
+
export interface SessionCapCrossTestOptions {
|
|
5
|
+
/** Per-test fixture producer (cross-process only — see the module doc). */
|
|
6
|
+
readonly setup_test: SetupTest;
|
|
7
|
+
/**
|
|
8
|
+
* The cap both spines enforce. Defaults to `DEFAULT_MAX_SESSIONS` — the TS
|
|
9
|
+
* route default and the Rust `const`. A consumer that overrides
|
|
10
|
+
* `max_sessions` on the TS side passes its value here.
|
|
11
|
+
*/
|
|
12
|
+
readonly max_sessions?: number;
|
|
13
|
+
/** REST login route path. Default `/api/account/login`. */
|
|
14
|
+
readonly login_path?: string;
|
|
15
|
+
/** REST account-status route path. Default `/api/account/status`. */
|
|
16
|
+
readonly status_path?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const describe_session_cap_cross_tests: (options: SessionCapCrossTestOptions) => void;
|
|
19
|
+
//# sourceMappingURL=session_cap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session_cap.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/session_cap.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAwD9B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5C,gDAAgD;AAChD,MAAM,WAAW,0BAA0B;IAC1C,2EAA2E;IAC3E,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IA6DtF,CAAC"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import '../assert_dev_env.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-backend parity suite for the **per-account concurrent-session cap**
|
|
4
|
+
* over real HTTP.
|
|
5
|
+
*
|
|
6
|
+
* The cap is a shipped control on the TS spine (`query_session_enforce_limit`,
|
|
7
|
+
* `DEFAULT_MAX_SESSIONS`) that the Rust spine went without — its session INSERT
|
|
8
|
+
* carried no eviction, so every Rust-backed deployment served unlimited
|
|
9
|
+
* concurrent sessions per account. That divergence survived because nothing crossed the
|
|
10
|
+
* wire on it — the TS half is pinned by in-process tests
|
|
11
|
+
* (`session_middleware.lifecycle.db.test.ts`, `session_token_limits.integration.db.test.ts`)
|
|
12
|
+
* whose Rust counterparts simply didn't exist to fail. This suite is the pin
|
|
13
|
+
* that runs on *both*, so the next spine to lose the cap fails here rather than
|
|
14
|
+
* in production. Two properties:
|
|
15
|
+
*
|
|
16
|
+
* - **the cap bounds concurrent sessions, by eviction not refusal** — logging in
|
|
17
|
+
* `max_sessions + 1` times succeeds every time (a login is never denied for
|
|
18
|
+
* being the one over the line), and the newest cookie resolves afterward.
|
|
19
|
+
* - **the evicted session is the oldest** — the first login's cookie no longer
|
|
20
|
+
* authenticates once the cap is exceeded. A spine with no cap authenticates it
|
|
21
|
+
* fine, which is exactly the defect this catches.
|
|
22
|
+
*
|
|
23
|
+
* **Why `max_sessions + 1` logins and not `max_sessions`.** The starting session
|
|
24
|
+
* count isn't zero and isn't guaranteed equal across impls — `create_account`
|
|
25
|
+
* seeds its own session on the Rust cradle. Overshooting by one guarantees at
|
|
26
|
+
* least one eviction whatever the baseline is, and the *oldest* login is the
|
|
27
|
+
* first to go either way. Asserting on the first and last sessions rather than
|
|
28
|
+
* on a row count keeps the case wire-observable (no DB channel) and
|
|
29
|
+
* baseline-independent.
|
|
30
|
+
*
|
|
31
|
+
* Both surfaces are flat REST (`POST /api/account/login`, `GET /api/account/status`)
|
|
32
|
+
* on every spine, so this is an imperative suite (not a `conformance_table` row)
|
|
33
|
+
* — the sibling of `cookie_attributes.ts` / `origin.ts` / `login_security.ts`.
|
|
34
|
+
* **Cross-process only**, and load-bearingly so: each login is held on its own
|
|
35
|
+
* `fresh_transport` and identified by that transport's cookie jar, which
|
|
36
|
+
* in-process is a jar-less passthrough. That also keeps the suite free of a
|
|
37
|
+
* fourth `Set-Cookie` parser — reading the raw header is `cookie_attributes.ts`'s
|
|
38
|
+
* job, and this case only needs "does this login still authenticate".
|
|
39
|
+
*
|
|
40
|
+
* What it does **not** pin: concurrent creators. Two simultaneous logins can
|
|
41
|
+
* each evict against a stale count under Read Committed and both commit above
|
|
42
|
+
* the cap — see `query_session_enforce_limit`'s race note. That needs a
|
|
43
|
+
* barrier-based test against one backend, not a parity case.
|
|
44
|
+
*
|
|
45
|
+
* `$lib`-free by contract (relative specifiers only), like the sibling
|
|
46
|
+
* cross-backend suites.
|
|
47
|
+
*
|
|
48
|
+
* @module
|
|
49
|
+
*/
|
|
50
|
+
import { describe, test, assert } from 'vitest';
|
|
51
|
+
import { DEFAULT_MAX_SESSIONS } from '../../auth/account_route_schema.js';
|
|
52
|
+
import { DEFAULT_TEST_PASSWORD } from '../test_credentials.js';
|
|
53
|
+
export const describe_session_cap_cross_tests = (options) => {
|
|
54
|
+
const { setup_test } = options;
|
|
55
|
+
const max_sessions = options.max_sessions ?? DEFAULT_MAX_SESSIONS;
|
|
56
|
+
const login_path = options.login_path ?? '/api/account/login';
|
|
57
|
+
const status_path = options.status_path ?? '/api/account/status';
|
|
58
|
+
// Fresh-keeper-per-test wipes the DB between tests, so a literal username
|
|
59
|
+
// never collides (see `setup.ts`).
|
|
60
|
+
const username = 'session_cap_user';
|
|
61
|
+
describe('per-account session cap parity', () => {
|
|
62
|
+
test(`logging in ${max_sessions + 1} times evicts the oldest session (newest cookie resolves, first does not)`, async () => {
|
|
63
|
+
const fixture = await setup_test();
|
|
64
|
+
await fixture.create_account({ username, password_value: DEFAULT_TEST_PASSWORD });
|
|
65
|
+
// One transport per login, each with its own empty jar, so each ends up
|
|
66
|
+
// holding exactly that login's session cookie and re-sends it on any
|
|
67
|
+
// later request. That's why this suite is cross-process only: in-process
|
|
68
|
+
// `fresh_transport` is a jar-less passthrough and every session would
|
|
69
|
+
// look alike. Held oldest-first.
|
|
70
|
+
const sessions = [];
|
|
71
|
+
for (let i = 0; i < max_sessions + 1; i++) {
|
|
72
|
+
const transport = fixture.fresh_transport();
|
|
73
|
+
const res = await transport(login_path, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ username, password: DEFAULT_TEST_PASSWORD })
|
|
77
|
+
});
|
|
78
|
+
assert.strictEqual(res.status, 200, `login ${i + 1}/${max_sessions + 1} must succeed — the cap evicts, it never refuses`);
|
|
79
|
+
assert.ok(transport.cookies().length > 0, `login ${i + 1} must set a session cookie on its jar`);
|
|
80
|
+
sessions.push(transport);
|
|
81
|
+
}
|
|
82
|
+
/** `GET /status` on one login's transport: 200 authenticated, 401 not. */
|
|
83
|
+
const status_on = async (transport) => {
|
|
84
|
+
const res = await transport(status_path, { method: 'GET' });
|
|
85
|
+
await res.text();
|
|
86
|
+
return res.status;
|
|
87
|
+
};
|
|
88
|
+
assert.strictEqual(await status_on(sessions[sessions.length - 1]), 200, 'the newest session must still resolve — the cap must evict, not refuse');
|
|
89
|
+
assert.strictEqual(await status_on(sessions[0]), 401, `the oldest session must be evicted once the account exceeds ${max_sessions} sessions ` +
|
|
90
|
+
'(a spine with no cap authenticates this cookie)');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"testing_backdoor.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_backdoor.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"testing_backdoor.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/testing_backdoor.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4D9B,OAAO,KAAK,EAAE,wBAAwB,EAAe,MAAM,YAAY,CAAC;AAqExE,kEAAkE;AAClE,MAAM,MAAM,+BAA+B,GAAG,wBAAwB,CAAC;AAEvE,eAAO,MAAM,qCAAqC,GACjD,SAAS,+BAA+B,KACtC,IAiCF,CAAC"}
|