@podosoft/podokit 1.4.2 → 1.5.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/deploy-compose-profile.js +2 -2
- package/dist/deploy-compose-render.js +1 -0
- package/dist/deploy-profile.js +2 -2
- package/dist/deploy-render.js +2 -0
- package/dist/deploy-schema.d.ts +7 -0
- package/dist/deploy-schema.js +40 -0
- package/dist/lockfile.js +3 -1
- package/dist/provider.d.ts +1 -0
- package/dist/provider.js +5 -2
- package/dist/templates/fullstack/AGENTS.md +18 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -4
- package/dist/templates/fullstack/apps/api/src/app.ts +6 -3
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +28 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +4 -2
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +17 -6
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +5 -8
- package/dist/templates/fullstack/apps/api/src/core/client-address.spec.ts +82 -0
- package/dist/templates/fullstack/apps/api/src/core/client-address.ts +68 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +30 -0
- package/dist/templates/fullstack/apps/web/Dockerfile +4 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +291 -36
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +196 -41
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +5 -2
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +22 -0
- package/dist/templates/fullstack/dot-env.docker.example +2 -0
- package/dist/templates/fullstack/dot-env.example +17 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.spec.ts +152 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/account-sessions/account-sessions.routes.ts +112 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +18 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +36 -13
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +13 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.test.ts +33 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-sessions.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +62 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/table-toolbar.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/account-sessions.api.spec.ts +55 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +3 -10
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-sessions.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +7 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +25 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +16 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +54 -6
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +8 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.spec.ts +56 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/revoke-other-sessions.ts +45 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +2 -59
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +0 -65
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +82 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +54 -15
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +43 -5
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +61 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +48 -15
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +20 -9
- package/dist/templates/modules/rate-limit/module.manifest.json +6 -9
- package/dist/templates/todo/AGENTS.md +41 -5
- package/package.json +2 -2
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts
CHANGED
|
@@ -9,10 +9,12 @@ import {
|
|
|
9
9
|
RATE_LIMIT_IDENTITY_EXTENSION,
|
|
10
10
|
RateLimitIdentity,
|
|
11
11
|
RateLimitIdentityExtension,
|
|
12
|
+
SessionUsers,
|
|
12
13
|
verifiedSessionToken,
|
|
13
14
|
} from "./rate-limit.identity";
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
// The address the API resolved for the request (see core/client-address.ts).
|
|
17
|
+
const clientAddress = "203.0.113.7";
|
|
16
18
|
const secret = "rate-limit-test-secret-with-at-least-32-characters";
|
|
17
19
|
const sessionToken = "Sx0d8mFqY3KcT7wJpLr2NvBhZa9EgUi4";
|
|
18
20
|
|
|
@@ -63,8 +65,7 @@ describe("RateLimitIdentity", () => {
|
|
|
63
65
|
|
|
64
66
|
const value = await identity.resolve(
|
|
65
67
|
request({ cookie: sessionCookie(signed(sessionToken)) }),
|
|
66
|
-
|
|
67
|
-
proxyConfig,
|
|
68
|
+
clientAddress,
|
|
68
69
|
);
|
|
69
70
|
|
|
70
71
|
expect(value).toMatch(/^session:[a-f0-9]{64}$/);
|
|
@@ -76,25 +77,22 @@ describe("RateLimitIdentity", () => {
|
|
|
76
77
|
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
|
|
77
78
|
const value = signed(sessionToken);
|
|
78
79
|
const resolved = await Promise.all([
|
|
79
|
-
identity.resolve(request({ cookie: sessionCookie(value) }),
|
|
80
|
+
identity.resolve(request({ cookie: sessionCookie(value) }), clientAddress),
|
|
80
81
|
identity.resolve(
|
|
81
82
|
request({ cookie: sessionCookie(value, "__Secure-better-auth.session_token") }),
|
|
82
|
-
|
|
83
|
-
proxyConfig,
|
|
83
|
+
clientAddress,
|
|
84
84
|
),
|
|
85
|
-
identity.resolve(request({ authorization: `Bearer ${value}` }),
|
|
85
|
+
identity.resolve(request({ authorization: `Bearer ${value}` }), clientAddress),
|
|
86
86
|
identity.resolve(
|
|
87
87
|
request({ authorization: `bearer ${encodeURIComponent(value)}` }),
|
|
88
|
-
|
|
89
|
-
proxyConfig,
|
|
88
|
+
clientAddress,
|
|
90
89
|
),
|
|
91
90
|
]);
|
|
92
91
|
expect(new Set(resolved).size).toBe(1);
|
|
93
92
|
|
|
94
93
|
const other = await identity.resolve(
|
|
95
94
|
request({ cookie: sessionCookie(signed("AnotherSessionToken0123456789abcd")) }),
|
|
96
|
-
|
|
97
|
-
proxyConfig,
|
|
95
|
+
clientAddress,
|
|
98
96
|
);
|
|
99
97
|
expect(other).toMatch(/^session:/);
|
|
100
98
|
expect(other).not.toBe(resolved[0]);
|
|
@@ -103,7 +101,7 @@ describe("RateLimitIdentity", () => {
|
|
|
103
101
|
test("counts forged, tampered, and unsigned session values by client address", async () => {
|
|
104
102
|
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret);
|
|
105
103
|
const address = { "x-forwarded-for": "203.0.113.7" };
|
|
106
|
-
const anonymous = await identity.resolve(request(address),
|
|
104
|
+
const anonymous = await identity.resolve(request(address), clientAddress);
|
|
107
105
|
expect(anonymous).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
108
106
|
|
|
109
107
|
const genuine = signed(sessionToken);
|
|
@@ -123,8 +121,7 @@ describe("RateLimitIdentity", () => {
|
|
|
123
121
|
for (const headers of variants) {
|
|
124
122
|
const value = await identity.resolve(
|
|
125
123
|
request({ ...address, ...headers }),
|
|
126
|
-
|
|
127
|
-
proxyConfig,
|
|
124
|
+
clientAddress,
|
|
128
125
|
);
|
|
129
126
|
expect(value).toBe(anonymous);
|
|
130
127
|
}
|
|
@@ -148,8 +145,7 @@ describe("RateLimitIdentity", () => {
|
|
|
148
145
|
for (const raw of ["configured-valid", "external-valid"]) {
|
|
149
146
|
const value = await identity.resolve(
|
|
150
147
|
request({ "x-api-key": raw, cookie }),
|
|
151
|
-
|
|
152
|
-
proxyConfig,
|
|
148
|
+
clientAddress,
|
|
153
149
|
);
|
|
154
150
|
expect(value).toMatch(/^api-key:[a-f0-9]{64}$/);
|
|
155
151
|
expect(value).not.toContain(raw);
|
|
@@ -157,8 +153,7 @@ describe("RateLimitIdentity", () => {
|
|
|
157
153
|
|
|
158
154
|
const invalidKey = await identity.resolve(
|
|
159
155
|
request({ "x-api-key": "invalid", cookie }),
|
|
160
|
-
|
|
161
|
-
proxyConfig,
|
|
156
|
+
clientAddress,
|
|
162
157
|
);
|
|
163
158
|
expect(invalidKey).toMatch(/^session:/);
|
|
164
159
|
});
|
|
@@ -169,17 +164,83 @@ describe("RateLimitIdentity", () => {
|
|
|
169
164
|
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), undefined);
|
|
170
165
|
const value = await identity.resolve(
|
|
171
166
|
request({ "x-forwarded-for": "203.0.113.7", cookie }),
|
|
172
|
-
|
|
173
|
-
proxyConfig,
|
|
167
|
+
clientAddress,
|
|
174
168
|
);
|
|
175
169
|
expect(value).toMatch(/^ip:[a-f0-9]{64}$/);
|
|
176
170
|
expect(value).not.toContain("203.0.113.7");
|
|
177
171
|
expect(
|
|
178
|
-
await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }),
|
|
172
|
+
await identity.resolve(request({ "x-forwarded-for": "203.0.113.7" }), clientAddress),
|
|
179
173
|
).toBe(value);
|
|
180
174
|
});
|
|
181
175
|
});
|
|
182
176
|
|
|
177
|
+
describe("RateLimitIdentity per user", () => {
|
|
178
|
+
const otherToken = "AnotherSessionToken0123456789abcd";
|
|
179
|
+
const thirdToken = "ThirdSessionToken0123456789abcdef";
|
|
180
|
+
const resolveWith = (identity: RateLimitIdentity, headers: Record<string, string>): Promise<string> =>
|
|
181
|
+
identity.resolve(request({ "x-forwarded-for": "203.0.113.7", ...headers }), clientAddress);
|
|
182
|
+
|
|
183
|
+
test("counts every session of a user under one counter once the API resolved them", async () => {
|
|
184
|
+
let now = 1_000;
|
|
185
|
+
const users = new SessionUsers(100, () => now);
|
|
186
|
+
const identity = new RateLimitIdentity(rejectingVerifier(), new ServiceRegistry(), secret, users);
|
|
187
|
+
const first = { cookie: sessionCookie(signed(sessionToken)) };
|
|
188
|
+
const second = { authorization: `Bearer ${signed(otherToken)}` };
|
|
189
|
+
|
|
190
|
+
// Before the API resolves a session, it keeps a counter of its own.
|
|
191
|
+
const unresolved = await resolveWith(identity, first);
|
|
192
|
+
expect(unresolved).toMatch(/^session:[a-f0-9]{64}$/);
|
|
193
|
+
|
|
194
|
+
users.remember(sessionToken, "user-1", new Date(now + 60_000));
|
|
195
|
+
users.remember(otherToken, "user-1", new Date(now + 60_000));
|
|
196
|
+
users.remember(thirdToken, "user-2", new Date(now + 60_000));
|
|
197
|
+
const userCounter = await resolveWith(identity, first);
|
|
198
|
+
expect(userCounter).toMatch(/^user:[a-f0-9]{64}$/);
|
|
199
|
+
expect(userCounter).not.toContain("user-1");
|
|
200
|
+
expect(await resolveWith(identity, second)).toBe(userCounter);
|
|
201
|
+
expect(await resolveWith(identity, { cookie: sessionCookie(signed(thirdToken)) })).not.toBe(userCounter);
|
|
202
|
+
|
|
203
|
+
// The entry lasts until the session expires.
|
|
204
|
+
now += 60_000;
|
|
205
|
+
expect(await resolveWith(identity, first)).toBe(unresolved);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("keeps API keys first and forged sessions on the client address", async () => {
|
|
209
|
+
const users = new SessionUsers();
|
|
210
|
+
users.remember(sessionToken, "user-1", new Date(Date.now() + 60_000));
|
|
211
|
+
const verifier = { isValid: mock((value: string) => value === "configured-valid") } as unknown as ApiKeyVerifier;
|
|
212
|
+
const identity = new RateLimitIdentity(verifier, new ServiceRegistry(), secret, users);
|
|
213
|
+
const cookie = sessionCookie(signed(sessionToken));
|
|
214
|
+
|
|
215
|
+
expect(await resolveWith(identity, { "x-api-key": "configured-valid", cookie })).toMatch(/^api-key:/);
|
|
216
|
+
expect(await resolveWith(identity, { cookie: sessionCookie(tamperedSignature(signed(sessionToken))) }))
|
|
217
|
+
.toBe(await resolveWith(identity, {}));
|
|
218
|
+
expect(await resolveWith(identity, {})).toMatch(/^ip:/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("SessionUsers", () => {
|
|
223
|
+
test("ignores an expired session and forgets the oldest past its bound", () => {
|
|
224
|
+
let now = 1_000;
|
|
225
|
+
const users = new SessionUsers(2, () => now);
|
|
226
|
+
users.remember("expired", "user-0", new Date(now));
|
|
227
|
+
expect(users.userOf("expired")).toBeUndefined();
|
|
228
|
+
|
|
229
|
+
users.remember("a", "user-a", new Date(now + 10_000));
|
|
230
|
+
users.remember("b", "user-b", new Date(now + 10_000));
|
|
231
|
+
users.remember("a", "user-a", new Date(now + 20_000));
|
|
232
|
+
users.remember("c", "user-c", new Date(now + 10_000));
|
|
233
|
+
// "a" was refreshed, so "b" was the oldest.
|
|
234
|
+
expect(users.userOf("a")).toBe("user-a");
|
|
235
|
+
expect(users.userOf("b")).toBeUndefined();
|
|
236
|
+
expect(users.userOf("c")).toBe("user-c");
|
|
237
|
+
|
|
238
|
+
now += 10_000;
|
|
239
|
+
expect(users.userOf("c")).toBeUndefined();
|
|
240
|
+
expect(users.userOf("a")).toBe("user-a");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
183
244
|
describe("verifiedSessionToken", () => {
|
|
184
245
|
test("returns the token only for a matching signature", () => {
|
|
185
246
|
expect(verifiedSessionToken(signed(sessionToken), secret)).toBe(sessionToken);
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
3
3
|
import type { ServiceKey, ServiceRegistry } from "../core/services";
|
|
4
|
-
import {
|
|
5
|
-
clientAddressFromProxy,
|
|
6
|
-
type RateLimitConfig,
|
|
7
|
-
} from "./rate-limit.config";
|
|
8
4
|
|
|
9
5
|
export abstract class RateLimitIdentityExtension {
|
|
10
6
|
abstract validatedApiKeyId(
|
|
@@ -28,7 +24,7 @@ const SESSION_COOKIE_NAMES = [
|
|
|
28
24
|
// accepts only the padded 44-character standard base64 signature.
|
|
29
25
|
const SIGNATURE = /^[A-Za-z0-9+/]{43}=$/;
|
|
30
26
|
|
|
31
|
-
type TrackerKind = "session" | "api-key" | "ip";
|
|
27
|
+
type TrackerKind = "user" | "session" | "api-key" | "ip";
|
|
32
28
|
|
|
33
29
|
function stableDigest(kind: TrackerKind, value: string): string {
|
|
34
30
|
return createHash("sha256")
|
|
@@ -85,18 +81,57 @@ export function verifiedSessionToken(
|
|
|
85
81
|
: undefined;
|
|
86
82
|
}
|
|
87
83
|
|
|
84
|
+
/** The most sessions one API process remembers; the oldest is forgotten first. */
|
|
85
|
+
const MAX_SESSION_USERS = 10_000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The users of the sessions this API process has resolved, so the limiter can
|
|
89
|
+
* count a signed-in caller per user without a session lookup of its own. Each
|
|
90
|
+
* entry lasts until its session expires, and is refreshed whenever the session
|
|
91
|
+
* is resolved again. A signed-out or revoked session keeps its entry: it only
|
|
92
|
+
* decides which counter its requests use, never whether they are allowed.
|
|
93
|
+
*/
|
|
94
|
+
export class SessionUsers {
|
|
95
|
+
private readonly users = new Map<string, { userId: string; expiresAt: number }>();
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly maxEntries = MAX_SESSION_USERS,
|
|
99
|
+
private readonly now: () => number = Date.now,
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
remember(sessionToken: string, userId: string, expiresAt: Date): void {
|
|
103
|
+
const key = stableDigest("session", sessionToken);
|
|
104
|
+
this.users.delete(key);
|
|
105
|
+
if (!(expiresAt.getTime() > this.now())) return;
|
|
106
|
+
this.users.set(key, { userId, expiresAt: expiresAt.getTime() });
|
|
107
|
+
if (this.users.size > this.maxEntries) {
|
|
108
|
+
const oldest = this.users.keys().next().value;
|
|
109
|
+
if (oldest !== undefined) this.users.delete(oldest);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
userOf(sessionToken: string): string | undefined {
|
|
114
|
+
const key = stableDigest("session", sessionToken);
|
|
115
|
+
const entry = this.users.get(key);
|
|
116
|
+
if (!entry) return undefined;
|
|
117
|
+
if (entry.expiresAt <= this.now()) {
|
|
118
|
+
this.users.delete(key);
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return entry.userId;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
88
125
|
export class RateLimitIdentity {
|
|
89
126
|
constructor(
|
|
90
127
|
private readonly configuredApiKeys: ApiKeyVerifier,
|
|
91
128
|
private readonly services: ServiceRegistry,
|
|
92
129
|
private readonly sessionSecret: string | undefined,
|
|
130
|
+
private readonly sessionUsers: SessionUsers = new SessionUsers(),
|
|
93
131
|
) {}
|
|
94
132
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
remoteAddress: string | undefined,
|
|
98
|
-
config: Pick<RateLimitConfig, "proxyHeader" | "trustedProxyHops">,
|
|
99
|
-
): Promise<string> {
|
|
133
|
+
/** clientAddress is the address the API resolved behind its trusted proxies. */
|
|
134
|
+
async resolve(request: Request, clientAddress: string | undefined): Promise<string> {
|
|
100
135
|
const rawApiKey = request.headers.get("x-api-key");
|
|
101
136
|
if (rawApiKey) {
|
|
102
137
|
if (this.configuredApiKeys.isValid(rawApiKey)) return tracker("api-key", rawApiKey);
|
|
@@ -106,15 +141,19 @@ export class RateLimitIdentity {
|
|
|
106
141
|
}
|
|
107
142
|
|
|
108
143
|
const sessionToken = this.sessionToken(request.headers);
|
|
109
|
-
if (sessionToken)
|
|
144
|
+
if (sessionToken) {
|
|
145
|
+
const userId = this.sessionUsers.userOf(sessionToken);
|
|
146
|
+
return userId === undefined ? tracker("session", sessionToken) : tracker("user", userId);
|
|
147
|
+
}
|
|
110
148
|
|
|
111
|
-
|
|
112
|
-
return tracker("ip", clientAddress);
|
|
149
|
+
return tracker("ip", clientAddress ?? "unknown");
|
|
113
150
|
}
|
|
114
151
|
|
|
115
152
|
// Only the signature is checked: resolving the session would cost a database
|
|
116
|
-
// read on every request.
|
|
117
|
-
//
|
|
153
|
+
// read on every request. Once the API has resolved the session for a request,
|
|
154
|
+
// SessionUsers maps it to its user's counter; until then, and for a revoked or
|
|
155
|
+
// expired token, it keeps a counter of its own. A value without a valid
|
|
156
|
+
// signature falls back to the client address.
|
|
118
157
|
private sessionToken(headers: Headers): string | undefined {
|
|
119
158
|
if (!this.sessionSecret) return undefined;
|
|
120
159
|
const cookies = headers.get("cookie");
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import
|
|
3
|
-
import type {
|
|
2
|
+
import { createHmac } from "node:crypto";
|
|
3
|
+
import type { ApiKeyVerifier } from "../api-key/api-key-verifier";
|
|
4
|
+
import { RESOLVED_SESSIONS, ResolvedSessions, ServiceRegistry, type RequestGuardContext } from "../core/services";
|
|
5
|
+
import { RateLimitIdentity, SessionUsers } from "./rate-limit.identity";
|
|
4
6
|
import { CacheRateLimitStorage, RateLimiter, type RateLimitStorage } from "./rate-limit.module";
|
|
5
7
|
import { MemoryCacheStore } from "@podosoft/podokit-runtime";
|
|
6
8
|
|
|
@@ -11,8 +13,6 @@ const config = {
|
|
|
11
13
|
authTtlSeconds: 60,
|
|
12
14
|
authLimit: 20,
|
|
13
15
|
runtimeLimit: 1000,
|
|
14
|
-
trustedProxyHops: 0,
|
|
15
|
-
proxyHeader: "x-forwarded-for",
|
|
16
16
|
storageTimeoutMs: 100,
|
|
17
17
|
unavailableRetryAfterSeconds: 1,
|
|
18
18
|
};
|
|
@@ -26,12 +26,50 @@ function context(path: string, method = "GET"): {
|
|
|
26
26
|
headers,
|
|
27
27
|
value: {
|
|
28
28
|
request: new Request(`http://localhost${path}`, { method }),
|
|
29
|
-
|
|
29
|
+
clientAddress: "127.0.0.1",
|
|
30
30
|
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
describe("RateLimiter per-user budgets", () => {
|
|
36
|
+
const secret = "rate-limit-test-secret-with-at-least-32-characters";
|
|
37
|
+
const signed = (token: string): string =>
|
|
38
|
+
`${token}.${createHmac("sha256", secret).update(token).digest("base64")}`;
|
|
39
|
+
|
|
40
|
+
test("draws a user's sessions from one budget and another user's from its own", async () => {
|
|
41
|
+
const services = new ServiceRegistry();
|
|
42
|
+
services.register(RESOLVED_SESSIONS, new ResolvedSessions());
|
|
43
|
+
const users = new SessionUsers();
|
|
44
|
+
services.resolve(RESOLVED_SESSIONS).listen(({ token, userId, expiresAt }) => users.remember(token, userId, expiresAt));
|
|
45
|
+
const limiter = new RateLimiter(
|
|
46
|
+
config,
|
|
47
|
+
new RateLimitIdentity({ isValid: () => false } as unknown as ApiKeyVerifier, services, secret, users),
|
|
48
|
+
new CacheRateLimitStorage(new MemoryCacheStore()),
|
|
49
|
+
);
|
|
50
|
+
const expiresAt = new Date(Date.now() + 60_000);
|
|
51
|
+
for (const [token, userId] of [["laptop", "user-1"], ["phone", "user-1"], ["other", "user-2"]] as const) {
|
|
52
|
+
services.resolve(RESOLVED_SESSIONS).report({ token, userId, expiresAt });
|
|
53
|
+
}
|
|
54
|
+
const remaining = async (token: string): Promise<string | undefined> => {
|
|
55
|
+
const headers: Record<string, string> = {};
|
|
56
|
+
await limiter.enforce({
|
|
57
|
+
request: new Request("http://localhost/ordinary", { headers: { authorization: `Bearer ${signed(token)}` } }),
|
|
58
|
+
clientAddress: "127.0.0.1",
|
|
59
|
+
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
|
60
|
+
});
|
|
61
|
+
return headers["ratelimit-remaining"];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
expect(await remaining("laptop")).toBe("299");
|
|
65
|
+
expect(await remaining("phone")).toBe("298");
|
|
66
|
+
expect(await remaining("laptop")).toBe("297");
|
|
67
|
+
expect(await remaining("other")).toBe("299");
|
|
68
|
+
// A session the API has not resolved yet keeps a counter of its own.
|
|
69
|
+
expect(await remaining("tablet")).toBe("299");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
35
73
|
describe("RateLimiter", () => {
|
|
36
74
|
test("uses the selected cache fixed-window primitive", async () => {
|
|
37
75
|
let now = 1_000;
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AppException } from "../common/app-exception";
|
|
|
3
3
|
import { API_KEY_VERIFIER } from "../api-key/api-key.module";
|
|
4
4
|
import {
|
|
5
5
|
REQUEST_GUARDS,
|
|
6
|
+
RESOLVED_SESSIONS,
|
|
6
7
|
type PodokitModule,
|
|
7
8
|
type RequestGuardContext,
|
|
8
9
|
type ServiceKey,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
sessionSigningSecret,
|
|
13
14
|
type RateLimitConfig,
|
|
14
15
|
} from "./rate-limit.config";
|
|
15
|
-
import { RateLimitIdentity } from "./rate-limit.identity";
|
|
16
|
+
import { RateLimitIdentity, SessionUsers } from "./rate-limit.identity";
|
|
16
17
|
import { defaultRateLimitRoutes, generalProfile, type RateLimitRoutes } from "./rate-limit.routes";
|
|
17
18
|
|
|
18
19
|
export { RATE_LIMIT_ROUTES } from "@podosoft/podokit-runtime";
|
|
@@ -61,7 +62,7 @@ export class RateLimiter {
|
|
|
61
62
|
const profile = this.routes.resolve(request.method, path) ?? generalProfile(this.config);
|
|
62
63
|
if (profile === "exempt") return;
|
|
63
64
|
try {
|
|
64
|
-
const tracker = await this.identity.resolve(request, context.
|
|
65
|
+
const tracker = await this.identity.resolve(request, context.clientAddress);
|
|
65
66
|
await this.enforceCustom(profile.name, tracker, profile.ttlSeconds, profile.limit, context.setHeader);
|
|
66
67
|
} catch (error) {
|
|
67
68
|
if (error instanceof AppException) throw error;
|
|
@@ -125,12 +126,19 @@ export const RATE_LIMITER = Symbol("rate-limiter") as ServiceKey<RateLimiter>;
|
|
|
125
126
|
export const rateLimitModule: PodokitModule = {
|
|
126
127
|
name: "rate-limit",
|
|
127
128
|
configure: (_env, services): void => {
|
|
129
|
+
// Sessions the auth module resolves for any request teach the limiter their
|
|
130
|
+
// users, so a user's sessions share one counter without another lookup.
|
|
131
|
+
const sessionUsers = new SessionUsers();
|
|
132
|
+
services.resolve(RESOLVED_SESSIONS).listen(({ token, userId, expiresAt }) =>
|
|
133
|
+
sessionUsers.remember(token, userId, expiresAt),
|
|
134
|
+
);
|
|
128
135
|
const limiter = new RateLimiter(
|
|
129
136
|
rateLimitConfig(),
|
|
130
137
|
new RateLimitIdentity(
|
|
131
138
|
services.resolve(API_KEY_VERIFIER),
|
|
132
139
|
services,
|
|
133
140
|
sessionSigningSecret(),
|
|
141
|
+
sessionUsers,
|
|
134
142
|
),
|
|
135
143
|
new CacheRateLimitStorage(services.resolve(CACHE)),
|
|
136
144
|
);
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts
CHANGED
|
@@ -11,8 +11,6 @@ const config = {
|
|
|
11
11
|
authTtlSeconds: 60,
|
|
12
12
|
authLimit: 20,
|
|
13
13
|
runtimeLimit: 1000,
|
|
14
|
-
trustedProxyHops: 0,
|
|
15
|
-
proxyHeader: "x-forwarded-for",
|
|
16
14
|
storageTimeoutMs: 100,
|
|
17
15
|
unavailableRetryAfterSeconds: 1,
|
|
18
16
|
};
|
|
@@ -25,7 +23,7 @@ function context(path: string, method = "GET"): { value: RequestGuardContext; he
|
|
|
25
23
|
headers,
|
|
26
24
|
value: {
|
|
27
25
|
request: new Request(`http://localhost${path}`, { method }),
|
|
28
|
-
|
|
26
|
+
clientAddress: "127.0.0.1",
|
|
29
27
|
setHeader: (name, value) => { headers[name.toLowerCase()] = value; },
|
|
30
28
|
},
|
|
31
29
|
};
|
|
@@ -50,11 +48,49 @@ describe("RateLimitRoutes", () => {
|
|
|
50
48
|
expect(routes.resolve("GET", "/other")).toBeUndefined();
|
|
51
49
|
});
|
|
52
50
|
|
|
51
|
+
test("matches a :name parameter against exactly one non-empty segment", () => {
|
|
52
|
+
const routes = new RateLimitRoutes(60);
|
|
53
|
+
const transfers = { name: "file-transfers", limit: 600 };
|
|
54
|
+
routes.register({ pathPrefix: "/hosts/:hostId/file-transfers", profile: transfers });
|
|
55
|
+
|
|
56
|
+
expect(routes.resolve("PUT", "/hosts/h1/file-transfers/t1/chunks/3")).toMatchObject(transfers);
|
|
57
|
+
expect(routes.resolve("GET", "/hosts/h1/file-transfers")).toMatchObject(transfers);
|
|
58
|
+
expect(routes.resolve("GET", "/hosts/h1/file-transfers/")).toMatchObject(transfers);
|
|
59
|
+
expect(routes.resolve("GET", "/hosts/h1/file-transfers-old")).toBeUndefined();
|
|
60
|
+
expect(routes.resolve("GET", "/hosts/file-transfers")).toBeUndefined();
|
|
61
|
+
expect(routes.resolve("GET", "/hosts//file-transfers")).toBeUndefined();
|
|
62
|
+
expect(routes.resolve("GET", "/hosts/h1/h2/file-transfers")).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("prefers more segments, then a literal segment over a parameter from the left", () => {
|
|
66
|
+
const routes = new RateLimitRoutes(60);
|
|
67
|
+
routes.register({ pathPrefix: "/hosts/:hostId/:section/:id", profile: { name: "deep", limit: 1 } });
|
|
68
|
+
routes.register({ pathPrefix: "/hosts/:hostId/files", profile: { name: "files", limit: 2 } });
|
|
69
|
+
routes.register({ pathPrefix: "/hosts/local/:section", profile: { name: "local", limit: 3 } });
|
|
70
|
+
routes.register({ pathPrefix: "/hosts", profile: { name: "hosts", limit: 4 } });
|
|
71
|
+
|
|
72
|
+
expect(routes.resolve("GET", "/hosts/local/files/1")).toMatchObject({ name: "deep" });
|
|
73
|
+
expect(routes.resolve("GET", "/hosts/local/files")).toMatchObject({ name: "local" });
|
|
74
|
+
expect(routes.resolve("GET", "/hosts/h2/files")).toMatchObject({ name: "files" });
|
|
75
|
+
expect(routes.resolve("GET", "/hosts/h2/logs")).toMatchObject({ name: "hosts" });
|
|
76
|
+
|
|
77
|
+
// Equal specificity falls back to the method, then the latest registration.
|
|
78
|
+
routes.register({ method: "GET", pathPrefix: "/hosts/:a/logs", profile: "exempt" });
|
|
79
|
+
routes.register({ pathPrefix: "/hosts/:b/logs", profile: { name: "logs", limit: 5 } });
|
|
80
|
+
expect(routes.resolve("GET", "/hosts/h2/logs")).toBe("exempt");
|
|
81
|
+
expect(routes.resolve("POST", "/hosts/h2/logs")).toMatchObject({ name: "logs" });
|
|
82
|
+
});
|
|
83
|
+
|
|
53
84
|
test("rejects invalid routes and conflicting budgets under one name", () => {
|
|
54
85
|
const routes = new RateLimitRoutes(60);
|
|
55
86
|
expect(() => routes.register({ pathPrefix: "uploads", profile: "exempt" })).toThrow("absolute path");
|
|
56
87
|
expect(() => routes.register({ pathPrefix: "/a?b", profile: "exempt" })).toThrow("absolute path");
|
|
57
88
|
expect(() => routes.register({ pathPrefix: "/a//b", profile: "exempt" })).toThrow("absolute path");
|
|
89
|
+
expect(() => routes.register({ pathPrefix: "/a/../b", profile: "exempt" })).toThrow('".." segments');
|
|
90
|
+
expect(() => routes.register({ pathPrefix: "/a/*", profile: "exempt" })).toThrow("not wildcards");
|
|
91
|
+
for (const parameter of [":", ":1st", ":host-id", ":a:b"]) {
|
|
92
|
+
expect(() => routes.register({ pathPrefix: `/hosts/${parameter}/x`, profile: "exempt" })).toThrow("parameter");
|
|
93
|
+
}
|
|
58
94
|
expect(() => routes.register({ method: "GET /", pathPrefix: "/a", profile: "exempt" })).toThrow("method");
|
|
59
95
|
expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a:b", limit: 1 } })).toThrow("name");
|
|
60
96
|
expect(() => routes.register({ pathPrefix: "/a", profile: { name: "a", limit: 0 } })).toThrow("limit");
|
|
@@ -83,6 +119,28 @@ describe("RateLimitRoutes", () => {
|
|
|
83
119
|
});
|
|
84
120
|
|
|
85
121
|
describe("RateLimiter route profiles", () => {
|
|
122
|
+
test("advertises the built-in budget of each default route", async () => {
|
|
123
|
+
const limiter = new RateLimiter(
|
|
124
|
+
config,
|
|
125
|
+
{ resolve: async () => "session:test" } as unknown as RateLimitIdentity,
|
|
126
|
+
{ increment: async () => ({ count: 1, retryAfterSeconds: 60 }) },
|
|
127
|
+
);
|
|
128
|
+
const ceiling = async (path: string, method = "GET"): Promise<string | undefined> => {
|
|
129
|
+
const request = context(path, method);
|
|
130
|
+
await limiter.enforce(request.value);
|
|
131
|
+
return request.headers["ratelimit-limit"];
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
expect(await ceiling("/api/auth/get-session")).toBe("1000");
|
|
135
|
+
expect(await ceiling("/site/settings")).toBe("1000");
|
|
136
|
+
expect(await ceiling("/api/auth/admin/list-users")).toBe("300");
|
|
137
|
+
expect(await ceiling("/api/auth/admin/ban-user", "POST")).toBe("20");
|
|
138
|
+
expect(await ceiling("/api/auth/sign-out", "POST")).toBe("20");
|
|
139
|
+
expect(await ceiling("/api/auth/sign-in/email", "POST")).toBe("20");
|
|
140
|
+
expect(await ceiling("/site/settings", "PUT")).toBe("300");
|
|
141
|
+
expect(await ceiling("/account/capabilities")).toBe("300");
|
|
142
|
+
});
|
|
143
|
+
|
|
86
144
|
test("counts a registered route in its own bucket and skips exempt routes", async () => {
|
|
87
145
|
const increment = mock(async (_key: string, _ttlSeconds: number) => ({ count: 1, retryAfterSeconds: 30 }));
|
|
88
146
|
const limiter = new RateLimiter(
|
package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts
CHANGED
|
@@ -8,14 +8,18 @@ import type { RateLimitConfig } from "./rate-limit.config";
|
|
|
8
8
|
/** A budget with its window resolved, or "exempt". */
|
|
9
9
|
export type ResolvedRateLimitProfile = Required<RateLimitProfile> | "exempt";
|
|
10
10
|
|
|
11
|
+
/** A literal path segment, or null for a `:name` parameter. */
|
|
12
|
+
type Segment = string | null;
|
|
13
|
+
|
|
11
14
|
interface RouteEntry {
|
|
12
15
|
method?: string;
|
|
13
|
-
|
|
16
|
+
segments: Segment[];
|
|
14
17
|
profile: ResolvedRateLimitProfile;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const PROFILE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
18
21
|
const METHOD = /^[A-Z]+$/;
|
|
22
|
+
const PARAMETER = /^:[A-Za-z_][A-Za-z0-9_]*$/;
|
|
19
23
|
|
|
20
24
|
function positiveInteger(value: number | undefined, label: string): void {
|
|
21
25
|
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1)) {
|
|
@@ -23,15 +27,47 @@ function positiveInteger(value: number | undefined, label: string): void {
|
|
|
23
27
|
}
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
function
|
|
30
|
+
function parsePrefix(prefix: string): Segment[] {
|
|
27
31
|
if (!prefix.startsWith("/") || /[?#\s]/.test(prefix) || prefix.includes("//")) {
|
|
28
32
|
throw new Error(`Rate limit path prefix must be an absolute path: ${prefix}`);
|
|
29
33
|
}
|
|
30
|
-
|
|
34
|
+
if (prefix.includes("*")) {
|
|
35
|
+
throw new Error(`Rate limit path prefix takes ":name" parameters, not wildcards: ${prefix}`);
|
|
36
|
+
}
|
|
37
|
+
const base = prefix.length > 1 && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
38
|
+
return base === "/"
|
|
39
|
+
? []
|
|
40
|
+
: base.slice(1).split("/").map((segment) => {
|
|
41
|
+
if (segment === "." || segment === "..") {
|
|
42
|
+
throw new Error(`Rate limit path prefix must not contain "." or ".." segments: ${prefix}`);
|
|
43
|
+
}
|
|
44
|
+
if (!segment.startsWith(":")) return segment;
|
|
45
|
+
if (!PARAMETER.test(segment)) {
|
|
46
|
+
throw new Error(`Rate limit path parameter must be ":" and a name such as ":hostId": ${prefix}`);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whole-segment prefix match; a parameter matches one non-empty segment. */
|
|
53
|
+
function covers(segments: Segment[], path: string[]): boolean {
|
|
54
|
+
return (
|
|
55
|
+
path.length >= segments.length &&
|
|
56
|
+
segments.every((segment, index) => (segment === null ? path[index] !== "" : segment === path[index]))
|
|
57
|
+
);
|
|
31
58
|
}
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Below zero when `a` is more specific: more segments, then, from the left, a
|
|
62
|
+
* literal segment before a parameter.
|
|
63
|
+
*/
|
|
64
|
+
function compareSpecificity(a: Segment[], b: Segment[]): number {
|
|
65
|
+
if (a.length !== b.length) return b.length - a.length;
|
|
66
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
67
|
+
const literalA = a[index] !== null;
|
|
68
|
+
if (literalA !== (b[index] !== null)) return literalA ? -1 : 1;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
35
71
|
}
|
|
36
72
|
|
|
37
73
|
/**
|
|
@@ -50,24 +86,21 @@ export class RateLimitRoutes implements RateLimitRouteRegistry {
|
|
|
50
86
|
if (method !== undefined && !METHOD.test(method)) {
|
|
51
87
|
throw new Error(`Rate limit route method is invalid: ${route.method}`);
|
|
52
88
|
}
|
|
53
|
-
const
|
|
89
|
+
const segments = parsePrefix(route.pathPrefix);
|
|
54
90
|
const profile = route.profile === "exempt" ? "exempt" : this.budget(route.profile);
|
|
55
|
-
this.entries.push({ ...(method ? { method } : {}),
|
|
91
|
+
this.entries.push({ ...(method ? { method } : {}), segments, profile });
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
/** The most specific registered profile for a request, if any route matches. */
|
|
59
95
|
resolve(method: string, path: string): ResolvedRateLimitProfile | undefined {
|
|
96
|
+
const pathSegments = path.split("/").slice(1);
|
|
60
97
|
let best: RouteEntry | undefined;
|
|
61
98
|
for (const entry of this.entries) {
|
|
62
99
|
if (entry.method !== undefined && entry.method !== method) continue;
|
|
63
|
-
if (!covers(entry.
|
|
64
|
-
// The
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
entry.prefix.length > best.prefix.length ||
|
|
68
|
-
(entry.prefix.length === best.prefix.length &&
|
|
69
|
-
(entry.method !== undefined || best.method === undefined))
|
|
70
|
-
) {
|
|
100
|
+
if (!covers(entry.segments, pathSegments)) continue;
|
|
101
|
+
// The more specific prefix wins, then a route with a method, then the later one.
|
|
102
|
+
const order = best ? compareSpecificity(entry.segments, best.segments) : -1;
|
|
103
|
+
if (order < 0 || (order === 0 && (entry.method !== undefined || best?.method === undefined))) {
|
|
71
104
|
best = entry;
|
|
72
105
|
}
|
|
73
106
|
}
|