@rapidrest/service-core 1.5.0 → 1.7.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.
@@ -0,0 +1,87 @@
1
+ import type { HttpRequest } from "./http/index.js";
2
+ /**
3
+ * The event type recorded via `@rapidrest/core`'s `EventUtils.record()` when a rate limit is exceeded (either
4
+ * the per-identifier or the per-source-IP layer - see `layer` on the event). A brute-force/abuse signal
5
+ * covering every rate-limited route from one call site, rather than instrumenting each caller's individual
6
+ * failure branches.
7
+ */
8
+ export declare const RATELIMIT_EXCEEDED_EVENT = "ratelimit.exceeded";
9
+ /**
10
+ * Configuration options for the source-IP counter layered alongside the primary, identifier-keyed one. An
11
+ * identifier (username/email/etc.) alone can't catch an attacker who rotates identifiers against a single
12
+ * source, and is itself something an attacker and a victim can share (e.g. a common username) - keying a
13
+ * second, independent counter on the caller's IP address closes that gap without weakening the existing
14
+ * per-identifier throttle. Deliberately more permissive by default than the per-identifier limit, since a
15
+ * single IP can legitimately represent many users behind NAT/a corporate proxy.
16
+ */
17
+ export interface IPRateLimiterConfig {
18
+ /** Set to `false` to disable the per-IP counter. Default is `true`. */
19
+ enabled?: boolean;
20
+ /** The maximum number of attempts allowed from a single IP within `windowSeconds`. Default is `100`. */
21
+ maxAttempts?: number;
22
+ /** The length of the window, in seconds, that `maxAttempts` applies to. Default is `300` (5 minutes). */
23
+ windowSeconds?: number;
24
+ }
25
+ /**
26
+ * Configuration options for `RateLimiter`, read from the `rateLimit` path of the application configuration.
27
+ */
28
+ export interface RateLimiterConfig {
29
+ /** Set to `false` to disable rate limiting entirely. Default is `true`. */
30
+ enabled?: boolean;
31
+ /** The maximum number of attempts allowed within `windowSeconds` before being rejected. Default is `5`. */
32
+ maxAttempts?: number;
33
+ /** The length of the sliding window, in seconds, that `maxAttempts` applies to. Default is `300` (5 minutes). */
34
+ windowSeconds?: number;
35
+ /** Configuration for the additional, independent per-source-IP counter. */
36
+ ip?: IPRateLimiterConfig;
37
+ }
38
+ /**
39
+ * A simple attempt-count rate limiter used to defend expensive or guessable endpoints (credential
40
+ * verification, one-time-code redemption, enumeration-prone lookups, etc.) against brute-force/abuse
41
+ * attacks. Backed by Redis when a `cache` connection is configured (so the count is shared across server
42
+ * instances), and falls back to an in-process in-memory counter otherwise.
43
+ *
44
+ * @author Jean-Philippe Steinmetz
45
+ */
46
+ export declare class RateLimiter {
47
+ protected config: RateLimiterConfig;
48
+ protected trustedProxies: string[];
49
+ private connMgr?;
50
+ /** In-memory fallback store, used both when no `cache` connection is configured and when the connected
51
+ * Redis server is too old to support `INCREX` (see `redisIncrexUnsupported` below). */
52
+ private readonly memoryStore;
53
+ protected logger: any;
54
+ /** Set once `incrementRedis()` learns the connected server rejects `INCREX` as unknown (`INCREX` is a
55
+ * Redis 8.8+ command - not yet supported by Redis Software, Redis Cloud, or any Redis-protocol-compatible
56
+ * server that hasn't caught up, e.g. Memurai on Windows) - subsequent calls skip straight to the in-memory
57
+ * fallback instead of paying for (and logging) a failed round trip on every single request. This does mean
58
+ * the per-identifier/per-IP counters stop being atomic across multiple server instances sharing one Redis
59
+ * for as long as this process runs, which is a real, deliberate degradation - see the class doc comment -
60
+ * but a working, non-atomic rate limiter is better than every rate-limited request 500ing.
61
+ */
62
+ private redisIncrexUnsupported;
63
+ private get cacheClient();
64
+ /**
65
+ * Records an attempt for the given identifier and throws once it has exceeded the configured
66
+ * `maxAttempts` within `windowSeconds`. A no-op when rate limiting is disabled via config.
67
+ *
68
+ * When `req` is supplied, an independent, more permissive counter keyed on the caller's source IP is
69
+ * also checked and incremented (see `IPRateLimiterConfig`) - this catches an attacker who rotates
70
+ * identifiers against a single source, which the identifier-keyed counter alone cannot.
71
+ * @param identifier A value that scopes the counter to a particular caller/target (e.g. a claimed username
72
+ * or email). Callers should be aware that an identifier alone can be shared by an attacker and a victim
73
+ * (e.g. a username), so this limits attempts against that identifier globally rather than per-source.
74
+ * @param req The source HTTP request, used to derive the caller's IP for the additional per-IP counter.
75
+ * Omit to check only the identifier-keyed counter (e.g. when no request is available).
76
+ */
77
+ checkAndIncrement(identifier: string, req?: HttpRequest): Promise<void>;
78
+ /**
79
+ * Increments the counter for `key` and throws once it exceeds `maxAttempts` within `windowSeconds`.
80
+ * Shared by the identifier-keyed and IP-keyed counters in `checkAndIncrement()` - the two are otherwise
81
+ * entirely independent (different keys, different limits), this just avoids duplicating the
82
+ * increment-then-compare logic between them.
83
+ */
84
+ private enforceLimit;
85
+ private incrementRedis;
86
+ private incrementMemory;
87
+ }
@@ -135,6 +135,16 @@ export declare function Protect(acl?: PartialACL): (target: any, propertyKey?: s
135
135
  * @param name THe name of the query parameter whose value will be injected.
136
136
  */
137
137
  export declare function Query(name?: string | undefined): (target: any, propertyKey: string, index: number) => void;
138
+ /**
139
+ * Indicates that the endpoint should have rate limiting applied for all incoming requests. This can be applied
140
+ * at the endpoint function level or the route class level. When applying at the class level, all defined
141
+ * endpoints in the class will have rate limiting applied.
142
+ *
143
+ * When performing rate limiting, the method and path of the request (without the query portion) is used as the identifier
144
+ * when calling `RateLimiter.checkAndIncrement()` such that a `GET /path/to/my/route` request has the effect of calling
145
+ * `RateLimiter.checkAndIncrement('GET /path/to/my/route', req)` directly.
146
+ */
147
+ export declare function RateLimit(): (target: any, propertyKey?: string, descriptor?: PropertyDescriptor) => void;
138
148
  /**
139
149
  * Injects the HTTP request object as the value of the decorated argument.
140
150
  */
@@ -7,6 +7,7 @@ export * from "./NetUtils.js";
7
7
  export * from "./NotificationUtils.js";
8
8
  export * from "./ObjectFactory.js";
9
9
  export * from "./OpenApiSpec.js";
10
+ export * from "./RateLimiter.js";
10
11
  export * from "./Server.js";
11
12
  export * from "./Types.js";
12
13
  export * from "./auth/index.js";
@@ -1,4 +1,5 @@
1
1
  import type { RequestHandler } from "../http/types.js";
2
+ import { RateLimiter } from "../RateLimiter.js";
2
3
  /**
3
4
  * Provides a set of utilities for converting Route classes to HTTP middleware.
4
5
  *
@@ -9,12 +10,18 @@ export declare class RouteUtils {
9
10
  private apiSpec;
10
11
  private authMiddleware?;
11
12
  private logger?;
13
+ protected rateLimiter?: RateLimiter;
12
14
  protected trustedRoles: string[];
13
15
  /**
14
16
  * Creates a middleware function that checks if the user has elevated privileges and if not
15
17
  * throws the `AUTH_REQUIRES_ELEVATION` error.
16
18
  */
17
19
  checkElevation(lastStart?: number): RequestHandler;
20
+ /**
21
+ * Creates a middleware function that performs rate limiting on the request where the identifier
22
+ * used to identify the resource is the request `<method> <path>`.
23
+ */
24
+ checkRateLimiter(): RequestHandler;
18
25
  /**
19
26
  * Creates a middleware function that verifies the incoming request is from a valid user with at least
20
27
  * one of the specified roles.
@@ -153,17 +153,41 @@ export declare class ACLUtils {
153
153
  */
154
154
  saveACLs(acls: AccessControlList[]): Promise<void>;
155
155
  /**
156
- * Retrieves the most specific record in the provided ACL associated with the provided user: an exact
157
- * uid/anonymous match beats a role match, which beats a wildcard (`.*`/`*`) match regardless of where
156
+ * Retrieves the most specific record in the provided ACL associated with the provided user. An exact
157
+ * uid/anonymous match beats a role match, which beats a wildcard (`.*`/`*`) match, regardless of where
158
158
  * each record sits in `acl.records`. Without this, a wildcard grant authored before a specific record
159
- * (a natural authoring order) would silently shadow that more specific record. Only falls back to the
160
- * parent ACL when nothing in this ACL's own records matches at all.
159
+ * (a natural authoring order) would silently shadow that more specific record. If no record can be found
160
+ * in the ACL provided, searches each parent for a matching record.
161
+ *
162
+ * The depth of the search can be limited by setting the `options.maxDepth` option to a non-negative value. A value
163
+ * of `0` only searches the provided ACL, a value of `1` searches the provided ACL and its direct parent, a value of
164
+ * `2` searches the ACL, it's parent and grand-parent, and so on. A value of `-1` (the default) means no limit;
165
+ * the entire parent chain is searched.
166
+ *
167
+ * Since records support regex patterns, it is also possible to cap the match quality (**specificity**) via the
168
+ * `options.specificity` option. This is a ceiling, not an exact requirement: setting `options.specificity` to
169
+ * `exact` only matches the user's UID exactly. A value of `role` matches the UID or a user's role (preferring
170
+ * the UID match). A value of `wildcard` matches the UID, role, or any regex pattern (e.g. `.*` or `user*`),
171
+ * preferring the most specific of those that match.
172
+ *
173
+ * The default options are:
174
+ * ```ts
175
+ * {
176
+ * maxDepth: -1 // no limit,
177
+ * specificity: "wildcard"
178
+ * }
179
+ * ```
161
180
  *
162
181
  * @param acl The access control list that will be searched.
163
182
  * @param user The user to find a record for.
183
+ * @param options The set of options to consider
164
184
  * @returns The ACL record associated with the given user if found, otherwise `undefined`.
165
185
  */
166
- getRecord(acl: AccessControlList, user: JWTUser | undefined): ACLRecord | null;
186
+ getRecord(acl: AccessControlList, user: JWTUser | undefined, options?: {
187
+ specificity?: "exact" | "role" | "wildcard";
188
+ maxDepth?: number;
189
+ curDepth?: number;
190
+ }): ACLRecord | null;
167
191
  /**
168
192
  * Attempts to retrieve the parent access control list for the given ACL object.
169
193
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidrest/service-core",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Provides all core functionality for RapidREST based backend services.",
5
5
  "repository": "https://github.com/rapidrest/service-core.git",
6
6
  "author": "RapidREST <rapidrests@gmail.com>",
@@ -21,10 +21,10 @@
21
21
  "test": "vitest run",
22
22
  "test:prod": "yarn lint && vitest run --coverage",
23
23
  "test:watch": "vitest",
24
- "release": "tsx scripts/release.ts"
24
+ "release": "rapidrest release"
25
25
  },
26
26
  "dependencies": {
27
- "axios": "^1.19.0",
27
+ "axios": "^1.20.0",
28
28
  "dayjs": "^1.11.23",
29
29
  "deepmerge": "^4.3.1",
30
30
  "node-schedule": "^2.1.1",
@@ -58,30 +58,31 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@rapidrest/core": "^5.1.0",
62
- "@simplewebauthn/server": "^13.3.2",
63
- "@swc/core": "^1.16.1",
61
+ "@rapidrest/cli": "^1.0.1",
62
+ "@rapidrest/core": "^5.2.0",
63
+ "@simplewebauthn/server": "^14.0.1",
64
+ "@swc/core": "^1.16.2",
64
65
  "@types/better-sqlite3": "^9.6.0",
65
- "@types/bun": "^1.4.0",
66
- "@types/node": "^26.2.0",
66
+ "@types/bun": "^1.4.2",
67
+ "@types/node": "^26.5.0",
67
68
  "@types/node-schedule": "^2.1.8",
68
69
  "@types/rimraf": "^4.0.5",
69
- "@types/semver": "^7",
70
+ "@types/semver": "^7.8.0",
70
71
  "@types/speakeasy": "^2.0.10",
71
72
  "@types/uuid": "^11.0.0",
72
73
  "@types/ws": "^8.18.1",
73
- "@typescript-eslint/eslint-plugin": "^8.67.0",
74
- "@typescript-eslint/parser": "^8.67.0",
75
- "@vitest/coverage-v8": "^4.1.11",
74
+ "@typescript-eslint/eslint-plugin": "^8.70.0",
75
+ "@typescript-eslint/parser": "^8.70.0",
76
+ "@vitest/coverage-v8": "^5.0.0",
76
77
  "autocannon": "^8.0.0",
77
- "better-sqlite3": "^12.0.0",
78
- "eslint": "^10.9.0",
78
+ "better-sqlite3": "^13.0.3",
79
+ "eslint": "^10.10.0",
79
80
  "eslint-config-prettier": "^10.1.8",
80
81
  "eslint-plugin-import": "^2.32.0",
81
- "eslint-plugin-jsdoc": "^64.2.1",
82
- "globals": "^17.11.0",
82
+ "eslint-plugin-jsdoc": "^64.3.6",
83
+ "globals": "^17.12.0",
83
84
  "lodash-es": "^4.18.1",
84
- "mongodb": "^7.5.0",
85
+ "mongodb": "^7.6.0",
85
86
  "mongodb-memory-server": "^11.2.0",
86
87
  "nconf": "^0.13.0",
87
88
  "nock": "^14.0.17",
@@ -93,14 +94,15 @@
93
94
  "rimraf": "^6.1.3",
94
95
  "semver": "^7.8.5",
95
96
  "ts-node": "^10.9.2",
96
- "tsx": "^4.23.12",
97
+ "tsx": "^4.23.13",
97
98
  "typedoc": "^0.28.20",
98
- "typedoc-plugin-markdown": "^4.12.0",
99
- "typeorm": "^1.1.0",
99
+ "typedoc-plugin-markdown": "^4.13.0",
100
+ "typeorm": "^1.1.1",
100
101
  "typescript": "^6.0.3",
101
- "unplugin-swc": "^1.5.11",
102
+ "unplugin-swc": "^1.6.0",
102
103
  "uuid": "^14.0.2",
103
- "vitest": "^4.1.11",
104
+ "vite": "^8.2.2",
105
+ "vitest": "^5.0.0",
104
106
  "winston": "^3.19.0",
105
107
  "ws": "^8.21.3"
106
108
  },