@zerotal/core 1.0.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/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- package/src/view/jsx-runtime.ts +233 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named rate-limiter registry and its fluent definition builder. Limiters
|
|
3
|
+
* are defined once at boot (`RateLimiter.for('api').limit(…).register()`) and
|
|
4
|
+
* later attached to routes or checked manually as `ThrottleMiddleware`.
|
|
5
|
+
*/
|
|
6
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
7
|
+
import { ThrottleMiddleware } from "./ThrottleMiddleware.ts";
|
|
8
|
+
import type { ThrottleOptions } from "./ThrottleMiddleware.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Fluent rate-limiter definition.
|
|
12
|
+
* Build one via `RateLimiter.for('name')` then register with `.register()`.
|
|
13
|
+
*/
|
|
14
|
+
export class LimiterDefinition {
|
|
15
|
+
private _max = 60;
|
|
16
|
+
private _window = 60;
|
|
17
|
+
private _keyFn?: (ctx: HttpContext) => string;
|
|
18
|
+
|
|
19
|
+
constructor(private readonly _name: string) {}
|
|
20
|
+
|
|
21
|
+
/** Maximum number of requests in the window. */
|
|
22
|
+
limit(max: number): this {
|
|
23
|
+
this._max = max;
|
|
24
|
+
return this;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Window duration in seconds. */
|
|
28
|
+
every(seconds: number): this {
|
|
29
|
+
this._window = seconds;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Custom key resolver — defaults to client IP. */
|
|
34
|
+
by(fn: (ctx: HttpContext) => string): this {
|
|
35
|
+
this._keyFn = fn;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Key by the authenticated user's ID.
|
|
41
|
+
* Unauthenticated requests fall back to the client IP so they are still
|
|
42
|
+
* rate-limited independently from each other.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* RateLimiter.for('api').limit(1000).every(3600).byUser().register();
|
|
46
|
+
*/
|
|
47
|
+
byUser(): this {
|
|
48
|
+
this._keyFn = (ctx) => {
|
|
49
|
+
const user = (ctx as unknown as Record<string, unknown>).user as { id?: number } | undefined;
|
|
50
|
+
return user?.id !== undefined ? `user:${user.id}` : `ip:${_resolveIp(ctx)}`;
|
|
51
|
+
};
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Key by an API key header value.
|
|
57
|
+
* Requests that omit the header fall back to client IP.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* RateLimiter.for('api').limit(500).every(60).byApiKey('x-api-key').register();
|
|
61
|
+
*/
|
|
62
|
+
byApiKey(header = "x-api-key"): this {
|
|
63
|
+
this._keyFn = (ctx) => {
|
|
64
|
+
const key = ctx.header(header);
|
|
65
|
+
return key ? `apikey:${key}` : `ip:${_resolveIp(ctx)}`;
|
|
66
|
+
};
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Key by client IP address (explicitly named; this is already the default).
|
|
72
|
+
* Useful to make intent explicit when combining with other `.by*()` calls
|
|
73
|
+
* through the fluent API.
|
|
74
|
+
*/
|
|
75
|
+
byIp(): this {
|
|
76
|
+
this._keyFn = (ctx) => `ip:${_resolveIp(ctx)}`;
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Register this limiter with the global RateLimiter registry. */
|
|
81
|
+
register(): this {
|
|
82
|
+
RateLimiter._register(this._name, this);
|
|
83
|
+
return this;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a dedicated `ThrottleMiddleware` **subclass** for this definition.
|
|
88
|
+
*
|
|
89
|
+
* A subclass rather than a bare instance for two reasons. First, routes and
|
|
90
|
+
* `Pipeline.through()` take middleware *classes* and call `new PipeClass()`; handing them an
|
|
91
|
+
* instance threw `TypeError: ThrottleMiddleware is not a constructor` on every request.
|
|
92
|
+
* Second, hit counters are keyed on the concrete class, so giving each named limiter its own
|
|
93
|
+
* subclass is what keeps `login` and `api` counting into separate buckets.
|
|
94
|
+
*/
|
|
95
|
+
toMiddlewareClass(): new () => ThrottleMiddleware {
|
|
96
|
+
const options: ThrottleOptions = {
|
|
97
|
+
maxAttempts: this._max,
|
|
98
|
+
windowSeconds: this._window,
|
|
99
|
+
};
|
|
100
|
+
if (this._keyFn) options.keyResolver = this._keyFn;
|
|
101
|
+
return ThrottleMiddleware.with(options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build a `ThrottleMiddleware` instance from this definition.
|
|
106
|
+
*
|
|
107
|
+
* Prefer {@link toMiddlewareClass} for anything that goes into a route or a pipeline. This
|
|
108
|
+
* remains for the imperative API (`RateLimiter.tooManyAttempts`, `resetFor`), which needs a
|
|
109
|
+
* live object; it shares counters with the class because both are the same subclass.
|
|
110
|
+
*/
|
|
111
|
+
toMiddleware(): ThrottleMiddleware {
|
|
112
|
+
const Cls = this.toMiddlewareClass();
|
|
113
|
+
return new Cls();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Resolve the best available client IP from the context. */
|
|
118
|
+
function _resolveIp(ctx: HttpContext): string {
|
|
119
|
+
return (
|
|
120
|
+
ctx.ip() ??
|
|
121
|
+
ctx.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
122
|
+
ctx.header("x-real-ip") ??
|
|
123
|
+
"unknown"
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Named rate-limiter registry.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* // In a ServiceProvider (boot time):
|
|
132
|
+
* RateLimiter.for('api')
|
|
133
|
+
* .limit(120).every(60)
|
|
134
|
+
* .by(ctx => String(ctx.user?.id ?? ctx.request.headers.get('x-forwarded-for')))
|
|
135
|
+
* .register();
|
|
136
|
+
*
|
|
137
|
+
* RateLimiter.for('login').limit(5).every(60).register();
|
|
138
|
+
*
|
|
139
|
+
* // On a route:
|
|
140
|
+
* Router.post('/login', AuthController, 'login', [
|
|
141
|
+
* RateLimiter.middleware('login'),
|
|
142
|
+
* ]);
|
|
143
|
+
*
|
|
144
|
+
* // Manual check:
|
|
145
|
+
* if (RateLimiter.tooManyAttempts('login', ctx)) {
|
|
146
|
+
* ctx.response = Response.json({ message: 'Too Many Requests' }, { status: 429 });
|
|
147
|
+
* return;
|
|
148
|
+
* }
|
|
149
|
+
*/
|
|
150
|
+
export class RateLimiter {
|
|
151
|
+
private static _definitions = new Map<string, LimiterDefinition>();
|
|
152
|
+
// Per-named-limiter ThrottleMiddleware subclasses (keyed by name). One class per limiter
|
|
153
|
+
// keeps their hit counters isolated, since the store is keyed on the concrete class.
|
|
154
|
+
private static _middlewareClasses = new Map<string, new () => ThrottleMiddleware>();
|
|
155
|
+
// A live instance of each class, for the imperative tooManyAttempts()/resetFor() API.
|
|
156
|
+
// Shares counters with the class above — same constructor, same bucket.
|
|
157
|
+
private static _middlewares = new Map<string, ThrottleMiddleware>();
|
|
158
|
+
|
|
159
|
+
/** @internal Called by `LimiterDefinition.register()`. */
|
|
160
|
+
static _register(name: string, definition: LimiterDefinition): void {
|
|
161
|
+
const Cls = definition.toMiddlewareClass();
|
|
162
|
+
RateLimiter._definitions.set(name, definition);
|
|
163
|
+
RateLimiter._middlewareClasses.set(name, Cls);
|
|
164
|
+
RateLimiter._middlewares.set(name, new Cls());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Start building a named limiter.
|
|
169
|
+
* Call `.register()` at the end to make it available globally.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* RateLimiter.for('api').limit(100).every(60).register();
|
|
173
|
+
*/
|
|
174
|
+
static for(name: string): LimiterDefinition {
|
|
175
|
+
return new LimiterDefinition(name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get the ThrottleMiddleware for a registered named limiter.
|
|
180
|
+
* Throws if the limiter was never registered.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* Router.post('/login', AuthController, 'login', [RateLimiter.middleware('login')]);
|
|
184
|
+
*/
|
|
185
|
+
static middleware(name: string): new () => ThrottleMiddleware {
|
|
186
|
+
const Cls = RateLimiter._middlewareClasses.get(name);
|
|
187
|
+
if (!Cls) throw new Error(`[Zerotal] RateLimiter "${name}" has not been registered.`);
|
|
188
|
+
return Cls;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The live `ThrottleMiddleware` instance backing a registered limiter.
|
|
193
|
+
*
|
|
194
|
+
* Only needed when you want to call `handle()`/`reset()` directly. Route and pipeline
|
|
195
|
+
* registration wants {@link middleware}, which returns the class.
|
|
196
|
+
*
|
|
197
|
+
* @internal
|
|
198
|
+
*/
|
|
199
|
+
static _instance(name: string): ThrottleMiddleware {
|
|
200
|
+
const middleware = RateLimiter._middlewares.get(name);
|
|
201
|
+
if (!middleware) throw new Error(`[Zerotal] RateLimiter "${name}" has not been registered.`);
|
|
202
|
+
return middleware;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Manually record a hit and check whether the limit is exceeded.
|
|
207
|
+
* Returns `true` when the caller should be throttled.
|
|
208
|
+
*
|
|
209
|
+
* Unlike the middleware, this does NOT automatically send a 429 response —
|
|
210
|
+
* the caller decides how to handle the throttle condition.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* if (RateLimiter.tooManyAttempts('login', ctx)) {
|
|
214
|
+
* ctx.response = Response.json({ message: 'Too Many Requests' }, { status: 429 });
|
|
215
|
+
* return;
|
|
216
|
+
* }
|
|
217
|
+
*/
|
|
218
|
+
static async tooManyAttempts(name: string, ctx: HttpContext): Promise<boolean> {
|
|
219
|
+
const middleware = RateLimiter._middlewares.get(name);
|
|
220
|
+
if (!middleware) throw new Error(`[Zerotal] RateLimiter "${name}" has not been registered.`);
|
|
221
|
+
|
|
222
|
+
let throttled = false;
|
|
223
|
+
await middleware.handle(ctx, async () => {
|
|
224
|
+
// Reaching this callback means the request was not throttled.
|
|
225
|
+
ctx.response = new Response("ok");
|
|
226
|
+
return ctx.response;
|
|
227
|
+
});
|
|
228
|
+
throttled = ctx.response?.status === 429;
|
|
229
|
+
// Discard the synthetic "ok" response so the real handler can set its own.
|
|
230
|
+
ctx.response = undefined;
|
|
231
|
+
return throttled;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reset the rate-limit counter for a specific key within a named limiter.
|
|
236
|
+
*
|
|
237
|
+
* Use this after a successful action to allow the actor to start fresh —
|
|
238
|
+
* e.g. clear failed login attempts after a successful authentication.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* // In your LoginController:
|
|
242
|
+
* await RateLimiter.resetFor('login', ctx);
|
|
243
|
+
*/
|
|
244
|
+
static resetFor(name: string, ctx: HttpContext): void {
|
|
245
|
+
const middleware = RateLimiter._middlewares.get(name);
|
|
246
|
+
if (!middleware) throw new Error(`[Zerotal] RateLimiter "${name}" has not been registered.`);
|
|
247
|
+
middleware.resetKey(ctx);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Remove all registered limiters (useful in tests). */
|
|
251
|
+
static clear(): void {
|
|
252
|
+
RateLimiter._definitions.clear();
|
|
253
|
+
RateLimiter._middlewares.clear();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security-headers middleware: adds a sensible baseline of protective response
|
|
3
|
+
* headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
|
|
4
|
+
* `Permissions-Policy`, optional CSP and HSTS) to every response.
|
|
5
|
+
*/
|
|
6
|
+
import type { NextFn } from "../pipeline/types.ts";
|
|
7
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
8
|
+
import { BaseMiddleware } from "./BaseMiddleware.ts";
|
|
9
|
+
import { withHeaders } from "../http/withHeaders.ts";
|
|
10
|
+
import { config } from "../helpers/config.ts";
|
|
11
|
+
|
|
12
|
+
export interface SecureHeadersOptions {
|
|
13
|
+
/**
|
|
14
|
+
* `Content-Security-Policy` header value.
|
|
15
|
+
* Omitted by default — set this to a policy appropriate for your application.
|
|
16
|
+
*/
|
|
17
|
+
contentSecurityPolicy?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `Strict-Transport-Security` max-age in seconds.
|
|
21
|
+
* Defaults to 1 year (31 536 000 s). Set to `0` to disable HSTS entirely.
|
|
22
|
+
* Only emitted when `secure: true` to avoid HSTS issues in plain-HTTP dev environments.
|
|
23
|
+
*/
|
|
24
|
+
hstsMaxAge?: number;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Include `includeSubDomains` in the HSTS header. Defaults to `true`.
|
|
28
|
+
*/
|
|
29
|
+
hstsIncludeSubDomains?: boolean;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set the HSTS `preload` directive. Defaults to `false`.
|
|
33
|
+
* Only set this if you have registered the domain with the HSTS preload list.
|
|
34
|
+
*/
|
|
35
|
+
hstsPreload?: boolean;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Enable HSTS and the `Secure` flag on the XSRF-TOKEN cookie.
|
|
39
|
+
* Default `false` — set to `true` in production when serving over HTTPS.
|
|
40
|
+
*/
|
|
41
|
+
secure?: boolean;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `X-Frame-Options` value. Defaults to `'SAMEORIGIN'`.
|
|
45
|
+
* Set to `'DENY'` for maximum protection, or `false` to omit the header.
|
|
46
|
+
*/
|
|
47
|
+
frameOptions?: "DENY" | "SAMEORIGIN" | false;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `Referrer-Policy` value. Defaults to `'strict-origin-when-cross-origin'`.
|
|
51
|
+
*/
|
|
52
|
+
referrerPolicy?: string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `Permissions-Policy` header value.
|
|
56
|
+
* Defaults to a conservative policy disabling sensitive APIs.
|
|
57
|
+
*/
|
|
58
|
+
permissionsPolicy?: string | false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=()";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adds common security response headers to every request.
|
|
65
|
+
*
|
|
66
|
+
* Defaults provide a solid security baseline out of the box:
|
|
67
|
+
* - `X-Content-Type-Options: nosniff`
|
|
68
|
+
* - `X-Frame-Options: SAMEORIGIN`
|
|
69
|
+
* - `Referrer-Policy: strict-origin-when-cross-origin`
|
|
70
|
+
* - `Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()`
|
|
71
|
+
* - `Strict-Transport-Security` (only when `secure: true`)
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* // Development (HTTP)
|
|
75
|
+
* app.use([SecureHeadersMiddleware]);
|
|
76
|
+
*
|
|
77
|
+
* // Production (HTTPS)
|
|
78
|
+
* app.use([SecureHeadersMiddleware.with({ secure: true })]);
|
|
79
|
+
*
|
|
80
|
+
* // Custom CSP
|
|
81
|
+
* app.use([SecureHeadersMiddleware.with({
|
|
82
|
+
* secure: true,
|
|
83
|
+
* contentSecurityPolicy: "default-src 'self'; script-src 'self' 'nonce-{nonce}'",
|
|
84
|
+
* })]);
|
|
85
|
+
*/
|
|
86
|
+
export class SecureHeadersMiddleware extends BaseMiddleware<SecureHeadersOptions> {
|
|
87
|
+
protected options: SecureHeadersOptions;
|
|
88
|
+
constructor() {
|
|
89
|
+
super();
|
|
90
|
+
// App-level defaults from config('app.secureHeaders'); .with(...) overrides these.
|
|
91
|
+
this.options = { ...config.safe("app.secureHeaders", {} as SecureHeadersOptions) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async handle(_ctx: HttpContext, next: NextFn): Promise<Response | void> {
|
|
95
|
+
const response = await next();
|
|
96
|
+
if (!response) return;
|
|
97
|
+
|
|
98
|
+
const secure: Record<string, string> = {
|
|
99
|
+
// Always-on security headers
|
|
100
|
+
"X-Content-Type-Options": "nosniff",
|
|
101
|
+
"Referrer-Policy": this.options.referrerPolicy ?? "strict-origin-when-cross-origin",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const frameOptions = this.options.frameOptions ?? "SAMEORIGIN";
|
|
105
|
+
if (frameOptions) secure["X-Frame-Options"] = frameOptions;
|
|
106
|
+
|
|
107
|
+
const permissionsPolicy = this.options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY;
|
|
108
|
+
if (permissionsPolicy) secure["Permissions-Policy"] = permissionsPolicy;
|
|
109
|
+
|
|
110
|
+
if (this.options.contentSecurityPolicy) {
|
|
111
|
+
secure["Content-Security-Policy"] = this.options.contentSecurityPolicy;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// HSTS only over HTTPS — emitting it on HTTP can lock users out
|
|
115
|
+
if (this.options.secure) {
|
|
116
|
+
const maxAge = this.options.hstsMaxAge ?? 31_536_000;
|
|
117
|
+
if (maxAge > 0) {
|
|
118
|
+
const parts = [`max-age=${maxAge}`];
|
|
119
|
+
if (this.options.hstsIncludeSubDomains !== false) parts.push("includeSubDomains");
|
|
120
|
+
if (this.options.hstsPreload) parts.push("preload");
|
|
121
|
+
secure["Strict-Transport-Security"] = parts.join("; ");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return withHeaders(response, secure);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate-limiting middleware backed by an in-memory sliding-window counter.
|
|
3
|
+
* Returns `429 Too Many Requests` (with `Retry-After` and `X-RateLimit-*`
|
|
4
|
+
* headers) once a client exceeds its allowance within the window.
|
|
5
|
+
*/
|
|
6
|
+
import type { NextFn } from "../pipeline/types.ts";
|
|
7
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
8
|
+
import { BaseMiddleware, deepMerge } from "./BaseMiddleware.ts";
|
|
9
|
+
import { withHeaders } from "../http/withHeaders.ts";
|
|
10
|
+
import { negotiate } from "../http/negotiate.ts";
|
|
11
|
+
import { config } from "../helpers/config.ts";
|
|
12
|
+
|
|
13
|
+
export interface ThrottleOptions {
|
|
14
|
+
/** Maximum number of requests allowed within the window. */
|
|
15
|
+
maxAttempts: number;
|
|
16
|
+
/** Time window in seconds. Defaults to 60. */
|
|
17
|
+
windowSeconds?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Custom key resolver — defaults to client IP address.
|
|
20
|
+
* Use this to rate-limit by user ID, API key, route, etc.
|
|
21
|
+
*/
|
|
22
|
+
keyResolver?: (ctx: HttpContext) => string;
|
|
23
|
+
/**
|
|
24
|
+
* Number of trusted reverse proxies in front of this server.
|
|
25
|
+
*
|
|
26
|
+
* `X-Forwarded-For` is client-writable, so it is only consulted when you state how many
|
|
27
|
+
* proxies sit in front of the app — the count is what says which entry is not
|
|
28
|
+
* attacker-controlled. Without it the unspoofable socket address is used.
|
|
29
|
+
*
|
|
30
|
+
* - `undefined` (default) / `0` — no trusted proxy; key on the socket address
|
|
31
|
+
* - `1` — one trusted proxy; the client IP is the second-to-last XFF entry
|
|
32
|
+
* - `n` — the client IP is `n` entries from the right
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* // Behind one load balancer:
|
|
36
|
+
* ThrottleMiddleware.with({ maxAttempts: 60, trustedProxies: 1 })
|
|
37
|
+
*/
|
|
38
|
+
trustedProxies?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface HitRecord {
|
|
42
|
+
count: number;
|
|
43
|
+
resetsAt: number; // unix ms
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Rate-limiting middleware using an in-memory sliding window counter.
|
|
48
|
+
*
|
|
49
|
+
* Returns 429 Too Many Requests with Retry-After and X-RateLimit-* headers
|
|
50
|
+
* when a client exceeds the configured threshold within the time window.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* // Global: 120 req / min
|
|
54
|
+
* app.use([ThrottleMiddleware.with({ maxAttempts: 120, windowSeconds: 60 })]);
|
|
55
|
+
*
|
|
56
|
+
* // Per-route: 5 login attempts / min
|
|
57
|
+
* Router.post('/login', AuthController, 'login', [
|
|
58
|
+
* ThrottleMiddleware.with({ maxAttempts: 5, windowSeconds: 60 }),
|
|
59
|
+
* ]);
|
|
60
|
+
*
|
|
61
|
+
* // By authenticated user ID
|
|
62
|
+
* ThrottleMiddleware.with({
|
|
63
|
+
* maxAttempts: 1000,
|
|
64
|
+
* windowSeconds: 3600,
|
|
65
|
+
* keyResolver: (ctx) => String(ctx.user?.id ?? _clientIp(ctx.request)),
|
|
66
|
+
* })
|
|
67
|
+
*/
|
|
68
|
+
export class ThrottleMiddleware extends BaseMiddleware<ThrottleOptions> {
|
|
69
|
+
protected options: ThrottleOptions = {
|
|
70
|
+
maxAttempts: 60,
|
|
71
|
+
windowSeconds: 60,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
constructor(options: Partial<ThrottleOptions> = {}) {
|
|
75
|
+
super();
|
|
76
|
+
// App-level defaults from config('app.throttle') layer over the built-ins; explicit
|
|
77
|
+
// options (constructor arg or .with(...)) win over both. Without this read, an operator
|
|
78
|
+
// who hardened `app.throttle` in config got the built-in 60/60 and no indication why.
|
|
79
|
+
this.options = deepMerge(
|
|
80
|
+
this.options,
|
|
81
|
+
config.safe("app.throttle", {} as Partial<ThrottleOptions>),
|
|
82
|
+
);
|
|
83
|
+
this.options = deepMerge(this.options, options);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The hit counters for this middleware class.
|
|
88
|
+
*
|
|
89
|
+
* This **must not** be per-instance. The pipeline constructs a fresh pipe for every request
|
|
90
|
+
* unless the class is container-registered, and middleware never is — so an instance field
|
|
91
|
+
* was reset on each request and the limiter counted to 1 forever, silently allowing unlimited
|
|
92
|
+
* traffic through every `ThrottleMiddleware` and `RateLimiter` (including login throttles).
|
|
93
|
+
*
|
|
94
|
+
* The store is keyed on the concrete class, so each `ThrottleMiddleware.with({...})` call
|
|
95
|
+
* site — which returns its own anonymous subclass — keeps an isolated bucket, exactly as the
|
|
96
|
+
* per-instance field intended. `reset()` and `resetKey()` operate on the same shared map.
|
|
97
|
+
*/
|
|
98
|
+
private get _store(): Map<string, HitRecord> {
|
|
99
|
+
return _storeFor(this.constructor as ThrottleClass);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
103
|
+
const max = this.options.maxAttempts;
|
|
104
|
+
const windowMs = (this.options.windowSeconds ?? 60) * 1000;
|
|
105
|
+
const keyFn =
|
|
106
|
+
this.options.keyResolver ?? ((context) => _clientIp(context, this.options.trustedProxies));
|
|
107
|
+
|
|
108
|
+
const key = keyFn(http);
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
const store = this._store;
|
|
111
|
+
const existing = store.get(key);
|
|
112
|
+
|
|
113
|
+
let record: HitRecord;
|
|
114
|
+
|
|
115
|
+
if (!existing || now >= existing.resetsAt) {
|
|
116
|
+
record = { count: 1, resetsAt: now + windowMs };
|
|
117
|
+
store.set(key, record);
|
|
118
|
+
_pruneStore(store, now);
|
|
119
|
+
} else {
|
|
120
|
+
record = existing;
|
|
121
|
+
record.count++;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const remaining = Math.max(0, max - record.count);
|
|
125
|
+
const resetSeconds = Math.ceil((record.resetsAt - now) / 1000);
|
|
126
|
+
|
|
127
|
+
if (record.count > max) {
|
|
128
|
+
await negotiate(http)({
|
|
129
|
+
web: () => {
|
|
130
|
+
http.response = new Response(
|
|
131
|
+
`<!DOCTYPE html>\n<html><head><title>429 Too Many Requests</title></head>` +
|
|
132
|
+
`<body><h1>429 Too Many Requests</h1>` +
|
|
133
|
+
`<p>You have exceeded the request limit. Please retry after ${resetSeconds} seconds.</p>` +
|
|
134
|
+
`</body></html>`,
|
|
135
|
+
{ status: 429, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
json: () => {
|
|
139
|
+
http.response = Response.json({ message: "Too Many Requests" }, { status: 429 });
|
|
140
|
+
},
|
|
141
|
+
cli: (cli) => cli.text(`Rate limit exceeded. Retry after ${resetSeconds}s.`, 429),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Apply rate-limit headers uniformly regardless of channel
|
|
145
|
+
if (http.response) {
|
|
146
|
+
http.response = withHeaders(http.response, {
|
|
147
|
+
"Retry-After": String(resetSeconds),
|
|
148
|
+
"X-RateLimit-Limit": String(max),
|
|
149
|
+
"X-RateLimit-Remaining": "0",
|
|
150
|
+
"X-RateLimit-Reset": String(Math.floor(record.resetsAt / 1000)),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return http.response;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const response = await next();
|
|
158
|
+
|
|
159
|
+
// Attach informational headers to every successful response.
|
|
160
|
+
if (response) {
|
|
161
|
+
return withHeaders(response, {
|
|
162
|
+
"X-RateLimit-Limit": String(max),
|
|
163
|
+
"X-RateLimit-Remaining": String(remaining),
|
|
164
|
+
"X-RateLimit-Reset": String(Math.floor(record.resetsAt / 1000)),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Reset all counters — useful in tests. */
|
|
170
|
+
reset(): void {
|
|
171
|
+
this._store.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reset the counter for a single key derived from the given context.
|
|
176
|
+
* Used by `RateLimiter.resetFor()` to clear a specific actor's bucket
|
|
177
|
+
* (e.g. after a successful login clears failed-attempt counters).
|
|
178
|
+
*/
|
|
179
|
+
resetKey(ctx: HttpContext): void {
|
|
180
|
+
const keyFn =
|
|
181
|
+
this.options.keyResolver ?? ((context) => _clientIp(context, this.options.trustedProxies));
|
|
182
|
+
this._store.delete(keyFn(ctx));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Hard cap on distinct keys held per middleware class.
|
|
188
|
+
*
|
|
189
|
+
* The key is derived from client-controlled input (the peer address, a forwarded header, an
|
|
190
|
+
* API key, or an app-supplied `keyResolver`), so an unbounded map is a memory-exhaustion
|
|
191
|
+
* vector: a few million requests with unique keys is a few million retained records. On
|
|
192
|
+
* overflow the map is swept of expired records first, and only genuinely live buckets are
|
|
193
|
+
* evicted — oldest-reset first — so an attacker cannot cheaply flush a legitimate client's
|
|
194
|
+
* counter by flooding new keys.
|
|
195
|
+
*/
|
|
196
|
+
const _MAX_KEYS = 100_000;
|
|
197
|
+
|
|
198
|
+
/** Identity of a concrete throttle class — used only as a `WeakMap` key. */
|
|
199
|
+
type ThrottleClass = abstract new (...args: never[]) => ThrottleMiddleware;
|
|
200
|
+
|
|
201
|
+
/** Per-class hit counters. Keyed on the class so `.with()` subclasses stay isolated. */
|
|
202
|
+
const _stores = new WeakMap<ThrottleClass, Map<string, HitRecord>>();
|
|
203
|
+
|
|
204
|
+
function _storeFor(cls: ThrottleClass): Map<string, HitRecord> {
|
|
205
|
+
let store = _stores.get(cls);
|
|
206
|
+
if (!store) {
|
|
207
|
+
store = new Map<string, HitRecord>();
|
|
208
|
+
_stores.set(cls, store);
|
|
209
|
+
}
|
|
210
|
+
return store;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Keep the store bounded. Called after each insert; O(1) in the common case because the
|
|
215
|
+
* size check short-circuits until the cap is actually reached.
|
|
216
|
+
*/
|
|
217
|
+
export function _pruneStore(store: Map<string, HitRecord>, now: number): void {
|
|
218
|
+
if (store.size <= _MAX_KEYS) return;
|
|
219
|
+
for (const [key, record] of store) {
|
|
220
|
+
if (now >= record.resetsAt) store.delete(key);
|
|
221
|
+
}
|
|
222
|
+
if (store.size <= _MAX_KEYS) return;
|
|
223
|
+
// Still over after sweeping expired entries: drop the buckets that reset soonest, since
|
|
224
|
+
// those are closest to being discarded anyway.
|
|
225
|
+
const live = [...store.entries()].sort((a, b) => a[1].resetsAt - b[1].resetsAt);
|
|
226
|
+
for (let i = 0; i < live.length - _MAX_KEYS; i++) store.delete(live[i]![0]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function _clientIp(ctx: HttpContext, trustedProxies?: number): string {
|
|
230
|
+
// Default (undefined) and an explicit 0 both mean "not behind a trusted proxy": read the raw
|
|
231
|
+
// socket address, which cannot be spoofed.
|
|
232
|
+
//
|
|
233
|
+
// This used to fall through to the leftmost X-Forwarded-For entry when `trustedProxies` was
|
|
234
|
+
// undefined — and undefined is the default. Because the header was consulted *before* the
|
|
235
|
+
// socket address, even a direct connection could pick its own bucket, so rotating
|
|
236
|
+
// `X-Forwarded-For: 1.2.3.<n>` defeated every limiter built on this, including the documented
|
|
237
|
+
// login throttle. Trusting a forwarded header is now strictly opt-in, which matches how
|
|
238
|
+
// RateLimiter._resolveIp and LoginRateLimiter._ip already behaved.
|
|
239
|
+
if (trustedProxies === undefined || trustedProxies === 0) {
|
|
240
|
+
return ctx.ip() ?? ctx.header("x-real-ip") ?? "unknown";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Behind one or more *trusted* reverse proxies — parse X-Forwarded-For.
|
|
244
|
+
const forwardedFor = ctx.header("x-forwarded-for");
|
|
245
|
+
if (!forwardedFor) return ctx.ip() ?? ctx.header("x-real-ip") ?? "unknown";
|
|
246
|
+
|
|
247
|
+
const addresses = forwardedFor.split(",").map((entry) => entry.trim());
|
|
248
|
+
|
|
249
|
+
// trustedProxies > 0: client IP is that many entries from the right.
|
|
250
|
+
const clientIndex = addresses.length - 1 - trustedProxies;
|
|
251
|
+
return (clientIndex >= 0 ? addresses[clientIndex] : addresses[0]) ?? "unknown";
|
|
252
|
+
}
|