@starci/hfs 4.11.11 → 4.11.13
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/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/index.mjs +2 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +5 -5
- package/runtime/scripts/api/sops/lib.mjs +25 -3
- package/runtime/scripts/api/sops/seal.mjs +4 -3
- package/runtime/scripts/hfs/path-findings.mjs +4 -2
- package/runtime/scripts/hfs/secret-init.mjs +15 -0
- package/runtime/scripts/hfs/secret-sops.mjs +65 -33
- package/runtime/scripts/lib/sops-envelope.mjs +36 -11
- package/scaffold/add-inbox-lite.mjs +6 -10
- package/scaffold/add-queue-lite.mjs +7 -2
- package/scaffold/add-table.mjs +5 -0
- package/scaffold/app.mjs +15 -3
- package/scaffold/lite-exports.mjs +23 -0
- package/src/main.mjs +3 -3
- package/stack/age-install.mjs +10 -2
- package/stack/context.mjs +2 -2
- package/stack/remote.mjs +29 -8
- package/stack/sealed.mjs +15 -11
- package/stack/server-key.mjs +31 -7
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +4 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +0 -2
- package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +4 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +260 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +133 -0
package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import "reflect-metadata"
|
|
2
|
+
import type { ExecutionContext, Type } from "@nestjs/common"
|
|
3
|
+
import type { Request, Response } from "express"
|
|
4
|
+
import { Reflector } from "@nestjs/core"
|
|
5
|
+
import { Test } from "@nestjs/testing"
|
|
6
|
+
import { In, LessThanOrEqual, MoreThan } from "typeorm"
|
|
7
|
+
import { FakeClock, mock, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
|
|
8
|
+
import type { MockEntityManager } from "@starci/jest-preset"
|
|
9
|
+
import { CLOCK } from "@modules/platform/clock"
|
|
10
|
+
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
11
|
+
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
12
|
+
import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
|
|
13
|
+
import type { HttpSecurityOptions } from "./http-security.options"
|
|
14
|
+
import { RateWindowEntity } from "./persistence/entities/rate-window.entity"
|
|
15
|
+
import { RateLimit, RateLimitGuard, RateTier } from "./rate-limit.guard"
|
|
16
|
+
import type { RateLimitExceeded } from "./rate-limit.guard"
|
|
17
|
+
|
|
18
|
+
const AT = "2026-02-03T04:05:06.000Z"
|
|
19
|
+
const RESET_AT = "2026-02-03T04:06:06.000Z"
|
|
20
|
+
const options: HttpSecurityOptions = {
|
|
21
|
+
allowedOrigins: [],
|
|
22
|
+
rateLimit: { windowMs: 60_000, defaultLimit: 2, strictLimit: 1 },
|
|
23
|
+
webhooks: {},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The caller's live window holding `hits`. */
|
|
27
|
+
const windowOf = (hits: number): RateWindowEntity => ({ key: "default:127.0.0.1", hits, resetAt: new Date(RESET_AT) })
|
|
28
|
+
|
|
29
|
+
/** The answers of a store whose live window of the caller holds `hits` after this hit's increment. */
|
|
30
|
+
const liveWindow = (hits: number) =>
|
|
31
|
+
({ increment: [RateWindowEntity, { affected: 1 }], findOneBy: [RateWindowEntity, windowOf(hits)] }) as const
|
|
32
|
+
|
|
33
|
+
/** The answers of a store where this hit opens the caller's window (no live or ended one) and the prune finds none ended. */
|
|
34
|
+
const OPENING_WINDOW = {
|
|
35
|
+
increment: [RateWindowEntity, { affected: 0 }],
|
|
36
|
+
update: [RateWindowEntity, { affected: 0 }],
|
|
37
|
+
find: [RateWindowEntity, []],
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
|
|
41
|
+
const reflector = mock<Reflector>()
|
|
42
|
+
reflector.getAllAndOverride.mockImplementation((key: unknown) =>
|
|
43
|
+
key === "platform.http-security.rate-exceeded" ? exceeded : tier,
|
|
44
|
+
)
|
|
45
|
+
const moduleRef = await Test.createTestingModule({
|
|
46
|
+
providers: [
|
|
47
|
+
RateLimitGuard,
|
|
48
|
+
{ provide: Reflector, useValue: reflector },
|
|
49
|
+
{ provide: CLOCK, useValue: new FakeClock(AT) },
|
|
50
|
+
{ provide: HTTP_SECURITY_OPTIONS, useValue: options },
|
|
51
|
+
{ provide: PRIMARY_ENTITY_MANAGER, useValue: entityManager },
|
|
52
|
+
],
|
|
53
|
+
}).compile()
|
|
54
|
+
return { guard: moduleRef.get(RateLimitGuard), reflector }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An execution context of one POST from `ip` on the door; the handler and the class carry the tier metadata. */
|
|
58
|
+
const contextOf = (
|
|
59
|
+
ip: string | undefined,
|
|
60
|
+
handler = () => undefined,
|
|
61
|
+
door: Type<unknown> = class Door {},
|
|
62
|
+
response = mock<Response>(),
|
|
63
|
+
) => {
|
|
64
|
+
const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
|
|
65
|
+
http.getRequest.mockImplementation(() => mock<Request>({ headers: {}, method: "POST", ip }))
|
|
66
|
+
http.getResponse.mockReturnValue(response)
|
|
67
|
+
const context = mock<ExecutionContext>()
|
|
68
|
+
context.getType.mockReturnValue("http")
|
|
69
|
+
context.switchToHttp.mockReturnValue(http)
|
|
70
|
+
context.getHandler.mockReturnValue(handler)
|
|
71
|
+
context.getClass.mockReturnValue(door)
|
|
72
|
+
return context
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("RateLimit", () => {
|
|
76
|
+
it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
|
|
77
|
+
class Door {}
|
|
78
|
+
RateLimit(RateTier.Strict)(Door)
|
|
79
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
80
|
+
const { guard, reflector } = await build(entityManager, RateTier.Strict)
|
|
81
|
+
const handler = () => undefined
|
|
82
|
+
|
|
83
|
+
expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
|
|
84
|
+
await expect(guard.canActivate(contextOf("127.0.0.1", handler, Door))).rejects.toMatchObject({
|
|
85
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
86
|
+
})
|
|
87
|
+
expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
|
|
88
|
+
expect(entityManager.increment).toHaveBeenCalledWith(
|
|
89
|
+
RateWindowEntity,
|
|
90
|
+
{ key: "strict:127.0.0.1", resetAt: MoreThan(new Date(AT)) },
|
|
91
|
+
"hits",
|
|
92
|
+
1,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
|
|
97
|
+
class Door {}
|
|
98
|
+
RateLimit(RateTier.Strict, { redirect: "/sign-in?auth=error" })(Door)
|
|
99
|
+
|
|
100
|
+
expect(Reflect.getMetadata("platform.http-security.rate-tier", Door)).toBe(RateTier.Strict)
|
|
101
|
+
expect(Reflect.getMetadata("platform.http-security.rate-exceeded", Door)).toEqual({
|
|
102
|
+
redirect: "/sign-in?auth=error",
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it.each(["https://foreign.test/sign-in", "//foreign.test/sign-in", "/\\foreign.test", "sign-in"])(
|
|
107
|
+
"refuses an exceeded redirect %s that is not a same-site path",
|
|
108
|
+
(redirect) => {
|
|
109
|
+
expect(() => RateLimit(RateTier.Strict, { redirect })).toThrow(HttpSecurityErrorCode.RequestInvalid)
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("RateLimitGuard", () => {
|
|
115
|
+
it("admits a caller under the default limit of an unmarked door, counting in the shared live window", async () => {
|
|
116
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
117
|
+
const { guard } = await build(entityManager)
|
|
118
|
+
|
|
119
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
120
|
+
expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:127.0.0.1" })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("refuses a caller whose shared window is over the default limit", async () => {
|
|
124
|
+
const { guard } = await build(mockEntityManager(liveWindow(3)))
|
|
125
|
+
|
|
126
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
127
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("refuses the request when the counted window vanished before it was read", async () => {
|
|
132
|
+
const entityManager = mockEntityManager({
|
|
133
|
+
increment: [RateWindowEntity, { affected: 1 }],
|
|
134
|
+
findOneBy: [RateWindowEntity, null],
|
|
135
|
+
})
|
|
136
|
+
const { guard } = await build(entityManager)
|
|
137
|
+
|
|
138
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
139
|
+
code: HttpSecurityErrorCode.RateLimited,
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe("RateLimitGuard opening a window", () => {
|
|
145
|
+
it("restarts an ended window at one hit and prunes the ended windows", async () => {
|
|
146
|
+
const ended = windowOf(5)
|
|
147
|
+
const entityManager = mockEntityManager({
|
|
148
|
+
increment: [RateWindowEntity, {}],
|
|
149
|
+
update: [RateWindowEntity, { affected: 1 }],
|
|
150
|
+
find: [RateWindowEntity, [ended]],
|
|
151
|
+
delete: [RateWindowEntity, { affected: 1 }],
|
|
152
|
+
})
|
|
153
|
+
const { guard } = await build(entityManager)
|
|
154
|
+
|
|
155
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
156
|
+
expect(entityManager.update).toHaveBeenCalledWith(
|
|
157
|
+
RateWindowEntity,
|
|
158
|
+
{ key: "default:127.0.0.1", resetAt: LessThanOrEqual(new Date(AT)) },
|
|
159
|
+
{ hits: 1, resetAt: new Date(RESET_AT) },
|
|
160
|
+
)
|
|
161
|
+
expect(entityManager.find).toHaveBeenCalledWith(RateWindowEntity, {
|
|
162
|
+
select: { key: true },
|
|
163
|
+
where: { resetAt: LessThanOrEqual(new Date(AT)) },
|
|
164
|
+
order: { resetAt: "ASC" },
|
|
165
|
+
take: 100,
|
|
166
|
+
})
|
|
167
|
+
expect(entityManager.delete).toHaveBeenCalledWith(RateWindowEntity, {
|
|
168
|
+
key: In([ended.key]),
|
|
169
|
+
resetAt: LessThanOrEqual(new Date(AT)),
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("opens a missing window at one hit, ignoring a concurrent opener, and deletes nothing when no window ended", async () => {
|
|
174
|
+
const entityManager = mockEntityManager(OPENING_WINDOW)
|
|
175
|
+
const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
176
|
+
const { guard } = await build(entityManager)
|
|
177
|
+
|
|
178
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
179
|
+
expect(opened?.callsOf("values")).toEqual([
|
|
180
|
+
[{ key: "default:127.0.0.1", hits: 1, resetAt: new Date(RESET_AT) }],
|
|
181
|
+
])
|
|
182
|
+
expect(opened?.callsOf("orIgnore")).toEqual([[]])
|
|
183
|
+
expect(entityManager.delete).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("opens a window when the restart answers no affected count", async () => {
|
|
187
|
+
const entityManager = mockEntityManager({
|
|
188
|
+
increment: [RateWindowEntity, { affected: 0 }],
|
|
189
|
+
update: [RateWindowEntity, {}],
|
|
190
|
+
find: [RateWindowEntity, []],
|
|
191
|
+
})
|
|
192
|
+
const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
193
|
+
const { guard } = await build(entityManager)
|
|
194
|
+
|
|
195
|
+
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
196
|
+
expect(opened?.callsOf("insert")).toEqual([[]])
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe("RateLimitGuard keys", () => {
|
|
201
|
+
it("counts a request without an address under the unknown key", async () => {
|
|
202
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
203
|
+
const { guard } = await build(entityManager)
|
|
204
|
+
|
|
205
|
+
await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
|
|
206
|
+
expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: "default:unknown" })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("bounds the window key to the length the table stores", async () => {
|
|
210
|
+
const entityManager = mockEntityManager(liveWindow(2))
|
|
211
|
+
const { guard } = await build(entityManager)
|
|
212
|
+
|
|
213
|
+
await guard.canActivate(contextOf("f".repeat(300)))
|
|
214
|
+
|
|
215
|
+
expect(entityManager.findOneBy).toHaveBeenCalledWith(RateWindowEntity, { key: `default:${"f".repeat(120)}` })
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe("RateLimitGuard on a redirect-only door", () => {
|
|
220
|
+
const EXCEEDED: RateLimitExceeded = { redirect: "/sign-in?auth=error" }
|
|
221
|
+
|
|
222
|
+
/** A response whose redirect is a plain double, so the spec reads the call without the deprecated overload. */
|
|
223
|
+
const responseOf = () => {
|
|
224
|
+
const redirect = jest.fn()
|
|
225
|
+
return { response: mock<Response>({ redirect }), redirect }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
it("answers the door's own 303 when the caller is over the limit, and still refuses", async () => {
|
|
229
|
+
const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
|
|
230
|
+
const { response, redirect } = responseOf()
|
|
231
|
+
|
|
232
|
+
await expect(
|
|
233
|
+
guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response)),
|
|
234
|
+
).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
|
|
235
|
+
expect(redirect).toHaveBeenCalledWith(303, "/sign-in?auth=error")
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("writes no redirect while the caller is under the limit", async () => {
|
|
239
|
+
const entityManager = mockEntityManager(OPENING_WINDOW)
|
|
240
|
+
queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
241
|
+
const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
|
|
242
|
+
const { response, redirect } = responseOf()
|
|
243
|
+
|
|
244
|
+
await expect(guard.canActivate(contextOf("127.0.0.1", () => undefined, class Door {}, response))).resolves.toBe(
|
|
245
|
+
true,
|
|
246
|
+
)
|
|
247
|
+
expect(redirect).not.toHaveBeenCalled()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
|
|
251
|
+
const { guard } = await build(mockEntityManager(liveWindow(2)), RateTier.Strict, EXCEEDED)
|
|
252
|
+
const { response, redirect } = responseOf()
|
|
253
|
+
const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
|
|
254
|
+
context.getType.mockReturnValue("graphql")
|
|
255
|
+
context.getArgs.mockReturnValue([{}, {}, { req: mock<Request>({ headers: {}, ip: "127.0.0.1" }) }, {}])
|
|
256
|
+
|
|
257
|
+
await expect(guard.canActivate(context)).rejects.toMatchObject({ code: HttpSecurityErrorCode.RateLimited })
|
|
258
|
+
expect(redirect).not.toHaveBeenCalled()
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Injectable, SetMetadata, applyDecorators } from "@nestjs/common"
|
|
2
|
+
import type { CanActivate, ExecutionContext } from "@nestjs/common"
|
|
3
|
+
import type { Reflector } from "@nestjs/core"
|
|
4
|
+
import type { Response } from "express"
|
|
5
|
+
import { In, LessThanOrEqual, MoreThan } from "typeorm"
|
|
6
|
+
import type { EntityManager } from "typeorm"
|
|
7
|
+
import { InjectClock } from "@modules/platform/clock"
|
|
8
|
+
import type { Clock } from "@modules/platform/clock"
|
|
9
|
+
import { InjectReflector } from "@modules/platform/composition"
|
|
10
|
+
import type { DoorDecorator } from "@modules/platform/composition"
|
|
11
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
12
|
+
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
13
|
+
import { requestOf } from "./execution-request.mapper"
|
|
14
|
+
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
15
|
+
import type { HttpSecurityOptions } from "./http-security.options"
|
|
16
|
+
import { RateWindowEntity } from "./persistence/entities/rate-window.entity"
|
|
17
|
+
|
|
18
|
+
/** The rate limit tiers of a door. */
|
|
19
|
+
export enum RateTier {
|
|
20
|
+
/** The global default tier. */
|
|
21
|
+
Default = "default",
|
|
22
|
+
/** The tight tier every authentication handshake and signed webhook carries. */
|
|
23
|
+
Strict = "strict",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What a door answers when its caller is over the limit, when that is not the 429 error body. */
|
|
27
|
+
export interface RateLimitExceeded {
|
|
28
|
+
/** A same-site path the door's own contract answers with a 303 (an OAuth callback's error redirect). */
|
|
29
|
+
readonly redirect: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RATE_TIER_KEY = "platform.http-security.rate-tier"
|
|
33
|
+
const RATE_EXCEEDED_KEY = "platform.http-security.rate-exceeded"
|
|
34
|
+
/** The longest window key the table stores: the tier, a colon and the caller address. */
|
|
35
|
+
const KEY_MAX = 128
|
|
36
|
+
/** The most ended windows one fresh window prunes. */
|
|
37
|
+
const PRUNE_BATCH = 100
|
|
38
|
+
|
|
39
|
+
/** A same-site path: one leading slash, never a protocol-relative `//` or `/\` that leaves the site. */
|
|
40
|
+
const isSameSitePath = (location: string): boolean =>
|
|
41
|
+
location.startsWith("/") && !location.startsWith("//") && !location.startsWith("/\\")
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Marks a door with a rate limit tier other than the default. A door whose contract answers only redirects (an OAuth
|
|
45
|
+
* callback) names its error redirect in `exceeded`, and a caller over the limit gets that 303 instead of a 429 body.
|
|
46
|
+
*/
|
|
47
|
+
export const RateLimit = (tier: RateTier, exceeded?: RateLimitExceeded): DoorDecorator => {
|
|
48
|
+
if (exceeded === undefined) return applyDecorators(SetMetadata(RATE_TIER_KEY, tier))
|
|
49
|
+
if (!isSameSitePath(exceeded.redirect))
|
|
50
|
+
throw new HttpSecurityError({
|
|
51
|
+
code: HttpSecurityErrorCode.RequestInvalid,
|
|
52
|
+
params: { fields: "RATE_LIMIT_REDIRECT" },
|
|
53
|
+
})
|
|
54
|
+
return applyDecorators(SetMetadata(RATE_TIER_KEY, tier), SetMetadata(RATE_EXCEEDED_KEY, exceeded))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Injectable()
|
|
58
|
+
/**
|
|
59
|
+
* The first app guard: a fixed-window counter per caller address and tier, stamped by the Clock and kept in the primary
|
|
60
|
+
* database through its entity, so every replica counts against the same window. A hit that opens a fresh window also prunes
|
|
61
|
+
* a bounded batch of expired windows.
|
|
62
|
+
*/
|
|
63
|
+
export class RateLimitGuard implements CanActivate {
|
|
64
|
+
constructor(
|
|
65
|
+
@InjectReflector() private readonly reflector: Reflector,
|
|
66
|
+
@InjectClock() private readonly clock: Clock,
|
|
67
|
+
@InjectHttpSecurityOptions() private readonly options: HttpSecurityOptions,
|
|
68
|
+
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Counts the request and refuses it when the caller is over the limit of the door tier. A door that declares its
|
|
73
|
+
* exceeded redirect has the 303 written here; the refusal still travels, and the errors filter leaves the sent
|
|
74
|
+
* response as it is.
|
|
75
|
+
*/
|
|
76
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
77
|
+
const targets = [context.getHandler(), context.getClass()]
|
|
78
|
+
const tier = this.reflector.getAllAndOverride<RateTier | undefined>(RATE_TIER_KEY, targets) ?? RateTier.Default
|
|
79
|
+
const limit =
|
|
80
|
+
tier === RateTier.Strict ? this.options.rateLimit.strictLimit : this.options.rateLimit.defaultLimit
|
|
81
|
+
const now = this.clock.now()
|
|
82
|
+
const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
|
|
83
|
+
const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
|
|
84
|
+
const hits = await this.hit(key, now, resetAt)
|
|
85
|
+
if (hits === 1) await this.prune(now)
|
|
86
|
+
if (hits <= limit) return true
|
|
87
|
+
const exceeded = this.reflector.getAllAndOverride<RateLimitExceeded | undefined>(RATE_EXCEEDED_KEY, targets)
|
|
88
|
+
if (exceeded !== undefined && context.getType() === "http")
|
|
89
|
+
context.switchToHttp().getResponse<Response>().redirect(303, exceeded.redirect)
|
|
90
|
+
throw new HttpSecurityError({ code: HttpSecurityErrorCode.RateLimited })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Counts one hit in the caller's window and answers the hits it holds: a live window grows by one in one atomic increment,
|
|
95
|
+
* an ended window restarts at one hit ending at `resetAt`, and a missing one opens at one hit (a concurrent opener's row
|
|
96
|
+
* wins and this hit is its first). A window that vanished between the writes counts as over every limit.
|
|
97
|
+
*/
|
|
98
|
+
private async hit(key: string, now: Date, resetAt: Date): Promise<number> {
|
|
99
|
+
const counted = await this.entityManager.increment(RateWindowEntity, { key, resetAt: MoreThan(now) }, "hits", 1)
|
|
100
|
+
if ((counted.affected ?? 0) > 0) {
|
|
101
|
+
const window = await this.entityManager.findOneBy(RateWindowEntity, { key })
|
|
102
|
+
return window?.hits ?? Number.POSITIVE_INFINITY
|
|
103
|
+
}
|
|
104
|
+
const restarted = await this.entityManager.update(
|
|
105
|
+
RateWindowEntity,
|
|
106
|
+
{ key, resetAt: LessThanOrEqual(now) },
|
|
107
|
+
{ hits: 1, resetAt },
|
|
108
|
+
)
|
|
109
|
+
if ((restarted.affected ?? 0) > 0) return 1
|
|
110
|
+
await this.entityManager
|
|
111
|
+
.createQueryBuilder(RateWindowEntity, "window")
|
|
112
|
+
.insert()
|
|
113
|
+
.values({ key, hits: 1, resetAt })
|
|
114
|
+
.orIgnore()
|
|
115
|
+
.execute()
|
|
116
|
+
return 1
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Deletes a bounded batch of the windows that ended by `now`; a window a hit restarted meanwhile is kept. */
|
|
120
|
+
private async prune(now: Date): Promise<void> {
|
|
121
|
+
const ended = await this.entityManager.find(RateWindowEntity, {
|
|
122
|
+
select: { key: true },
|
|
123
|
+
where: { resetAt: LessThanOrEqual(now) },
|
|
124
|
+
order: { resetAt: "ASC" },
|
|
125
|
+
take: PRUNE_BATCH,
|
|
126
|
+
})
|
|
127
|
+
if (ended.length === 0) return
|
|
128
|
+
await this.entityManager.delete(RateWindowEntity, {
|
|
129
|
+
key: In(ended.map((window) => window.key)),
|
|
130
|
+
resetAt: LessThanOrEqual(now),
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|