@starci/hfs 4.11.8 → 4.11.12
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 +31 -0
- package/README.md +6 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/knowledge/hfs/slots.yaml +16 -1
- package/runtime/modules/kernel/failure-codes.yaml +5 -5
- package/runtime/scripts/api/sops/lib.mjs +30 -0
- package/runtime/scripts/api/sops/update-keys.mjs +20 -0
- package/runtime/scripts/hfs/path-findings.mjs +4 -2
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
- package/runtime/scripts/hfs/secret-sops.mjs +15 -0
- package/runtime/scripts/hfs/secret-standard.mjs +54 -0
- package/runtime/scripts/hfs/secret.mjs +36 -6
- package/runtime/scripts/hfs/stack-deploy-config.mjs +11 -4
- 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 +1 -0
- package/stack/age-install.mjs +4 -3
- package/stack/context.mjs +32 -2
- package/stack/remote.mjs +4 -2
- package/stack/server-key.mjs +296 -0
- package/stack/ssh-session.mjs +7 -1
- package/sync/index.mjs +3 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +1 -2
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +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/tests/fixtures/builders/api.builder.ts +1 -0
|
@@ -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
|
+
}
|