@starci/hfs 4.11.13 → 4.11.16
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 +28 -0
- package/db/migrate.mjs +1 -1
- package/db/postgres.mjs +38 -3
- package/package.json +1 -1
- package/release/index.mjs +4 -3
- package/release/lint-baseline.mjs +123 -0
- package/release/urgent.mjs +19 -4
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/rules.yaml +4 -4
- package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
- package/runtime/scripts/hfs/architecture/identity.mjs +1 -1
- package/runtime/scripts/hfs/architecture/jwt-ast.mjs +14 -9
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
- package/scaffold/add-compose.mjs +3 -0
- package/scaffold/add-inbox-lite.mjs +45 -23
- package/scaffold/lite-exports.mjs +13 -0
- package/src/main.mjs +2 -2
- package/stack/cli.mjs +5 -3
- package/stack/deploy.mjs +6 -2
- package/stack/urgent-floor.mjs +101 -7
- package/templates/be/inbox/entities.index.ts.tpl +4 -0
- package/templates/be/inbox/entity.ts.tpl +29 -0
- package/templates/be/inbox/index.ts.tpl +1 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
- package/templates/be/inbox/service.spec.ts.tpl +39 -15
- package/templates/be/inbox/service.ts.tpl +13 -13
- package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +18 -8
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +12 -3
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +39 -17
- package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +121 -9
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +12 -6
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
- package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
- package/templates/be/inbox/database-client.ts.tpl +0 -45
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isRecord } from "@modules/platform/primitives"
|
|
2
|
+
import { DatabaseError, DatabaseErrorCode } from "./errors/database.error"
|
|
3
|
+
|
|
4
|
+
/** What node-postgres hands back for one returned column; a kind ending in `?` also admits SQL null. */
|
|
5
|
+
interface ColumnTypes {
|
|
6
|
+
readonly string: string
|
|
7
|
+
readonly number: number
|
|
8
|
+
readonly boolean: boolean
|
|
9
|
+
readonly date: Date
|
|
10
|
+
readonly json: unknown
|
|
11
|
+
readonly "string?": string | null
|
|
12
|
+
readonly "number?": number | null
|
|
13
|
+
readonly "boolean?": boolean | null
|
|
14
|
+
readonly "date?": Date | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The kind one returned column is checked against. */
|
|
18
|
+
type ColumnKind = keyof ColumnTypes
|
|
19
|
+
|
|
20
|
+
/** The returned columns of one statement, each named as the database returns it (snake_case) with its kind. */
|
|
21
|
+
type ColumnShape = Readonly<Record<string, ColumnKind>>
|
|
22
|
+
|
|
23
|
+
/** The row a shape describes: every named column, typed by its kind. */
|
|
24
|
+
type RowOf<Shape extends ColumnShape> = { readonly [Column in keyof Shape]: ColumnTypes[Shape[Column]] }
|
|
25
|
+
|
|
26
|
+
/** A guard that proves one returned row has the shape its returned columns give it. */
|
|
27
|
+
type RowGuard<Row> = (value: unknown) => value is Row
|
|
28
|
+
|
|
29
|
+
/** Whether `value` is of the non-null part of a kind. */
|
|
30
|
+
const KIND_CHECKS: Readonly<Record<ColumnKind, (value: unknown) => boolean>> = {
|
|
31
|
+
string: (value) => typeof value === "string",
|
|
32
|
+
number: (value) => typeof value === "number",
|
|
33
|
+
boolean: (value) => typeof value === "boolean",
|
|
34
|
+
date: (value) => value instanceof Date,
|
|
35
|
+
json: (value) => value !== undefined,
|
|
36
|
+
"string?": (value) => value === null || typeof value === "string",
|
|
37
|
+
"number?": (value) => value === null || typeof value === "number",
|
|
38
|
+
"boolean?": (value) => value === null || typeof value === "boolean",
|
|
39
|
+
"date?": (value) => value === null || value instanceof Date,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The guard of the rows a RETURNING clause answers for `shape`: an object holding every named column with a value of its kind.
|
|
44
|
+
* A column the statement does not return is refused, so the shape and the RETURNING list cannot drift apart unseen.
|
|
45
|
+
*/
|
|
46
|
+
export const rowShape =
|
|
47
|
+
<Shape extends ColumnShape>(shape: Shape): RowGuard<RowOf<Shape>> =>
|
|
48
|
+
(value: unknown): value is RowOf<Shape> =>
|
|
49
|
+
isRecord(value) && Object.entries(shape).every(([column, kind]) => KIND_CHECKS[kind](value[column]))
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The rows a RETURNING statement answered, each proven by `isRow`: the result of `manager.query` for an INSERT or a CTE-wrapped
|
|
53
|
+
* UPDATE or DELETE, or the `raw` of a query builder's `execute()`. A result that is not a list of rows, or a row the guard
|
|
54
|
+
* refuses, throws `DATABASE_RETURNING_ROW_REJECTED`: the statement and the shape its caller expects disagree.
|
|
55
|
+
*/
|
|
56
|
+
export const returnedRows = <Row>(raw: unknown, isRow: RowGuard<Row>): Array<Row> => {
|
|
57
|
+
if (!Array.isArray(raw)) throw new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected })
|
|
58
|
+
const rows: ReadonlyArray<unknown> = raw
|
|
59
|
+
if (!rows.every(isRow)) throw new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected })
|
|
60
|
+
return [...rows]
|
|
61
|
+
}
|
package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { SetMetadata } from "@nestjs/common"
|
|
1
2
|
import { injector } from "@modules/platform/composition"
|
|
2
|
-
import type { TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
|
+
import type { DoorDecorator, TypedParameterDecorator } from "@modules/platform/composition"
|
|
3
4
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
4
5
|
import type { WebhookSignatureService } from "./webhook-signature.service"
|
|
5
6
|
|
|
7
|
+
/** Metadata for doors that require an actual Origin, including the start of a cookie-authenticated flow. */
|
|
8
|
+
export const REQUIRE_ORIGIN_KEY = "platform.http-security.require-origin"
|
|
9
|
+
|
|
10
|
+
/** Requires an allowed Origin on mutations even before the caller has received its first cookie. */
|
|
11
|
+
export const RequireOrigin = (): DoorDecorator => SetMetadata(REQUIRE_ORIGIN_KEY, true)
|
|
12
|
+
|
|
6
13
|
/** Token of the http-security options, exported so a spec can provide it. */
|
|
7
14
|
export const HTTP_SECURITY_OPTIONS: unique symbol = Symbol("platform.http-security.options")
|
|
8
15
|
|
|
@@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"
|
|
|
5
5
|
import { Test } from "@nestjs/testing"
|
|
6
6
|
import { mock } from "@starci/jest-preset"
|
|
7
7
|
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
8
|
-
import { HTTP_SECURITY_OPTIONS } from "./http-security.decorators"
|
|
8
|
+
import { HTTP_SECURITY_OPTIONS, RequireOrigin } from "./http-security.decorators"
|
|
9
9
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
10
10
|
import { OriginGuard, ServerToServer } from "./origin.guard"
|
|
11
11
|
|
|
@@ -17,16 +17,31 @@ const options: HttpSecurityOptions = {
|
|
|
17
17
|
|
|
18
18
|
class OpenDoor {}
|
|
19
19
|
|
|
20
|
+
@RequireOrigin()
|
|
21
|
+
class CookieDoor {}
|
|
22
|
+
|
|
23
|
+
class SignInDoor {
|
|
24
|
+
@RequireOrigin()
|
|
25
|
+
start(): undefined {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
class SignedDoor {}
|
|
21
31
|
ServerToServer()(SignedDoor)
|
|
22
32
|
|
|
23
|
-
const build = async (
|
|
33
|
+
const build = async (
|
|
34
|
+
method: string,
|
|
35
|
+
headers: Request["headers"],
|
|
36
|
+
door: Type<unknown> = OpenDoor,
|
|
37
|
+
handler: () => void = () => undefined,
|
|
38
|
+
) => {
|
|
24
39
|
const request = mock<Request>({ headers, method })
|
|
25
40
|
const http = mock<ReturnType<ExecutionContext["switchToHttp"]>>()
|
|
26
41
|
http.getRequest.mockReturnValue(request)
|
|
27
42
|
const context = mock<ExecutionContext>()
|
|
28
43
|
context.switchToHttp.mockReturnValue(http)
|
|
29
|
-
context.getHandler.mockReturnValue(
|
|
44
|
+
context.getHandler.mockReturnValue(handler)
|
|
30
45
|
context.getClass.mockReturnValue(door)
|
|
31
46
|
const moduleRef = await Test.createTestingModule({
|
|
32
47
|
providers: [OriginGuard, Reflector, { provide: HTTP_SECURITY_OPTIONS, useValue: options }],
|
|
@@ -36,19 +51,23 @@ const build = async (method: string, headers: Request["headers"], door: Type<unk
|
|
|
36
51
|
|
|
37
52
|
describe("OriginGuard", () => {
|
|
38
53
|
it.each(["GET", "HEAD", "OPTIONS"])("allows the safe %s method without an origin", async (method) => {
|
|
39
|
-
const { guard, context } = await build(method, {})
|
|
54
|
+
const { guard, context } = await build(method, { cookie: "session=opaque" }, CookieDoor)
|
|
40
55
|
|
|
41
56
|
expect(guard.canActivate(context)).toBe(true)
|
|
42
57
|
})
|
|
43
58
|
|
|
44
59
|
it("allows a state-changing request carrying an allowed Origin header", async () => {
|
|
45
|
-
const { guard, context } = await build("POST", {
|
|
60
|
+
const { guard, context } = await build("POST", {
|
|
61
|
+
origin: "https://app.test",
|
|
62
|
+
})
|
|
46
63
|
|
|
47
64
|
expect(guard.canActivate(context)).toBe(true)
|
|
48
65
|
})
|
|
49
66
|
|
|
50
67
|
it("falls back to the origin of an allowed Referer when Origin is absent", async () => {
|
|
51
|
-
const { guard, context } = await build("PATCH", {
|
|
68
|
+
const { guard, context } = await build("PATCH", {
|
|
69
|
+
referer: "https://app.test/notes/one",
|
|
70
|
+
})
|
|
52
71
|
|
|
53
72
|
expect(guard.canActivate(context)).toBe(true)
|
|
54
73
|
})
|
|
@@ -56,12 +75,21 @@ describe("OriginGuard", () => {
|
|
|
56
75
|
it.each([
|
|
57
76
|
{ name: "no Origin and no Referer", headers: {} },
|
|
58
77
|
{ name: "a Referer that is not a URL", headers: { referer: "not a URL" } },
|
|
59
|
-
{
|
|
78
|
+
{
|
|
79
|
+
name: "an Origin outside the allowlist",
|
|
80
|
+
headers: { origin: "https://foreign.test" },
|
|
81
|
+
},
|
|
60
82
|
{ name: "the opaque null Origin", headers: { origin: "null" } },
|
|
61
|
-
{
|
|
83
|
+
{
|
|
84
|
+
name: "a Referer outside the allowlist",
|
|
85
|
+
headers: { referer: "https://foreign.test/form" },
|
|
86
|
+
},
|
|
62
87
|
{
|
|
63
88
|
name: "a foreign Origin beside an allowed Referer",
|
|
64
|
-
headers: {
|
|
89
|
+
headers: {
|
|
90
|
+
origin: "https://foreign.test",
|
|
91
|
+
referer: "https://app.test/notes/one",
|
|
92
|
+
},
|
|
65
93
|
},
|
|
66
94
|
])("refuses a state-changing request with $name", async ({ headers }) => {
|
|
67
95
|
const { guard, context } = await build("DELETE", headers)
|
|
@@ -75,3 +103,87 @@ describe("OriginGuard", () => {
|
|
|
75
103
|
expect(guard.canActivate(context)).toBe(true)
|
|
76
104
|
})
|
|
77
105
|
})
|
|
106
|
+
|
|
107
|
+
describe("OriginGuard", () => {
|
|
108
|
+
it.each(["POST", "PUT", "PATCH", "DELETE"])(
|
|
109
|
+
"allows a cookie-bearing %s with an actual allowed Origin",
|
|
110
|
+
async (method) => {
|
|
111
|
+
const { guard, context } = await build(method, {
|
|
112
|
+
origin: "https://app.test",
|
|
113
|
+
cookie: "session=opaque",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(guard.canActivate(context)).toBe(true)
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
it.each([
|
|
121
|
+
{ name: "no Origin", headers: {} },
|
|
122
|
+
{
|
|
123
|
+
name: "an allowed Referer alone",
|
|
124
|
+
headers: { referer: "https://app.test/form" },
|
|
125
|
+
},
|
|
126
|
+
{ name: "a malformed Referer alone", headers: { referer: "not a URL" } },
|
|
127
|
+
{ name: "a foreign Origin", headers: { origin: "https://foreign.test" } },
|
|
128
|
+
{ name: "an opaque Origin", headers: { origin: "null" } },
|
|
129
|
+
{ name: "an empty Origin", headers: { origin: "" } },
|
|
130
|
+
{
|
|
131
|
+
name: "a foreign Origin beside an allowed Referer",
|
|
132
|
+
headers: {
|
|
133
|
+
origin: "https://foreign.test",
|
|
134
|
+
referer: "https://app.test/form",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
])("refuses a cookie-bearing mutation with $name", async ({ headers }) => {
|
|
138
|
+
const { guard, context } = await build("POST", {
|
|
139
|
+
...headers,
|
|
140
|
+
cookie: "session=opaque",
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe("OriginGuard", () => {
|
|
148
|
+
it.each([{}, { referer: "https://app.test/sign-in" }])(
|
|
149
|
+
"refuses a marked cookie-flow door without Origin before a cookie exists (%o)",
|
|
150
|
+
async (headers) => {
|
|
151
|
+
const { guard, context } = await build("POST", headers, CookieDoor)
|
|
152
|
+
|
|
153
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
it("requires Origin on an individually marked handler before a cookie exists", async () => {
|
|
158
|
+
const { guard, context } = await build("POST", {}, SignInDoor, SignInDoor.prototype.start)
|
|
159
|
+
|
|
160
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("admits a marked cookie-flow door with an actual allowed Origin before a cookie exists", async () => {
|
|
164
|
+
const { guard, context } = await build("POST", { origin: "https://app.test" }, CookieDoor)
|
|
165
|
+
|
|
166
|
+
expect(guard.canActivate(context)).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it("refuses Referer-only admission when a server-to-server door receives a cookie", async () => {
|
|
170
|
+
const { guard, context } = await build(
|
|
171
|
+
"POST",
|
|
172
|
+
{ cookie: "session=opaque", referer: "https://app.test/form" },
|
|
173
|
+
SignedDoor,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("refuses Referer-only admission when a server-to-server handler requires Origin", async () => {
|
|
180
|
+
const { guard, context } = await build(
|
|
181
|
+
"POST",
|
|
182
|
+
{ referer: "https://app.test/form" },
|
|
183
|
+
SignedDoor,
|
|
184
|
+
SignInDoor.prototype.start,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(() => guard.canActivate(context)).toThrow(HttpSecurityErrorCode.OriginRejected)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -4,7 +4,7 @@ import type { Reflector } from "@nestjs/core"
|
|
|
4
4
|
import { InjectReflector } from "@modules/platform/composition"
|
|
5
5
|
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
6
6
|
import { requestOf } from "./execution-request.mapper"
|
|
7
|
-
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
7
|
+
import { InjectHttpSecurityOptions, REQUIRE_ORIGIN_KEY } from "./http-security.decorators"
|
|
8
8
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
9
9
|
|
|
10
10
|
const SAFE_METHODS: ReadonlySet<string> = new Set(["GET", "HEAD", "OPTIONS"])
|
|
@@ -26,9 +26,8 @@ const originOf = (origin: string | undefined, referer: string | undefined): stri
|
|
|
26
26
|
|
|
27
27
|
@Injectable()
|
|
28
28
|
/**
|
|
29
|
-
* The second app guard, the CSRF defence:
|
|
30
|
-
*
|
|
31
|
-
* whatever sent it. A door marked `ServerToServer` proves its caller itself and owes no origin.
|
|
29
|
+
* The second app guard, the CSRF defence: cookie-bearing mutations and doors marked RequireOrigin need an actual allowed
|
|
30
|
+
* Origin. Other mutations retain Referer fallback and the ServerToServer exemption for callers proving themselves.
|
|
32
31
|
*/
|
|
33
32
|
export class OriginGuard implements CanActivate {
|
|
34
33
|
constructor(
|
|
@@ -40,12 +39,19 @@ export class OriginGuard implements CanActivate {
|
|
|
40
39
|
canActivate(context: ExecutionContext): boolean {
|
|
41
40
|
const request = requestOf(context)
|
|
42
41
|
if (SAFE_METHODS.has(request.method)) return true
|
|
42
|
+
const requiresOrigin =
|
|
43
|
+
this.reflector.getAllAndOverride<boolean | undefined>(REQUIRE_ORIGIN_KEY, [
|
|
44
|
+
context.getHandler(),
|
|
45
|
+
context.getClass(),
|
|
46
|
+
]) === true || request.headers.cookie !== undefined
|
|
43
47
|
const exempt = this.reflector.getAllAndOverride<boolean | undefined>(SERVER_TO_SERVER_KEY, [
|
|
44
48
|
context.getHandler(),
|
|
45
49
|
context.getClass(),
|
|
46
50
|
])
|
|
47
|
-
if (exempt === true) return true
|
|
48
|
-
const origin =
|
|
51
|
+
if (!requiresOrigin && exempt === true) return true
|
|
52
|
+
const origin = requiresOrigin
|
|
53
|
+
? request.headers.origin
|
|
54
|
+
: originOf(request.headers.origin, request.headers.referer)
|
|
49
55
|
if (origin !== undefined && this.options.allowedOrigins.includes(origin)) return true
|
|
50
56
|
throw new HttpSecurityError({ code: HttpSecurityErrorCode.OriginRejected })
|
|
51
57
|
}
|
package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { rowShape } from "@modules/platform/database"
|
|
2
|
+
|
|
1
3
|
/** The row HIT_RATE_WINDOW answers. */
|
|
2
4
|
export interface RateWindowRow {
|
|
3
5
|
/** The hits of the caller's current window, this one included. */
|
|
4
6
|
readonly hits: number
|
|
5
7
|
}
|
|
8
|
+
|
|
9
|
+
/** Proves one row HIT_RATE_WINDOW answered is a RateWindowRow. */
|
|
10
|
+
export const isRateWindowRow: (value: unknown) => value is RateWindowRow = rowShape({ hits: "number" })
|
|
@@ -7,12 +7,12 @@ import { InjectClock } from "@modules/platform/clock"
|
|
|
7
7
|
import type { Clock } from "@modules/platform/clock"
|
|
8
8
|
import { InjectReflector } from "@modules/platform/composition"
|
|
9
9
|
import type { DoorDecorator } from "@modules/platform/composition"
|
|
10
|
-
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
10
|
+
import { InjectPrimaryEntityManager, returnedRows } from "@modules/platform/database"
|
|
11
11
|
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
12
12
|
import { requestOf } from "./execution-request.mapper"
|
|
13
13
|
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
14
14
|
import type { HttpSecurityOptions } from "./http-security.options"
|
|
15
|
-
import
|
|
15
|
+
import { isRateWindowRow } from "./persistence/rate-limit.rows"
|
|
16
16
|
import { HIT_RATE_WINDOW, PRUNE_RATE_WINDOWS } from "./persistence/rate-limit.sql"
|
|
17
17
|
|
|
18
18
|
/** The rate limit tiers of a door. */
|
|
@@ -79,7 +79,7 @@ export class RateLimitGuard implements CanActivate {
|
|
|
79
79
|
const now = this.clock.now()
|
|
80
80
|
const key = `${tier}:${requestOf(context).ip ?? "unknown"}`.slice(0, KEY_MAX)
|
|
81
81
|
const resetAt = new Date(now.getTime() + this.options.rateLimit.windowMs)
|
|
82
|
-
const rows
|
|
82
|
+
const rows = returnedRows(await this.entityManager.query(HIT_RATE_WINDOW, [key, now, resetAt]), isRateWindowRow)
|
|
83
83
|
const hits = rows[0]?.hits ?? Number.POSITIVE_INFINITY
|
|
84
84
|
if (hits === 1) await this.entityManager.query(PRUNE_RATE_WINDOWS, [now])
|
|
85
85
|
if (hits <= limit) return true
|
|
@@ -7,12 +7,15 @@ export enum DatabaseErrorCode {
|
|
|
7
7
|
ConnectionUndeclared = "DATABASE_CONNECTION_UNDECLARED",
|
|
8
8
|
/** A dynamic SQL identifier was not on the names its caller allows. */
|
|
9
9
|
IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
|
|
10
|
+
/** A RETURNING statement answered something other than rows of the shape its caller expects. */
|
|
11
|
+
ReturningRowRejected = "DATABASE_RETURNING_ROW_REJECTED",
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** How each database code travels. */
|
|
13
15
|
export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
|
|
14
16
|
[DatabaseErrorCode.ConnectionUndeclared]: "internal",
|
|
15
17
|
[DatabaseErrorCode.IdentifierRejected]: "internal",
|
|
18
|
+
[DatabaseErrorCode.ReturningRowRejected]: "internal",
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/** The one error class of the database capability. */
|
|
@@ -4,4 +4,5 @@ export { DatabaseProbeService } from "./database-probe.service"
|
|
|
4
4
|
export type { DatabaseConnectionOptions } from "./database.options"
|
|
5
5
|
export { PRIMARY_DATA_SOURCE } from "./database.port"
|
|
6
6
|
export { DATABASE_ERROR_KINDS } from "./errors/database.error"
|
|
7
|
+
export { returnedRows, rowShape } from "./returned-rows.policy"
|
|
7
8
|
export { parsePrimaryDatabaseConfig } from "./primary.config"
|
package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"
|
|
|
5
5
|
import { Test } from "@nestjs/testing"
|
|
6
6
|
import { In, LessThanOrEqual, MoreThan } from "typeorm"
|
|
7
7
|
import { FakeClock, mock, mockEntityManager, queueQueryBuilders } from "@starci/jest-preset"
|
|
8
|
-
import type { MockEntityManager } from "@starci/jest-preset"
|
|
8
|
+
import type { FakeQueryBuilder, MockEntityManager } from "@starci/jest-preset"
|
|
9
9
|
import { CLOCK } from "@modules/platform/clock"
|
|
10
10
|
import { PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
11
11
|
import { HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
@@ -23,20 +23,31 @@ const options: HttpSecurityOptions = {
|
|
|
23
23
|
webhooks: {},
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** The caller's
|
|
26
|
+
/** The caller's window holding `hits`. */
|
|
27
27
|
const windowOf = (hits: number): RateWindowEntity => ({ key: "default:127.0.0.1", hits, resetAt: new Date(RESET_AT) })
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/** A store with a live window of the caller, and the builder of the UPDATE that counted this hit in it. */
|
|
30
|
+
interface LiveWindow {
|
|
31
|
+
readonly entityManager: MockEntityManager
|
|
32
|
+
readonly counting: FakeQueryBuilder | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A store whose live window of the caller holds `hits` after this hit: the counting UPDATE returns them. */
|
|
36
|
+
const liveWindow = (hits: number): LiveWindow => {
|
|
37
|
+
const entityManager = mockEntityManager()
|
|
38
|
+
const [counting] = queueQueryBuilders(entityManager, [{ execute: { raw: [{ hits }] } }])
|
|
39
|
+
return { entityManager, counting }
|
|
40
|
+
}
|
|
32
41
|
|
|
33
42
|
/** 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
43
|
const OPENING_WINDOW = {
|
|
35
|
-
increment: [RateWindowEntity, { affected: 0 }],
|
|
36
44
|
update: [RateWindowEntity, { affected: 0 }],
|
|
37
45
|
find: [RateWindowEntity, []],
|
|
38
46
|
} as const
|
|
39
47
|
|
|
48
|
+
/** The counting UPDATE of a hit that finds no live window: it returns no row. */
|
|
49
|
+
const NO_LIVE_WINDOW = { execute: { raw: [] } } as const
|
|
50
|
+
|
|
40
51
|
const build = async (entityManager: MockEntityManager, tier?: RateTier, exceeded?: RateLimitExceeded) => {
|
|
41
52
|
const reflector = mock<Reflector>()
|
|
42
53
|
reflector.getAllAndOverride.mockImplementation((key: unknown) =>
|
|
@@ -76,7 +87,7 @@ describe("RateLimit", () => {
|
|
|
76
87
|
it("marks a door with the tier it declares, and the guard counts it under that tier's limit", async () => {
|
|
77
88
|
class Door {}
|
|
78
89
|
RateLimit(RateTier.Strict)(Door)
|
|
79
|
-
const entityManager =
|
|
90
|
+
const { entityManager, counting } = liveWindow(2)
|
|
80
91
|
const { guard, reflector } = await build(entityManager, RateTier.Strict)
|
|
81
92
|
const handler = () => undefined
|
|
82
93
|
|
|
@@ -85,12 +96,7 @@ describe("RateLimit", () => {
|
|
|
85
96
|
code: HttpSecurityErrorCode.RateLimited,
|
|
86
97
|
})
|
|
87
98
|
expect(reflector.getAllAndOverride).toHaveBeenCalledWith("platform.http-security.rate-tier", [handler, Door])
|
|
88
|
-
expect(
|
|
89
|
-
RateWindowEntity,
|
|
90
|
-
{ key: "strict:127.0.0.1", resetAt: MoreThan(new Date(AT)) },
|
|
91
|
-
"hits",
|
|
92
|
-
1,
|
|
93
|
-
)
|
|
99
|
+
expect(counting?.callsOf("where")).toEqual([[{ key: "strict:127.0.0.1", resetAt: MoreThan(new Date(AT)) }]])
|
|
94
100
|
})
|
|
95
101
|
|
|
96
102
|
it("marks a redirect-only door with its exceeded redirect beside the tier", () => {
|
|
@@ -113,30 +119,31 @@ describe("RateLimit", () => {
|
|
|
113
119
|
|
|
114
120
|
describe("RateLimitGuard", () => {
|
|
115
121
|
it("admits a caller under the default limit of an unmarked door, counting in the shared live window", async () => {
|
|
116
|
-
const entityManager =
|
|
122
|
+
const { entityManager, counting } = liveWindow(2)
|
|
117
123
|
const { guard } = await build(entityManager)
|
|
118
124
|
|
|
119
125
|
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
120
|
-
expect(
|
|
126
|
+
expect(counting?.callsOf("update")).toEqual([[]])
|
|
127
|
+
expect(counting?.callsOf("returning")).toEqual([["hits"]])
|
|
128
|
+
expect(counting?.callsOf("where")).toEqual([[{ key: "default:127.0.0.1", resetAt: MoreThan(new Date(AT)) }]])
|
|
129
|
+
expect(entityManager.update).not.toHaveBeenCalled()
|
|
121
130
|
})
|
|
122
131
|
|
|
123
132
|
it("refuses a caller whose shared window is over the default limit", async () => {
|
|
124
|
-
const { guard } = await build(
|
|
133
|
+
const { guard } = await build(liveWindow(3).entityManager)
|
|
125
134
|
|
|
126
135
|
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
127
136
|
code: HttpSecurityErrorCode.RateLimited,
|
|
128
137
|
})
|
|
129
138
|
})
|
|
130
139
|
|
|
131
|
-
it("refuses
|
|
132
|
-
const entityManager = mockEntityManager(
|
|
133
|
-
|
|
134
|
-
findOneBy: [RateWindowEntity, null],
|
|
135
|
-
})
|
|
140
|
+
it("refuses to count from a returned row that is not the window's hits", async () => {
|
|
141
|
+
const entityManager = mockEntityManager()
|
|
142
|
+
queueQueryBuilders(entityManager, [{ execute: { raw: [{ hits: "2" }] } }])
|
|
136
143
|
const { guard } = await build(entityManager)
|
|
137
144
|
|
|
138
145
|
await expect(guard.canActivate(contextOf("127.0.0.1"))).rejects.toMatchObject({
|
|
139
|
-
code:
|
|
146
|
+
code: "DATABASE_RETURNING_ROW_REJECTED",
|
|
140
147
|
})
|
|
141
148
|
})
|
|
142
149
|
})
|
|
@@ -145,11 +152,11 @@ describe("RateLimitGuard opening a window", () => {
|
|
|
145
152
|
it("restarts an ended window at one hit and prunes the ended windows", async () => {
|
|
146
153
|
const ended = windowOf(5)
|
|
147
154
|
const entityManager = mockEntityManager({
|
|
148
|
-
increment: [RateWindowEntity, {}],
|
|
149
155
|
update: [RateWindowEntity, { affected: 1 }],
|
|
150
156
|
find: [RateWindowEntity, [ended]],
|
|
151
157
|
delete: [RateWindowEntity, { affected: 1 }],
|
|
152
158
|
})
|
|
159
|
+
queueQueryBuilders(entityManager, [NO_LIVE_WINDOW])
|
|
153
160
|
const { guard } = await build(entityManager)
|
|
154
161
|
|
|
155
162
|
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
@@ -172,7 +179,7 @@ describe("RateLimitGuard opening a window", () => {
|
|
|
172
179
|
|
|
173
180
|
it("opens a missing window at one hit, ignoring a concurrent opener, and deletes nothing when no window ended", async () => {
|
|
174
181
|
const entityManager = mockEntityManager(OPENING_WINDOW)
|
|
175
|
-
const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
182
|
+
const [, opened] = queueQueryBuilders(entityManager, [NO_LIVE_WINDOW, { execute: {} }])
|
|
176
183
|
const { guard } = await build(entityManager)
|
|
177
184
|
|
|
178
185
|
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
@@ -185,11 +192,10 @@ describe("RateLimitGuard opening a window", () => {
|
|
|
185
192
|
|
|
186
193
|
it("opens a window when the restart answers no affected count", async () => {
|
|
187
194
|
const entityManager = mockEntityManager({
|
|
188
|
-
increment: [RateWindowEntity, { affected: 0 }],
|
|
189
195
|
update: [RateWindowEntity, {}],
|
|
190
196
|
find: [RateWindowEntity, []],
|
|
191
197
|
})
|
|
192
|
-
const [opened] = queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
198
|
+
const [, opened] = queueQueryBuilders(entityManager, [NO_LIVE_WINDOW, { execute: {} }])
|
|
193
199
|
const { guard } = await build(entityManager)
|
|
194
200
|
|
|
195
201
|
await expect(guard.canActivate(contextOf("127.0.0.1"))).resolves.toBe(true)
|
|
@@ -199,20 +205,22 @@ describe("RateLimitGuard opening a window", () => {
|
|
|
199
205
|
|
|
200
206
|
describe("RateLimitGuard keys", () => {
|
|
201
207
|
it("counts a request without an address under the unknown key", async () => {
|
|
202
|
-
const entityManager =
|
|
208
|
+
const { entityManager, counting } = liveWindow(2)
|
|
203
209
|
const { guard } = await build(entityManager)
|
|
204
210
|
|
|
205
211
|
await expect(guard.canActivate(contextOf(undefined))).resolves.toBe(true)
|
|
206
|
-
expect(
|
|
212
|
+
expect(counting?.callsOf("where")).toEqual([[{ key: "default:unknown", resetAt: MoreThan(new Date(AT)) }]])
|
|
207
213
|
})
|
|
208
214
|
|
|
209
215
|
it("bounds the window key to the length the table stores", async () => {
|
|
210
|
-
const entityManager =
|
|
216
|
+
const { entityManager, counting } = liveWindow(2)
|
|
211
217
|
const { guard } = await build(entityManager)
|
|
212
218
|
|
|
213
219
|
await guard.canActivate(contextOf("f".repeat(300)))
|
|
214
220
|
|
|
215
|
-
expect(
|
|
221
|
+
expect(counting?.callsOf("where")).toEqual([
|
|
222
|
+
[{ key: `default:${"f".repeat(120)}`, resetAt: MoreThan(new Date(AT)) }],
|
|
223
|
+
])
|
|
216
224
|
})
|
|
217
225
|
})
|
|
218
226
|
|
|
@@ -226,7 +234,7 @@ describe("RateLimitGuard on a redirect-only door", () => {
|
|
|
226
234
|
}
|
|
227
235
|
|
|
228
236
|
it("answers the door's own 303 when the caller is over the limit, and still refuses", async () => {
|
|
229
|
-
const { guard } = await build(
|
|
237
|
+
const { guard } = await build(liveWindow(2).entityManager, RateTier.Strict, EXCEEDED)
|
|
230
238
|
const { response, redirect } = responseOf()
|
|
231
239
|
|
|
232
240
|
await expect(
|
|
@@ -237,7 +245,7 @@ describe("RateLimitGuard on a redirect-only door", () => {
|
|
|
237
245
|
|
|
238
246
|
it("writes no redirect while the caller is under the limit", async () => {
|
|
239
247
|
const entityManager = mockEntityManager(OPENING_WINDOW)
|
|
240
|
-
queueQueryBuilders(entityManager, [{ execute: {} }])
|
|
248
|
+
queueQueryBuilders(entityManager, [NO_LIVE_WINDOW, { execute: {} }])
|
|
241
249
|
const { guard } = await build(entityManager, RateTier.Strict, EXCEEDED)
|
|
242
250
|
const { response, redirect } = responseOf()
|
|
243
251
|
|
|
@@ -248,7 +256,7 @@ describe("RateLimitGuard on a redirect-only door", () => {
|
|
|
248
256
|
})
|
|
249
257
|
|
|
250
258
|
it("refuses a GraphQL operation over the limit without writing a redirect", async () => {
|
|
251
|
-
const { guard } = await build(
|
|
259
|
+
const { guard } = await build(liveWindow(2).entityManager, RateTier.Strict, EXCEEDED)
|
|
252
260
|
const { response, redirect } = responseOf()
|
|
253
261
|
const context = contextOf("127.0.0.1", () => undefined, class Door {}, response)
|
|
254
262
|
context.getType.mockReturnValue("graphql")
|
|
@@ -8,7 +8,7 @@ import { InjectClock } from "@modules/platform/clock"
|
|
|
8
8
|
import type { Clock } from "@modules/platform/clock"
|
|
9
9
|
import { InjectReflector } from "@modules/platform/composition"
|
|
10
10
|
import type { DoorDecorator } from "@modules/platform/composition"
|
|
11
|
-
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
11
|
+
import { InjectPrimaryEntityManager, returnedRows, rowShape } from "@modules/platform/database"
|
|
12
12
|
import { HttpSecurityError, HttpSecurityErrorCode } from "./errors/http-security.error"
|
|
13
13
|
import { requestOf } from "./execution-request.mapper"
|
|
14
14
|
import { InjectHttpSecurityOptions } from "./http-security.decorators"
|
|
@@ -35,6 +35,8 @@ const RATE_EXCEEDED_KEY = "platform.http-security.rate-exceeded"
|
|
|
35
35
|
const KEY_MAX = 128
|
|
36
36
|
/** The most ended windows one fresh window prunes. */
|
|
37
37
|
const PRUNE_BATCH = 100
|
|
38
|
+
/** The row the counting update returns: the hits of the live window, this one included. */
|
|
39
|
+
const isCountedWindow = rowShape({ hits: "number" })
|
|
38
40
|
|
|
39
41
|
/** A same-site path: one leading slash, never a protocol-relative `//` or `/\` that leaves the site. */
|
|
40
42
|
const isSameSitePath = (location: string): boolean =>
|
|
@@ -91,16 +93,20 @@ export class RateLimitGuard implements CanActivate {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/**
|
|
94
|
-
* Counts one hit in the caller's window and answers the hits it holds: a live window grows by one in one
|
|
95
|
-
* an ended window restarts at one hit ending at `resetAt`, and a missing one opens at one hit (a
|
|
96
|
-
* wins and this hit is its first).
|
|
96
|
+
* Counts one hit in the caller's window and answers the hits it holds: a live window grows by one in one UPDATE that
|
|
97
|
+
* returns its hits, an ended window restarts at one hit ending at `resetAt`, and a missing one opens at one hit (a
|
|
98
|
+
* concurrent opener's row wins and this hit is its first).
|
|
97
99
|
*/
|
|
98
100
|
private async hit(key: string, now: Date, resetAt: Date): Promise<number> {
|
|
99
|
-
const counted = await this.entityManager
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
const counted = await this.entityManager
|
|
102
|
+
.createQueryBuilder(RateWindowEntity, "window")
|
|
103
|
+
.update()
|
|
104
|
+
.set({ hits: () => "hits + 1" })
|
|
105
|
+
.where({ key, resetAt: MoreThan(now) })
|
|
106
|
+
.returning("hits")
|
|
107
|
+
.execute()
|
|
108
|
+
const [live] = returnedRows(counted.raw, isCountedWindow)
|
|
109
|
+
if (live !== undefined) return live.hits
|
|
104
110
|
const restarted = await this.entityManager.update(
|
|
105
111
|
RateWindowEntity,
|
|
106
112
|
{ key, resetAt: LessThanOrEqual(now) },
|