@adaptic/backend-legacy 0.0.996 → 0.0.998
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/esm/auth/authorization-map.d.ts +77 -0
- package/esm/auth/authorization-map.d.ts.map +1 -0
- package/esm/auth/authorization-map.js.map +1 -0
- package/esm/auth/authorization-map.mjs +132 -0
- package/esm/auth/cortex-auth-checker.d.ts +114 -0
- package/esm/auth/cortex-auth-checker.d.ts.map +1 -0
- package/esm/auth/cortex-auth-checker.js.map +1 -0
- package/esm/auth/cortex-auth-checker.mjs +163 -0
- package/esm/middleware/__tests__/rate-limit-test-utils.d.ts +60 -0
- package/esm/middleware/__tests__/rate-limit-test-utils.d.ts.map +1 -0
- package/esm/middleware/__tests__/rate-limit-test-utils.js.map +1 -0
- package/esm/middleware/__tests__/rate-limit-test-utils.mjs +85 -0
- package/esm/middleware/rate-limiter.d.ts +81 -4
- package/esm/middleware/rate-limiter.d.ts.map +1 -1
- package/esm/middleware/rate-limiter.js.map +1 -1
- package/esm/middleware/rate-limiter.mjs +154 -48
- package/esm/middleware/rate-tier.d.ts +56 -0
- package/esm/middleware/rate-tier.d.ts.map +1 -0
- package/esm/middleware/rate-tier.js.map +1 -0
- package/esm/middleware/rate-tier.mjs +103 -0
- package/package.json +1 -1
- package/server.cjs +61 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
/**
|
|
4
|
+
* Shared fixtures for the rate-limiter test files (audit
|
|
5
|
+
* B01-backend-legacy-08). Pure helpers only — each test file remains
|
|
6
|
+
* responsible for setting `process.env.JWT_SECRET` via `vi.hoisted` BEFORE its
|
|
7
|
+
* first import pulls in `config/jwtConfig` (which reads the env at load time).
|
|
8
|
+
*/
|
|
9
|
+
/** Window used by every test config. */
|
|
10
|
+
export const WINDOW_MS = 60_000;
|
|
11
|
+
/**
|
|
12
|
+
* Builds a small test limiter config.
|
|
13
|
+
*
|
|
14
|
+
* @param overrides - Field overrides applied over the defaults.
|
|
15
|
+
* @returns A complete {@link RateLimitConfig}.
|
|
16
|
+
*/
|
|
17
|
+
export function makeConfig(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
name: 'graphql',
|
|
20
|
+
windowMs: WINDOW_MS,
|
|
21
|
+
maxAuthenticated: 100,
|
|
22
|
+
maxUnauthenticated: 2,
|
|
23
|
+
standardHeaders: true,
|
|
24
|
+
message: { errors: [{ message: 'Too many requests' }] },
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Creates a mock response whose header/status/json calls are observable —
|
|
30
|
+
* the shadow contract is proven by these spies staying untouched.
|
|
31
|
+
*
|
|
32
|
+
* @returns The {@link MockResponse}.
|
|
33
|
+
*/
|
|
34
|
+
export function makeRes() {
|
|
35
|
+
const setHeader = vi.fn();
|
|
36
|
+
const json = vi.fn();
|
|
37
|
+
const status = vi.fn(() => ({ json }));
|
|
38
|
+
const res = { setHeader, status, json };
|
|
39
|
+
return { res, setHeader, status, json };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Creates a mock request. Defaults to an anonymous POST from a fixed IP.
|
|
43
|
+
*
|
|
44
|
+
* @param overrides - Request field overrides (headers, ip, method, …).
|
|
45
|
+
* @returns A Request usable by the limiter middleware.
|
|
46
|
+
*/
|
|
47
|
+
export function makeReq(overrides = {}) {
|
|
48
|
+
return {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: {},
|
|
51
|
+
ip: '203.0.113.10',
|
|
52
|
+
socket: { remoteAddress: '203.0.113.10' },
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reads the current value of a limiter counter for a label pair.
|
|
58
|
+
*
|
|
59
|
+
* @param counter - The prom-client counter to read.
|
|
60
|
+
* @param labels - The limiter/tier label pair identifying the series.
|
|
61
|
+
* @returns The current value (0 when the series does not exist yet).
|
|
62
|
+
*/
|
|
63
|
+
export async function counterValue(counter, labels) {
|
|
64
|
+
const metric = await counter.get();
|
|
65
|
+
const sample = metric.values.find((v) => v.labels.limiter === labels.limiter && v.labels.tier === labels.tier);
|
|
66
|
+
return sample?.value ?? 0;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Signs a real HS256 user JWT with the ambient `JWT_SECRET` (the same secret
|
|
70
|
+
* `config/jwtConfig` resolved at load), so the tier resolver verifies it.
|
|
71
|
+
*
|
|
72
|
+
* @param sub - Subject claim for the token.
|
|
73
|
+
* @returns A signed JWT string.
|
|
74
|
+
*/
|
|
75
|
+
export function signUserToken(sub) {
|
|
76
|
+
const secret = process.env.JWT_SECRET;
|
|
77
|
+
if (!secret) {
|
|
78
|
+
throw new Error('JWT_SECRET must be set by the test file before signing');
|
|
79
|
+
}
|
|
80
|
+
return jwt.sign({ sub, roles: ['user'] }, secret, {
|
|
81
|
+
algorithm: 'HS256',
|
|
82
|
+
expiresIn: '1h',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=rate-limit-test-utils.js.map
|
|
@@ -1,16 +1,93 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { Counter, Gauge } from 'prom-client';
|
|
3
|
+
/**
|
|
4
|
+
* Environment variable gating 429 enforcement. When set to `true`/`1`, the
|
|
5
|
+
* limiters block over-limit requests with HTTP 429. Unset or any other value
|
|
6
|
+
* keeps them in SHADOW mode (observe + count + always proceed).
|
|
7
|
+
*/
|
|
8
|
+
export declare const CORTEX_RATE_LIMIT_ENFORCE_ENV = "CORTEX_RATE_LIMIT_ENFORCE";
|
|
9
|
+
/**
|
|
10
|
+
* Whether rate-limit 429 enforcement is ON. Read fresh on each request (a cheap
|
|
11
|
+
* env read) so the shadow→enforce graduation can be flipped operationally
|
|
12
|
+
* without a restart. Defaults to OFF (shadow) for any unset/unrecognised value.
|
|
13
|
+
*
|
|
14
|
+
* @param env - Environment source (defaults to `process.env`); injectable for tests.
|
|
15
|
+
* @returns `true` only when the flag is explicitly `true`/`1`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isRateLimitEnforced(env?: Record<string, string | undefined>): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Counts requests that rate limiting WOULD block (HTTP 429) while in SHADOW
|
|
20
|
+
* mode. This is the signal proving whether flipping
|
|
21
|
+
* `CORTEX_RATE_LIMIT_ENFORCE` to enforce would reject legitimate traffic.
|
|
22
|
+
* Labelled by the `limiter` name (`graphql`/`auth`) and the request `tier`
|
|
23
|
+
* (`auth`/`anon`). Real enforce-mode denials count on
|
|
24
|
+
* {@link cortexRateLimitBlockedTotal} instead, keeping the observed-only and
|
|
25
|
+
* actually-blocked series distinct (audit B01-backend-legacy-14).
|
|
26
|
+
*/
|
|
27
|
+
export declare const cortexRateLimitWouldBlockTotal: Counter<"limiter" | "tier">;
|
|
28
|
+
/**
|
|
29
|
+
* Counts requests actually blocked with HTTP 429 in ENFORCE mode. Kept as a
|
|
30
|
+
* separate series from {@link cortexRateLimitWouldBlockTotal} so shadow
|
|
31
|
+
* observations and real blocks never mix (audit B01-backend-legacy-14).
|
|
32
|
+
*/
|
|
33
|
+
export declare const cortexRateLimitBlockedTotal: Counter<"limiter" | "tier">;
|
|
34
|
+
/**
|
|
35
|
+
* Tracked bucket-key cardinality per limiter store. Bounds memory ahead of the
|
|
36
|
+
* trust-proxy fix, after which the identifier derives from X-Forwarded-For and
|
|
37
|
+
* an attacker rotating spoofed values could otherwise mint unbounded entries
|
|
38
|
+
* (audit B01-backend-legacy-12).
|
|
39
|
+
*/
|
|
40
|
+
export declare const cortexRateLimitStoreKeys: Gauge<"limiter">;
|
|
41
|
+
/** Configuration for one rate limiter instance. */
|
|
42
|
+
export interface RateLimitConfig {
|
|
43
|
+
/** Stable limiter identifier used as the `limiter` metric label. */
|
|
44
|
+
name: string;
|
|
45
|
+
windowMs: number;
|
|
46
|
+
maxAuthenticated: number;
|
|
47
|
+
maxUnauthenticated: number;
|
|
48
|
+
message: {
|
|
49
|
+
errors: Array<{
|
|
50
|
+
message: string;
|
|
51
|
+
}>;
|
|
52
|
+
};
|
|
53
|
+
standardHeaders?: boolean;
|
|
54
|
+
legacyHeaders?: boolean;
|
|
55
|
+
/** Cap on distinct bucket keys (defaults to {@link DEFAULT_MAX_TRACKED_KEYS}). */
|
|
56
|
+
maxTrackedKeys?: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Creates an in-memory rate limiter middleware with separate limits for
|
|
60
|
+
* verified (auth) and anonymous requests.
|
|
61
|
+
*
|
|
62
|
+
* Bucketing: verified callers are keyed per principal (`sub:<sub>`); anonymous
|
|
63
|
+
* callers are keyed by client IP, which resolves correctly behind the LB only
|
|
64
|
+
* with `trust proxy` configured in server.ts (audit B01-backend-legacy-02).
|
|
65
|
+
* The `auth` tier requires actual offline verification — a fabricated
|
|
66
|
+
* JWT-shaped header no longer earns the higher budget (B01-backend-legacy-09).
|
|
67
|
+
* CORS preflights (`OPTIONS`) are never counted (B01-backend-legacy-11).
|
|
68
|
+
*
|
|
69
|
+
* Response headers (enforce mode only, when standardHeaders is enabled):
|
|
70
|
+
* X-RateLimit-Limit - maximum requests allowed in the current window
|
|
71
|
+
* X-RateLimit-Remaining - requests remaining in the current window
|
|
72
|
+
* X-RateLimit-Reset - seconds until the current window resets
|
|
73
|
+
* Retry-After - seconds to wait before retrying (only on 429)
|
|
74
|
+
*
|
|
75
|
+
* @param config - Rate limit configuration.
|
|
76
|
+
* @returns Express middleware function.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createRateLimiter(config: RateLimitConfig): (req: Request, res: Response, next: NextFunction) => void;
|
|
2
79
|
/**
|
|
3
80
|
* Rate limiter for GraphQL endpoint.
|
|
4
81
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
82
|
+
* Verified requests: 1000 requests per 15 minutes (configurable via RATE_LIMIT_MAX)
|
|
83
|
+
* Anonymous requests: 200 requests per 15 minutes (configurable via RATE_LIMIT_MAX_UNAUTH)
|
|
7
84
|
*/
|
|
8
85
|
export declare const graphqlRateLimiter: (req: Request, res: Response, next: NextFunction) => void;
|
|
9
86
|
/**
|
|
10
87
|
* Rate limiter for authentication endpoints.
|
|
11
88
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
89
|
+
* Verified requests: 50 requests per 15 minutes
|
|
90
|
+
* Anonymous requests: 20 requests per 15 minutes
|
|
14
91
|
*/
|
|
15
92
|
export declare const authRateLimiter: (req: Request, res: Response, next: NextFunction) => void;
|
|
16
93
|
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAK7C;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,8BAA8B,CAAC;AAEzE;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACpD,OAAO,CAGT;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,8BAA8B,6BAKzC,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,6BAKtC,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,kBAKnC,CAAC;AAeH,mDAAmD;AACnD,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;IACzB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,OAAO,EAAE;QAAE,MAAM,EAAE,KAAK,CAAC;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;IAChD,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,kFAAkF;IAClF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAOD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,eAAe,GACtB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,KAAK,IAAI,CAgG3D;AAED;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QAxGtB,OAAO,OAAO,QAAQ,QAAQ,YAAY,KAAK,IAkHtD,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QA1HnB,OAAO,OAAO,QAAQ,QAAQ,YAAY,KAAK,IAkItD,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"rate-limiter.js","sourceRoot":"","sources":["../../../src/middleware/rate-limiter.ts"],"names":[],"mappings":"AAAA,yCAAyC;AACzC,sFAAsF;AACtF,uFAAuF;AACvF,2EAA2E;AAC3E,2EAA2E;AAC3E,2EAA2E;AAG3E,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;GAIG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,2BAA2B,CAAC;AAEzE;;;;;;;GAOG;AACH,MAAM,UAAU,mBAAmB,CACjC,MAA0C,OAAO,CAAC,GAAG;IAErD,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,6BAA6B,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5E,OAAO,GAAG,KAAK,MAAM,IAAI,GAAG,KAAK,GAAG,CAAC;AACvC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,OAAO,CAAC;IACxD,IAAI,EAAE,qCAAqC;IAC3C,IAAI,EAAE,yIAAyI;IAC/I,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,CAAU;IACxC,SAAS,EAAE,CAAC,eAAe,CAAC;CAC7B,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,OAAO,CAAC;IACrD,IAAI,EAAE,iCAAiC;IACvC,IAAI,EAAE,kFAAkF;IACxF,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,CAAU;IACxC,SAAS,EAAE,CAAC,eAAe,CAAC;CAC7B,CAAC,CAAC;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,IAAI,KAAK,CAAC;IAChD,IAAI,EAAE,8BAA8B;IACpC,IAAI,EAAE,0DAA0D;IAChE,UAAU,EAAE,CAAC,SAAS,CAAU;IAChC,SAAS,EAAE,CAAC,eAAe,CAAC;CAC7B,CAAC,CAAC;AAEH,gDAAgD;AAChD,MAAM,uBAAuB,GAAG,MAAM,CAAC;AAEvC;;;;GAIG;AACH,MAAM,wBAAwB,GAAG,OAAO,CAAC;AAEzC,wEAAwE;AACxE,MAAM,mBAAmB,GAAG,UAAU,CAAC;AAqBvC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAuB;IAEvB,MAAM,KAAK,GAAG,IAAI,GAAG,EAA0B,CAAC;IAChD,MAAM,cAAc,GAAG,MAAM,CAAC,cAAc,IAAI,wBAAwB,CAAC;IAEzE,wEAAwE;IACxE,8DAA8D;IAC9D,iCAAiC;IACjC,WAAW,CAAC,GAAG,EAAE;QACf,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACvB,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,EAAE,CAAC;YACjC,IAAI,KAAK,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC;gBAC1B,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;QACD,wBAAwB,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;IACrE,CAAC,EAAE,uBAAuB,CAAC,CAAC,KAAK,EAAE,CAAC;IAEpC,OAAO,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAQ,EAAE;QAC/D,wEAAwE;QACxE,iCAAiC;QACjC,IAAI,GAAG,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAC7B,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QAED,MAAM,EAAE,IAAI,EAAE,YAAY,EAAE,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;QACpD,MAAM,UAAU,GACd,YAAY,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,MAAM,CAAC,aAAa,IAAI,SAAS,CAAC;QAClE,MAAM,YAAY,GAChB,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC;QACxE,IAAI,QAAQ,GAAG,GAAG,UAAU,IAAI,IAAI,EAAE,CAAC;QACvC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QAEvB,sEAAsE;QACtE,oDAAoD;QACpD,iCAAiC;QACjC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,KAAK,CAAC,IAAI,IAAI,cAAc,EAAE,CAAC;YACzD,QAAQ,GAAG,GAAG,mBAAmB,IAAI,IAAI,EAAE,CAAC;QAC9C,CAAC;QAED,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACrC,IAAI,OAAuB,CAAC;QAC5B,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,SAAS,GAAG,GAAG,EAAE,CAAC;YAC1C,OAAO,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,SAAS,EAAE,GAAG,GAAG,MAAM,CAAC,QAAQ,EAAE,CAAC;YACzD,KAAK,CAAC,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QAC/B,CAAC;aAAM,CAAC;YACN,QAAQ,CAAC,KAAK,IAAI,CAAC,CAAC;YACpB,OAAO,GAAG,QAAQ,CAAC;QACrB,CAAC;QAED,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;QAC5D,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,SAAS,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;QACjE,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK,GAAG,YAAY,CAAC;QAE/C,6EAA6E;QAC7E,0EAA0E;QAC1E,gEAAgE;QAChE,IAAI,CAAC,mBAAmB,EAAE,EAAE,CAAC;YAC3B,IAAI,SAAS,EAAE,CAAC;gBACd,8BAA8B,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;gBACnE,mEAAmE;gBACnE,sEAAsE;gBACtE,wEAAwE;gBACxE,IAAI,OAAO,CAAC,KAAK,KAAK,YAAY,GAAG,CAAC,EAAE,CAAC;oBACvC,MAAM,CAAC,IAAI,CAAC,mDAAmD,EAAE;wBAC/D,OAAO,EAAE,MAAM,CAAC,IAAI;wBACpB,UAAU;wBACV,IAAI;wBACJ,KAAK,EAAE,OAAO,CAAC,KAAK;wBACpB,KAAK,EAAE,YAAY;wBACnB,YAAY;qBACb,CAAC,CAAC;gBACL,CAAC;YACH,CAAC;YACD,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QAED,oEAAoE;QACpE,qCAAqC;QACrC,IAAI,MAAM,CAAC,eAAe,KAAK,KAAK,EAAE,CAAC;YACrC,GAAG,CAAC,SAAS,CAAC,mBAAmB,EAAE,YAAY,CAAC,QAAQ,EAAE,CAAC,CAAC;YAC5D,GAAG,CAAC,SAAS,CAAC,uBAAuB,EAAE,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;YAC7D,GAAG,CAAC,SAAS,CAAC,mBAAmB,EAAE,YAAY,CAAC,QAAQ,EAAE,CAAC,CAAC;QAC9D,CAAC;QAED,IAAI,SAAS,EAAE,CAAC;YACd,oEAAoE;YACpE,GAAG,CAAC,SAAS,CAAC,aAAa,EAAE,YAAY,CAAC,QAAQ,EAAE,CAAC,CAAC;YACtD,2BAA2B,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YAChE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;YACrC,OAAO;QACT,CAAC;QAED,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;IAClD,IAAI,EAAE,SAAS;IACf,QAAQ,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,aAAa;IACvC,gBAAgB,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,MAAM,EAAE,EAAE,CAAC;IACpE,kBAAkB,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,qBAAqB,IAAI,KAAK,EAAE,EAAE,CAAC;IAC5E,eAAe,EAAE,IAAI;IACrB,aAAa,EAAE,KAAK;IACpB,OAAO,EAAE;QACP,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,4CAA4C,EAAE,CAAC;KACpE;CACF,CAAC,CAAC;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAC;IAC/C,IAAI,EAAE,MAAM;IACZ,QAAQ,EAAE,EAAE,GAAG,EAAE,GAAG,IAAI,EAAE,aAAa;IACvC,gBAAgB,EAAE,EAAE;IACpB,kBAAkB,EAAE,EAAE;IACtB,eAAe,EAAE,IAAI;IACrB,aAAa,EAAE,KAAK;IACpB,OAAO,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,mCAAmC,EAAE,CAAC,EAAE;CACxE,CAAC,CAAC"}
|
|
@@ -1,75 +1,179 @@
|
|
|
1
|
-
//
|
|
1
|
+
// CORTEX-P0-001: mounted in server.ts as
|
|
2
|
+
// app.use('/graphql', cors, graphqlRateLimiter, …) // GraphQL surface (after CORS)
|
|
3
|
+
// app.use('/api', authRateLimiter) // authenticated Express surface
|
|
4
|
+
// SHADOW-FIRST: 429 enforcement is gated behind CORTEX_RATE_LIMIT_ENFORCE.
|
|
5
|
+
// While OFF (default), the limiters observe + count requests that WOULD be
|
|
6
|
+
// blocked but never touch the response — live behaviour is byte-identical.
|
|
7
|
+
import { Counter, Gauge } from 'prom-client';
|
|
8
|
+
import { metricsRegistry } from '../config/metrics.mjs';
|
|
9
|
+
import { logger } from '../utils/logger.mjs';
|
|
10
|
+
import { resolveRateTier } from './rate-tier.mjs';
|
|
2
11
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* (standard JWT structure).
|
|
12
|
+
* Environment variable gating 429 enforcement. When set to `true`/`1`, the
|
|
13
|
+
* limiters block over-limit requests with HTTP 429. Unset or any other value
|
|
14
|
+
* keeps them in SHADOW mode (observe + count + always proceed).
|
|
7
15
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export const CORTEX_RATE_LIMIT_ENFORCE_ENV = 'CORTEX_RATE_LIMIT_ENFORCE';
|
|
17
|
+
/**
|
|
18
|
+
* Whether rate-limit 429 enforcement is ON. Read fresh on each request (a cheap
|
|
19
|
+
* env read) so the shadow→enforce graduation can be flipped operationally
|
|
20
|
+
* without a restart. Defaults to OFF (shadow) for any unset/unrecognised value.
|
|
21
|
+
*
|
|
22
|
+
* @param env - Environment source (defaults to `process.env`); injectable for tests.
|
|
23
|
+
* @returns `true` only when the flag is explicitly `true`/`1`.
|
|
24
|
+
*/
|
|
25
|
+
export function isRateLimitEnforced(env = process.env) {
|
|
26
|
+
const raw = (env[CORTEX_RATE_LIMIT_ENFORCE_ENV] ?? '').trim().toLowerCase();
|
|
27
|
+
return raw === 'true' || raw === '1';
|
|
19
28
|
}
|
|
20
29
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
30
|
+
* Counts requests that rate limiting WOULD block (HTTP 429) while in SHADOW
|
|
31
|
+
* mode. This is the signal proving whether flipping
|
|
32
|
+
* `CORTEX_RATE_LIMIT_ENFORCE` to enforce would reject legitimate traffic.
|
|
33
|
+
* Labelled by the `limiter` name (`graphql`/`auth`) and the request `tier`
|
|
34
|
+
* (`auth`/`anon`). Real enforce-mode denials count on
|
|
35
|
+
* {@link cortexRateLimitBlockedTotal} instead, keeping the observed-only and
|
|
36
|
+
* actually-blocked series distinct (audit B01-backend-legacy-14).
|
|
37
|
+
*/
|
|
38
|
+
export const cortexRateLimitWouldBlockTotal = new Counter({
|
|
39
|
+
name: 'cortex_rate_limit_would_block_total',
|
|
40
|
+
help: 'Requests rate limiting would block (429), by limiter and tier (shadow mode only; real denials count on cortex_rate_limit_blocked_total)',
|
|
41
|
+
labelNames: ['limiter', 'tier'],
|
|
42
|
+
registers: [metricsRegistry],
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* Counts requests actually blocked with HTTP 429 in ENFORCE mode. Kept as a
|
|
46
|
+
* separate series from {@link cortexRateLimitWouldBlockTotal} so shadow
|
|
47
|
+
* observations and real blocks never mix (audit B01-backend-legacy-14).
|
|
48
|
+
*/
|
|
49
|
+
export const cortexRateLimitBlockedTotal = new Counter({
|
|
50
|
+
name: 'cortex_rate_limit_blocked_total',
|
|
51
|
+
help: 'Requests actually blocked with HTTP 429, by limiter and tier (enforce mode only)',
|
|
52
|
+
labelNames: ['limiter', 'tier'],
|
|
53
|
+
registers: [metricsRegistry],
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Tracked bucket-key cardinality per limiter store. Bounds memory ahead of the
|
|
57
|
+
* trust-proxy fix, after which the identifier derives from X-Forwarded-For and
|
|
58
|
+
* an attacker rotating spoofed values could otherwise mint unbounded entries
|
|
59
|
+
* (audit B01-backend-legacy-12).
|
|
60
|
+
*/
|
|
61
|
+
export const cortexRateLimitStoreKeys = new Gauge({
|
|
62
|
+
name: 'cortex_rate_limit_store_keys',
|
|
63
|
+
help: 'Current tracked bucket-key cardinality per limiter store',
|
|
64
|
+
labelNames: ['limiter'],
|
|
65
|
+
registers: [metricsRegistry],
|
|
66
|
+
});
|
|
67
|
+
/** Sweep cadence for expired bucket entries. */
|
|
68
|
+
const STORE_SWEEP_INTERVAL_MS = 60_000;
|
|
69
|
+
/**
|
|
70
|
+
* Default cap on distinct bucket keys per limiter store. Past the cap, new
|
|
71
|
+
* identifiers aggregate into a shared per-tier overflow bucket instead of
|
|
72
|
+
* allocating — bounding memory under identifier-rotation abuse.
|
|
73
|
+
*/
|
|
74
|
+
const DEFAULT_MAX_TRACKED_KEYS = 100_000;
|
|
75
|
+
/** Bucket-key prefix used once the store cardinality cap is reached. */
|
|
76
|
+
const OVERFLOW_KEY_PREFIX = 'overflow';
|
|
77
|
+
/**
|
|
78
|
+
* Creates an in-memory rate limiter middleware with separate limits for
|
|
79
|
+
* verified (auth) and anonymous requests.
|
|
23
80
|
*
|
|
24
|
-
*
|
|
81
|
+
* Bucketing: verified callers are keyed per principal (`sub:<sub>`); anonymous
|
|
82
|
+
* callers are keyed by client IP, which resolves correctly behind the LB only
|
|
83
|
+
* with `trust proxy` configured in server.ts (audit B01-backend-legacy-02).
|
|
84
|
+
* The `auth` tier requires actual offline verification — a fabricated
|
|
85
|
+
* JWT-shaped header no longer earns the higher budget (B01-backend-legacy-09).
|
|
86
|
+
* CORS preflights (`OPTIONS`) are never counted (B01-backend-legacy-11).
|
|
87
|
+
*
|
|
88
|
+
* Response headers (enforce mode only, when standardHeaders is enabled):
|
|
25
89
|
* X-RateLimit-Limit - maximum requests allowed in the current window
|
|
26
90
|
* X-RateLimit-Remaining - requests remaining in the current window
|
|
27
91
|
* X-RateLimit-Reset - seconds until the current window resets
|
|
28
92
|
* Retry-After - seconds to wait before retrying (only on 429)
|
|
29
93
|
*
|
|
30
|
-
* @param config - Rate limit configuration
|
|
31
|
-
* @returns Express middleware function
|
|
94
|
+
* @param config - Rate limit configuration.
|
|
95
|
+
* @returns Express middleware function.
|
|
32
96
|
*/
|
|
33
|
-
function createRateLimiter(config) {
|
|
34
|
-
const store =
|
|
35
|
-
|
|
97
|
+
export function createRateLimiter(config) {
|
|
98
|
+
const store = new Map();
|
|
99
|
+
const maxTrackedKeys = config.maxTrackedKeys ?? DEFAULT_MAX_TRACKED_KEYS;
|
|
100
|
+
// Sweep expired entries every minute. `unref()` so an importing process
|
|
101
|
+
// (tests, one-off scripts) is never kept alive by the sweeper
|
|
102
|
+
// (audit B01-backend-legacy-12).
|
|
36
103
|
setInterval(() => {
|
|
37
104
|
const now = Date.now();
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
delete
|
|
105
|
+
for (const [key, entry] of store) {
|
|
106
|
+
if (entry.resetTime < now) {
|
|
107
|
+
store.delete(key);
|
|
41
108
|
}
|
|
42
|
-
}
|
|
43
|
-
|
|
109
|
+
}
|
|
110
|
+
cortexRateLimitStoreKeys.set({ limiter: config.name }, store.size);
|
|
111
|
+
}, STORE_SWEEP_INTERVAL_MS).unref();
|
|
44
112
|
return (req, res, next) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
113
|
+
// CORS preflights carry no credentials and must not consume rate budget
|
|
114
|
+
// (audit B01-backend-legacy-11).
|
|
115
|
+
if (req.method === 'OPTIONS') {
|
|
116
|
+
next();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { tier, principalKey } = resolveRateTier(req);
|
|
120
|
+
const identifier = principalKey ?? req.ip ?? req.socket.remoteAddress ?? 'unknown';
|
|
121
|
+
const effectiveMax = tier === 'auth' ? config.maxAuthenticated : config.maxUnauthenticated;
|
|
122
|
+
let storeKey = `${identifier}:${tier}`;
|
|
51
123
|
const now = Date.now();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
124
|
+
// Cardinality cap: aggregate novel identifiers into a shared per-tier
|
|
125
|
+
// overflow bucket rather than growing without bound
|
|
126
|
+
// (audit B01-backend-legacy-12).
|
|
127
|
+
if (!store.has(storeKey) && store.size >= maxTrackedKeys) {
|
|
128
|
+
storeKey = `${OVERFLOW_KEY_PREFIX}:${tier}`;
|
|
129
|
+
}
|
|
130
|
+
const existing = store.get(storeKey);
|
|
131
|
+
let current;
|
|
132
|
+
if (!existing || existing.resetTime < now) {
|
|
133
|
+
current = { count: 1, resetTime: now + config.windowMs };
|
|
134
|
+
store.set(storeKey, current);
|
|
57
135
|
}
|
|
58
136
|
else {
|
|
59
|
-
|
|
137
|
+
existing.count += 1;
|
|
138
|
+
current = existing;
|
|
60
139
|
}
|
|
61
|
-
const current = store[storeKey];
|
|
62
140
|
const remaining = Math.max(0, effectiveMax - current.count);
|
|
63
141
|
const resetSeconds = Math.ceil((current.resetTime - now) / 1000);
|
|
64
|
-
|
|
142
|
+
const overLimit = current.count > effectiveMax;
|
|
143
|
+
// SHADOW mode (default): observe + count over-limit requests but NEVER touch
|
|
144
|
+
// the response — no rate-limit headers, no 429. This keeps live behaviour
|
|
145
|
+
// byte-identical until CORTEX_RATE_LIMIT_ENFORCE is flipped on.
|
|
146
|
+
if (!isRateLimitEnforced()) {
|
|
147
|
+
if (overLimit) {
|
|
148
|
+
cortexRateLimitWouldBlockTotal.inc({ limiter: config.name, tier });
|
|
149
|
+
// Log only the FIRST over-limit request per (bucket, window) — the
|
|
150
|
+
// counter carries per-request cardinality; unthrottled logging floods
|
|
151
|
+
// Cloud Logging and buries real warnings (audit B01-backend-legacy-10).
|
|
152
|
+
if (current.count === effectiveMax + 1) {
|
|
153
|
+
logger.warn('[cortex-rate-limit] shadow would-block (allowing)', {
|
|
154
|
+
limiter: config.name,
|
|
155
|
+
identifier,
|
|
156
|
+
tier,
|
|
157
|
+
count: current.count,
|
|
158
|
+
limit: effectiveMax,
|
|
159
|
+
resetSeconds,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
next();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// ENFORCE mode: emit the informational rate-limit headers and block
|
|
167
|
+
// over-limit requests with HTTP 429.
|
|
65
168
|
if (config.standardHeaders !== false) {
|
|
66
169
|
res.setHeader('X-RateLimit-Limit', effectiveMax.toString());
|
|
67
170
|
res.setHeader('X-RateLimit-Remaining', remaining.toString());
|
|
68
171
|
res.setHeader('X-RateLimit-Reset', resetSeconds.toString());
|
|
69
172
|
}
|
|
70
|
-
if (
|
|
173
|
+
if (overLimit) {
|
|
71
174
|
// Include Retry-After header on 429 responses (RFC 6585 / RFC 7231)
|
|
72
175
|
res.setHeader('Retry-After', resetSeconds.toString());
|
|
176
|
+
cortexRateLimitBlockedTotal.inc({ limiter: config.name, tier });
|
|
73
177
|
res.status(429).json(config.message);
|
|
74
178
|
return;
|
|
75
179
|
}
|
|
@@ -79,10 +183,11 @@ function createRateLimiter(config) {
|
|
|
79
183
|
/**
|
|
80
184
|
* Rate limiter for GraphQL endpoint.
|
|
81
185
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
186
|
+
* Verified requests: 1000 requests per 15 minutes (configurable via RATE_LIMIT_MAX)
|
|
187
|
+
* Anonymous requests: 200 requests per 15 minutes (configurable via RATE_LIMIT_MAX_UNAUTH)
|
|
84
188
|
*/
|
|
85
189
|
export const graphqlRateLimiter = createRateLimiter({
|
|
190
|
+
name: 'graphql',
|
|
86
191
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
87
192
|
maxAuthenticated: parseInt(process.env.RATE_LIMIT_MAX || '1000', 10),
|
|
88
193
|
maxUnauthenticated: parseInt(process.env.RATE_LIMIT_MAX_UNAUTH || '200', 10),
|
|
@@ -95,10 +200,11 @@ export const graphqlRateLimiter = createRateLimiter({
|
|
|
95
200
|
/**
|
|
96
201
|
* Rate limiter for authentication endpoints.
|
|
97
202
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
203
|
+
* Verified requests: 50 requests per 15 minutes
|
|
204
|
+
* Anonymous requests: 20 requests per 15 minutes
|
|
100
205
|
*/
|
|
101
206
|
export const authRateLimiter = createRateLimiter({
|
|
207
|
+
name: 'auth',
|
|
102
208
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
103
209
|
maxAuthenticated: 50,
|
|
104
210
|
maxUnauthenticated: 20,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit tier resolution (audit B01-backend-legacy-09).
|
|
3
|
+
*
|
|
4
|
+
* The rate limiter previously granted the 5x-higher authenticated tier to any
|
|
5
|
+
* request carrying a 3-dot-segment `Bearer` header — a trivially fabricated
|
|
6
|
+
* shape (`Bearer a.b.c`). This module keys the tier off ACTUAL verification
|
|
7
|
+
* instead, using the two offline-verifiable credential classes:
|
|
8
|
+
*
|
|
9
|
+
* 1. `SERVER_AUTH_TOKEN` exact match (the engine / server-to-server path).
|
|
10
|
+
* 2. App-issued HS256 JWTs verified against the shared `jwtSecret`.
|
|
11
|
+
*
|
|
12
|
+
* Google ID tokens (RS256, JWKS-verified) cannot be verified synchronously
|
|
13
|
+
* without a network dependency in the request hot path; they are deliberately
|
|
14
|
+
* tiered as `anon`. This is a metrics-only inaccuracy while the limiter is in
|
|
15
|
+
* shadow mode — before any enforce graduation, either extend this resolver
|
|
16
|
+
* with an async JWKS-backed path or raise the anon ceiling to cover browser
|
|
17
|
+
* traffic (tracked in the B01-02 graduation checklist).
|
|
18
|
+
*
|
|
19
|
+
* Verified results are memoised in a bounded, TTL'd cache keyed by the token's
|
|
20
|
+
* SHA-256 (raw tokens are never retained) so the per-request cost is one hash.
|
|
21
|
+
*/
|
|
22
|
+
import type { Request } from 'express';
|
|
23
|
+
/** Rate-limit tier: `auth` = verified caller budget, `anon` = default budget. */
|
|
24
|
+
export type RateTier = 'auth' | 'anon';
|
|
25
|
+
/**
|
|
26
|
+
* Outcome of tier resolution for one request.
|
|
27
|
+
*/
|
|
28
|
+
export interface RateTierResolution {
|
|
29
|
+
/** The tier the request's rate budget is drawn from. */
|
|
30
|
+
readonly tier: RateTier;
|
|
31
|
+
/**
|
|
32
|
+
* Stable per-principal bucket key (`sub:<sub>`) for verified callers, so
|
|
33
|
+
* authenticated traffic is bucketed per principal rather than per IP
|
|
34
|
+
* (audit B01-backend-legacy-02). `null` for anonymous callers — the limiter
|
|
35
|
+
* falls back to the client IP.
|
|
36
|
+
*/
|
|
37
|
+
readonly principalKey: string | null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Clears the memoised token-verdict cache. Test-only.
|
|
41
|
+
*
|
|
42
|
+
* @internal exported for testing
|
|
43
|
+
*/
|
|
44
|
+
export declare function _resetRateTierCacheForTests(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the rate-limit tier and principal bucket key for a request.
|
|
47
|
+
*
|
|
48
|
+
* Cheap on the hot path: one SHA-256 over the token plus a bounded map lookup;
|
|
49
|
+
* the HMAC verification runs at most once per token per TTL window.
|
|
50
|
+
*
|
|
51
|
+
* @param req - Incoming Express request.
|
|
52
|
+
* @returns The request's {@link RateTierResolution}; `anon` when no `Bearer`
|
|
53
|
+
* token is present or the token cannot be verified offline.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveRateTier(req: Request): RateTierResolution;
|
|
56
|
+
//# sourceMappingURL=rate-tier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-tier.d.ts","sourceRoot":"","sources":["../../../src/middleware/rate-tier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAGvC,iFAAiF;AACjF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,wDAAwD;IACxD,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAiBD;;;;GAIG;AACH,wBAAgB,2BAA2B,IAAI,IAAI,CAElD;AAiCD;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,GAAG,kBAAkB,CA6BhE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-tier.js","sourceRoot":"","sources":["../../../src/middleware/rate-tier.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limit tier resolution (audit B01-backend-legacy-09).
|
|
3
|
+
*
|
|
4
|
+
* The rate limiter previously granted the 5x-higher authenticated tier to any
|
|
5
|
+
* request carrying a 3-dot-segment `Bearer` header — a trivially fabricated
|
|
6
|
+
* shape (`Bearer a.b.c`). This module keys the tier off ACTUAL verification
|
|
7
|
+
* instead, using the two offline-verifiable credential classes:
|
|
8
|
+
*
|
|
9
|
+
* 1. `SERVER_AUTH_TOKEN` exact match (the engine / server-to-server path).
|
|
10
|
+
* 2. App-issued HS256 JWTs verified against the shared `jwtSecret`.
|
|
11
|
+
*
|
|
12
|
+
* Google ID tokens (RS256, JWKS-verified) cannot be verified synchronously
|
|
13
|
+
* without a network dependency in the request hot path; they are deliberately
|
|
14
|
+
* tiered as `anon`. This is a metrics-only inaccuracy while the limiter is in
|
|
15
|
+
* shadow mode — before any enforce graduation, either extend this resolver
|
|
16
|
+
* with an async JWKS-backed path or raise the anon ceiling to cover browser
|
|
17
|
+
* traffic (tracked in the B01-02 graduation checklist).
|
|
18
|
+
*
|
|
19
|
+
* Verified results are memoised in a bounded, TTL'd cache keyed by the token's
|
|
20
|
+
* SHA-256 (raw tokens are never retained) so the per-request cost is one hash.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'crypto';
|
|
23
|
+
import jwt from 'jsonwebtoken';
|
|
24
|
+
import { jwtSecret } from '../config/jwtConfig.mjs';
|
|
25
|
+
/** Bound on memoised token verdicts; oldest entries evicted first. */
|
|
26
|
+
const TIER_CACHE_MAX_ENTRIES = 2048;
|
|
27
|
+
/** Memoised verdicts expire after one rate-limit window (15 minutes). */
|
|
28
|
+
const TIER_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
29
|
+
const ANON_RESOLUTION = { tier: 'anon', principalKey: null };
|
|
30
|
+
const tierCache = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* Clears the memoised token-verdict cache. Test-only.
|
|
33
|
+
*
|
|
34
|
+
* @internal exported for testing
|
|
35
|
+
*/
|
|
36
|
+
export function _resetRateTierCacheForTests() {
|
|
37
|
+
tierCache.clear();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verifies a bearer token offline and classifies its rate tier.
|
|
41
|
+
*
|
|
42
|
+
* @param token - Raw bearer token value (after the `Bearer ` prefix).
|
|
43
|
+
* @returns The resolved tier; `anon` for anything that fails verification.
|
|
44
|
+
*/
|
|
45
|
+
function verifyTier(token) {
|
|
46
|
+
const serverAuthToken = process.env.SERVER_AUTH_TOKEN;
|
|
47
|
+
if (typeof serverAuthToken === 'string' &&
|
|
48
|
+
serverAuthToken.length > 0 &&
|
|
49
|
+
token === serverAuthToken) {
|
|
50
|
+
return { tier: 'auth', principalKey: 'sub:server' };
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
// HS256 pinned for the same alg-confusion reasons as the token-verifier.
|
|
54
|
+
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] });
|
|
55
|
+
if (typeof payload !== 'string' && typeof payload.sub === 'string') {
|
|
56
|
+
return { tier: 'auth', principalKey: `sub:${payload.sub}` };
|
|
57
|
+
}
|
|
58
|
+
return ANON_RESOLUTION;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Unverifiable (bad signature, expired, RS256 Google ID token, garbage
|
|
62
|
+
// shape) — treated as anonymous for tier purposes. Never throws upward:
|
|
63
|
+
// the limiter must not be able to fail a request.
|
|
64
|
+
return ANON_RESOLUTION;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves the rate-limit tier and principal bucket key for a request.
|
|
69
|
+
*
|
|
70
|
+
* Cheap on the hot path: one SHA-256 over the token plus a bounded map lookup;
|
|
71
|
+
* the HMAC verification runs at most once per token per TTL window.
|
|
72
|
+
*
|
|
73
|
+
* @param req - Incoming Express request.
|
|
74
|
+
* @returns The request's {@link RateTierResolution}; `anon` when no `Bearer`
|
|
75
|
+
* token is present or the token cannot be verified offline.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveRateTier(req) {
|
|
78
|
+
const authHeader = req.headers.authorization ?? '';
|
|
79
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
80
|
+
return ANON_RESOLUTION;
|
|
81
|
+
}
|
|
82
|
+
const token = authHeader.slice('Bearer '.length).trim();
|
|
83
|
+
if (token.length === 0) {
|
|
84
|
+
return ANON_RESOLUTION;
|
|
85
|
+
}
|
|
86
|
+
const cacheKey = createHash('sha256').update(token).digest('hex');
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const cached = tierCache.get(cacheKey);
|
|
89
|
+
if (cached && cached.expiresAt > now) {
|
|
90
|
+
return cached.resolution;
|
|
91
|
+
}
|
|
92
|
+
const resolution = verifyTier(token);
|
|
93
|
+
if (tierCache.size >= TIER_CACHE_MAX_ENTRIES) {
|
|
94
|
+
// Map preserves insertion order — evict the oldest entry.
|
|
95
|
+
const oldestKey = tierCache.keys().next().value;
|
|
96
|
+
if (oldestKey !== undefined) {
|
|
97
|
+
tierCache.delete(oldestKey);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
tierCache.set(cacheKey, { resolution, expiresAt: now + TIER_CACHE_TTL_MS });
|
|
101
|
+
return resolution;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=rate-tier.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adaptic/backend-legacy",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.998",
|
|
4
4
|
"description": "Backend executable CRUD functions with dynamic variables construction, and type definitions for the Adaptic AI platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "index.d.ts",
|