@boo-labs/server 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +287 -2
- package/index.mjs +23 -0
- package/package.json +71 -4
- package/source/application/Kernel/index.mjs +1137 -0
- package/source/application/Server/index.mjs +1201 -0
- package/source/application/configurations/Http2CleartextServerConfiguration/index.mjs +168 -0
- package/source/application/configurations/Http2SecureServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/HttpServerConfiguration/index.mjs +169 -0
- package/source/application/configurations/HttpsServerConfiguration/index.mjs +172 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/CORSOptionsValue.mjs +158 -0
- package/source/application/configurations/ServerConfiguration/Options/CORSOptions/index.mjs +87 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/CSRFOptionsValue.mjs +178 -0
- package/source/application/configurations/ServerConfiguration/Options/CSRFOptions/index.mjs +80 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/CompressOptionsValue.mjs +126 -0
- package/source/application/configurations/ServerConfiguration/Options/CompressOptions/index.mjs +81 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/CookieParserOptionsValue.mjs +107 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieParserOptions/index.mjs +82 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/CookieSessionOptionsValue.mjs +182 -0
- package/source/application/configurations/ServerConfiguration/Options/CookieSessionOptions/index.mjs +86 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/ListenOptionsValue.mjs +95 -0
- package/source/application/configurations/ServerConfiguration/Options/ListenOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/MiddlewareOption.mjs +102 -0
- package/source/application/configurations/ServerConfiguration/Options/Options.mjs +105 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/RateLimitOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/RateLimitOptions/index.mjs +84 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/RequestOptionsValue.mjs +108 -0
- package/source/application/configurations/ServerConfiguration/Options/RequestOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/ResponseOptionsValue.mjs +101 -0
- package/source/application/configurations/ServerConfiguration/Options/ResponseOptions/index.mjs +47 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/SecurityHeadersOptionsValue.mjs +272 -0
- package/source/application/configurations/ServerConfiguration/Options/SecurityHeadersOptions/index.mjs +85 -0
- package/source/application/configurations/ServerConfiguration/Options/ServerOption.mjs +50 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/StaticOptionsValue.mjs +164 -0
- package/source/application/configurations/ServerConfiguration/Options/StaticOptions/index.mjs +89 -0
- package/source/application/configurations/ServerConfiguration/index.mjs +781 -0
- package/source/application/resolvers/TransportResolver/index.mjs +119 -0
- package/source/application/use-cases/CloseServer/index.mjs +85 -0
- package/source/application/use-cases/ListenServer/index.mjs +98 -0
- package/source/core/EventSystem/index.mjs +110 -0
- package/source/core/Exchange/ExchangeContext.mjs +291 -0
- package/source/core/Exchange/Telemetry.mjs +193 -0
- package/source/core/Exchange/index.mjs +604 -0
- package/source/core/ExchangeLayerBuilder/index.mjs +133 -0
- package/source/core/IPCMain/IPCMain.mjs +507 -0
- package/source/core/IPCMain/IPCProtocol.mjs +441 -0
- package/source/core/IPCMain/IPCServerChannel.mjs +331 -0
- package/source/core/IPCMain/WebSocketAdapter.mjs +189 -0
- package/source/core/Request/BodyParser/BodyParser.mjs +197 -0
- package/source/core/Request/BodyParser/JSONParser.mjs +70 -0
- package/source/core/Request/BodyParser/MultipartParser.mjs +612 -0
- package/source/core/Request/BodyParser/URLEncodedParser.mjs +71 -0
- package/source/core/Request/RequestHeaders.mjs +177 -0
- package/source/core/Request/index.mjs +805 -0
- package/source/core/Response/ResponseHeaders.mjs +402 -0
- package/source/core/Response/index.mjs +1370 -0
- package/source/core/middlewares/CORSMiddleware/index.mjs +289 -0
- package/source/core/middlewares/CSRFMiddleware/index.mjs +284 -0
- package/source/core/middlewares/CompressMiddleware/index.mjs +460 -0
- package/source/core/middlewares/CookieParserMiddleware/index.mjs +194 -0
- package/source/core/middlewares/CookieSessionMiddleware/index.mjs +460 -0
- package/source/core/middlewares/RateLimitMiddleware/index.mjs +245 -0
- package/source/core/middlewares/SecurityHeadersMiddleware/index.mjs +151 -0
- package/source/core/middlewares/StaticMiddleware/index.mjs +628 -0
- package/source/core/routing/ExchangeLayer/ExchangeMatcher.mjs +127 -0
- package/source/core/routing/ExchangeLayer/index.mjs +254 -0
- package/source/core/routing/Pipeline/index.mjs +157 -0
- package/source/core/routing/Router/Route.mjs +184 -0
- package/source/core/routing/Router/index.mjs +523 -0
- package/source/core/routing/RouterRegister/OptionsMiddleware.mjs +147 -0
- package/source/core/routing/RouterRegister/index.mjs +150 -0
- package/source/core/routing/RouterSystem/index.mjs +225 -0
- package/source/core/value-objects/Address/index.mjs +281 -0
- package/source/core/value-objects/Endpoint/index.mjs +293 -0
- package/source/index.mjs +62 -0
- package/source/infrastructure/adapters/Http2Adapter/index.mjs +144 -0
- package/source/infrastructure/adapters/Http2CleartextAdapter/index.mjs +147 -0
- package/source/infrastructure/adapters/HttpAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/HttpsAdapter/index.mjs +124 -0
- package/source/infrastructure/adapters/TransportAdapter/createWebSocketOpenRequest.mjs +843 -0
- package/source/infrastructure/adapters/TransportAdapter/index.mjs +555 -0
- package/source/shared/Identifier/index.mjs +671 -0
- package/source/shared/constants/symbols/brands/index.mjs +45 -0
- package/source/shared/domain/UUIDV7/index.mjs +346 -0
- package/source/shared/errors/BaseError/index.mjs +157 -0
- package/source/shared/errors/IPCError/index.mjs +97 -0
- package/source/shared/errors/MiddlewareError/index.mjs +106 -0
- package/source/shared/errors/PathnamePatternError/index.mjs +103 -0
- package/source/shared/errors/RequestError/index.mjs +116 -0
- package/source/shared/errors/ResponseError/index.mjs +140 -0
- package/source/shared/errors/RouterError/index.mjs +90 -0
- package/source/shared/errors/ServerConfigurationError/index.mjs +551 -0
- package/source/shared/errors/ServerError/index.mjs +127 -0
- package/source/shared/errors/WebSocketError/index.mjs +58 -0
- package/source/shared/outcome/ErrorSchema.mjs +361 -0
- package/source/shared/outcome/Result/index.mjs +239 -0
- package/source/shared/outcome/Validation/index.mjs +208 -0
- package/source/shared/validations/Middleware/index.mjs +160 -0
- package/source/shared/validations/PathnamePattern/index.mjs +256 -0
- package/source/shared/validations/Server/NotFoundHandler/index.mjs +94 -0
- package/source/shared/validations/Server/Router/index.mjs +68 -0
- package/source/shared/validations/Server/ServerConfiguration/index.mjs +68 -0
- package/source/shared/validations/Server/SystemErrorHandler/index.mjs +94 -0
- package/source/shared/validations/ServerConfiguration/CORSOptions/index.mjs +310 -0
- package/source/shared/validations/ServerConfiguration/CSRFOptions/index.mjs +299 -0
- package/source/shared/validations/ServerConfiguration/CompressOptions/index.mjs +204 -0
- package/source/shared/validations/ServerConfiguration/CookieParserOptions/index.mjs +102 -0
- package/source/shared/validations/ServerConfiguration/CookieSessionOptions/index.mjs +332 -0
- package/source/shared/validations/ServerConfiguration/ListenOptions/index.mjs +178 -0
- package/source/shared/validations/ServerConfiguration/RateLimitOptions/index.mjs +229 -0
- package/source/shared/validations/ServerConfiguration/RequestOptions/index.mjs +132 -0
- package/source/shared/validations/ServerConfiguration/ResponseOptions/index.mjs +122 -0
- package/source/shared/validations/ServerConfiguration/SecurityHeadersOptions/index.mjs +137 -0
- package/source/shared/validations/ServerConfiguration/StaticOptions/index.mjs +248 -0
- package/types/index.d.ts +719 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Defines the CORS middleware.
|
|
4
|
+
*
|
|
5
|
+
* @description Implements Cross-Origin Resource Sharing (CORS) without
|
|
6
|
+
* external dependencies. Supports origin reflection and allow-lists,
|
|
7
|
+
* preflight handling, credentials, exposed headers, and configurable
|
|
8
|
+
* preflight caching.
|
|
9
|
+
*
|
|
10
|
+
* Configuration is expected to be validated and normalized before the
|
|
11
|
+
* middleware is created.
|
|
12
|
+
*
|
|
13
|
+
* @module CorsMiddleware
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
17
|
+
/**
|
|
18
|
+
* Represents an HTTP exchange.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {import('#Exchange').default} Exchange
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Represents an outgoing HTTP response.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {import('#Response').default} Response
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Represents a function that delegates execution to the next middleware
|
|
31
|
+
* layer.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {() => void | Promise<void>} NextFunction
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Represents normalized CORS middleware configuration.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {object} CorsOptionsData
|
|
40
|
+
* @property {true|string|string[]} origin - Allowed origin(s).
|
|
41
|
+
* @property {string[]} methods - Allowed HTTP methods for preflight responses.
|
|
42
|
+
* @property {string[]|undefined} allowedHeaders - Headers the client is allowed to send. When `undefined`, the value of Access-Control-Request-Headers is reflected.
|
|
43
|
+
* @property {string[]|undefined} exposedHeaders - Headers the client is allowed to read from the response.
|
|
44
|
+
* @property {boolean} credentials - Whether the response may include credentials.
|
|
45
|
+
* @property {number|undefined} maxAge - How long (in seconds) the preflight response may be cached.
|
|
46
|
+
* @property {boolean} preflightContinue - Whether to call `next()` on successful preflight instead of ending the response.
|
|
47
|
+
* @property {number} optionsSuccessStatus - Status code used for successful preflight responses.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
// ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
51
|
+
/**
|
|
52
|
+
* Reads a header value as a single string.
|
|
53
|
+
*
|
|
54
|
+
* @private
|
|
55
|
+
* @param {string|string[]|undefined} value - Header value to normalize.
|
|
56
|
+
* @returns {string|undefined} The first header value, or `undefined` when no value exists.
|
|
57
|
+
*/
|
|
58
|
+
function firstHeaderValue(value) {
|
|
59
|
+
return Array.isArray(value) ? value[0] : value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the value that should be written to
|
|
64
|
+
* Access-Control-Allow-Origin.
|
|
65
|
+
*
|
|
66
|
+
* @private
|
|
67
|
+
* @param {true|string|string[]} originConfig - Configured origin option.
|
|
68
|
+
* @param {string|undefined} requestOrigin - Origin header from the request.
|
|
69
|
+
* @returns {string|null} The header value to set, or `null` when the origin is not allowed.
|
|
70
|
+
*/
|
|
71
|
+
function resolveAllowOrigin(originConfig, requestOrigin) {
|
|
72
|
+
if (originConfig === true) {
|
|
73
|
+
return typeof requestOrigin === 'string' ? requestOrigin : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof originConfig === 'string') {
|
|
77
|
+
if (originConfig === '*') {
|
|
78
|
+
return '*';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return originConfig === requestOrigin ? originConfig : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof requestOrigin === 'string' && originConfig.includes(requestOrigin)) {
|
|
85
|
+
return requestOrigin;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Serializes an array of tokens into a comma-separated header value.
|
|
93
|
+
*
|
|
94
|
+
* @private
|
|
95
|
+
* @param {string[]} values - Header tokens to serialize.
|
|
96
|
+
* @returns {string} The serialized header value.
|
|
97
|
+
*/
|
|
98
|
+
function joinHeaderValues(values) {
|
|
99
|
+
return values.join(', ');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Applies the CORS headers shared by preflight and actual requests.
|
|
104
|
+
*
|
|
105
|
+
* @private
|
|
106
|
+
* @param {Response} response - Response to which the CORS headers are applied.
|
|
107
|
+
* @param {string} allowOrigin - Value for the Access-Control-Allow-Origin header.
|
|
108
|
+
* @param {boolean} credentials - Whether to allow credentials in the response.
|
|
109
|
+
*/
|
|
110
|
+
function applyBaseCorsHeaders(response, allowOrigin, credentials) {
|
|
111
|
+
response.setHeader('Access-Control-Allow-Origin', allowOrigin);
|
|
112
|
+
|
|
113
|
+
if (credentials) {
|
|
114
|
+
response.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
119
|
+
/**
|
|
120
|
+
* Implements Cross-Origin Resource Sharing (CORS).
|
|
121
|
+
*
|
|
122
|
+
* @classdesc The middleware handles both simple CORS requests and preflight
|
|
123
|
+
* (OPTIONS) requests according to the Fetch specification.
|
|
124
|
+
*
|
|
125
|
+
* When the request origin is not allowed, no CORS headers are written and
|
|
126
|
+
* processing is delegated to `next()`.
|
|
127
|
+
*/
|
|
128
|
+
class CorsMiddleware {
|
|
129
|
+
/**
|
|
130
|
+
* Normalized CORS middleware options.
|
|
131
|
+
*
|
|
132
|
+
* @type {Readonly<CorsOptionsData>}
|
|
133
|
+
*/
|
|
134
|
+
#options;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Creates a CORS middleware.
|
|
138
|
+
*
|
|
139
|
+
* The `origin` option may be `true` to reflect the request origin, a
|
|
140
|
+
* string for a fixed origin or `"*"`, or an array of allowed origins.
|
|
141
|
+
*
|
|
142
|
+
* @param {CorsOptionsData} options - Validated and normalized CORS options.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```js
|
|
146
|
+
* const middleware = new CorsMiddleware({
|
|
147
|
+
* origin: true,
|
|
148
|
+
* methods: ['GET', 'POST'],
|
|
149
|
+
* credentials: true,
|
|
150
|
+
* preflightContinue: false,
|
|
151
|
+
* optionsSuccessStatus: 204,
|
|
152
|
+
* });
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```js
|
|
157
|
+
* const middleware = new CorsMiddleware({
|
|
158
|
+
* origin: true,
|
|
159
|
+
* methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
|
160
|
+
* allowedHeaders: undefined,
|
|
161
|
+
* exposedHeaders: ['X-Request-Id'],
|
|
162
|
+
* credentials: true,
|
|
163
|
+
* maxAge: 86400,
|
|
164
|
+
* preflightContinue: false,
|
|
165
|
+
* optionsSuccessStatus: 204,
|
|
166
|
+
* });
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
constructor(options) {
|
|
170
|
+
this.#options = options;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Processes an HTTP exchange and applies CORS headers.
|
|
175
|
+
*
|
|
176
|
+
* For preflight (OPTIONS) requests the middleware may end the response
|
|
177
|
+
* early, depending on `preflightContinue`. For non-preflight requests,
|
|
178
|
+
* it applies the appropriate CORS response headers and delegates
|
|
179
|
+
* processing to `next()`.
|
|
180
|
+
*
|
|
181
|
+
* @param {Exchange} exchange - Exchange to process.
|
|
182
|
+
* @param {NextFunction} next - Function that delegates to the next middleware.
|
|
183
|
+
* @returns {Promise<void>} Resolves when request processing has completed.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* ```js
|
|
187
|
+
* await middleware.handle(exchange, next);
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
async handle(exchange, next) {
|
|
191
|
+
const { request, response } = exchange;
|
|
192
|
+
const { origin: originConfig } = this.#options;
|
|
193
|
+
|
|
194
|
+
const requestOrigin = firstHeaderValue(request.getHeader('origin'));
|
|
195
|
+
const allowOrigin = resolveAllowOrigin(originConfig, requestOrigin);
|
|
196
|
+
|
|
197
|
+
if (allowOrigin === null) {
|
|
198
|
+
await next();
|
|
199
|
+
} else {
|
|
200
|
+
if (originConfig === true || Array.isArray(originConfig)) {
|
|
201
|
+
response.vary('Origin');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (request.method === 'OPTIONS') {
|
|
205
|
+
await this.#handlePreflight(exchange, allowOrigin, next);
|
|
206
|
+
} else {
|
|
207
|
+
await this.#handleActualRequest(exchange, allowOrigin, next);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Handles preflight (OPTIONS) requests.
|
|
214
|
+
*
|
|
215
|
+
* @param {Exchange} exchange - Exchange to process.
|
|
216
|
+
* @param {string} allowOrigin - Value for the Access-Control-Allow-Origin header.
|
|
217
|
+
* @param {NextFunction} next - Function that delegates to the next middleware.
|
|
218
|
+
* @returns {Promise<void>} Resolves when request processing has completed.
|
|
219
|
+
*/
|
|
220
|
+
async #handlePreflight(exchange, allowOrigin, next) {
|
|
221
|
+
const { request, response } = exchange;
|
|
222
|
+
const {
|
|
223
|
+
methods,
|
|
224
|
+
allowedHeaders,
|
|
225
|
+
credentials,
|
|
226
|
+
maxAge,
|
|
227
|
+
preflightContinue,
|
|
228
|
+
optionsSuccessStatus,
|
|
229
|
+
} = this.#options;
|
|
230
|
+
|
|
231
|
+
const requestMethod = firstHeaderValue(request.getHeader('access-control-request-method'));
|
|
232
|
+
|
|
233
|
+
if (typeof requestMethod !== 'string' || !methods.includes(requestMethod)) {
|
|
234
|
+
await next();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
applyBaseCorsHeaders(response, allowOrigin, credentials);
|
|
239
|
+
response.setHeader('Access-Control-Allow-Methods', joinHeaderValues(methods));
|
|
240
|
+
|
|
241
|
+
if (allowedHeaders !== undefined) {
|
|
242
|
+
response.setHeader('Access-Control-Allow-Headers', joinHeaderValues(allowedHeaders));
|
|
243
|
+
} else {
|
|
244
|
+
const requestHeaders = request.getHeader('access-control-request-headers');
|
|
245
|
+
|
|
246
|
+
if (typeof requestHeaders === 'string') {
|
|
247
|
+
response.setHeader('Access-Control-Allow-Headers', requestHeaders);
|
|
248
|
+
response.vary('Access-Control-Request-Headers');
|
|
249
|
+
} else if (Array.isArray(requestHeaders) && requestHeaders.length > 0) {
|
|
250
|
+
response.setHeader('Access-Control-Allow-Headers', joinHeaderValues(requestHeaders));
|
|
251
|
+
response.vary('Access-Control-Request-Headers');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (typeof maxAge === 'number') {
|
|
256
|
+
response.setHeader('Access-Control-Max-Age', String(maxAge));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (preflightContinue) {
|
|
260
|
+
await next();
|
|
261
|
+
} else {
|
|
262
|
+
response.status(optionsSuccessStatus).end();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Handles actual (non-preflight) CORS requests.
|
|
268
|
+
*
|
|
269
|
+
* @param {Exchange} exchange - Exchange to process.
|
|
270
|
+
* @param {string} allowOrigin - Value for the Access-Control-Allow-Origin header.
|
|
271
|
+
* @param {NextFunction} next - Function that delegates to the next middleware.
|
|
272
|
+
* @returns {Promise<void>} Resolves when request processing has completed.
|
|
273
|
+
*/
|
|
274
|
+
async #handleActualRequest(exchange, allowOrigin, next) {
|
|
275
|
+
const { response } = exchange;
|
|
276
|
+
const { exposedHeaders, credentials } = this.#options;
|
|
277
|
+
|
|
278
|
+
applyBaseCorsHeaders(response, allowOrigin, credentials);
|
|
279
|
+
|
|
280
|
+
if (exposedHeaders !== undefined && exposedHeaders.length > 0) {
|
|
281
|
+
response.setHeader('Access-Control-Expose-Headers', joinHeaderValues(exposedHeaders));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
await next();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
289
|
+
export default CorsMiddleware;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Defines the CSRF middleware.
|
|
4
|
+
*
|
|
5
|
+
* @description Implements CSRF protection using the Double Submit Cookie
|
|
6
|
+
* pattern. A cryptographically secure token is stored in a cookie and must be
|
|
7
|
+
* echoed by the client in a configurable request header. Safe methods are not
|
|
8
|
+
* validated; they only ensure the cookie is present.
|
|
9
|
+
*
|
|
10
|
+
* This is a classic Double Submit Cookie implementation: it verifies that the
|
|
11
|
+
* cookie token and the header token match. It does not bind the token to a
|
|
12
|
+
* server-side secret or session and therefore does not by itself prevent an
|
|
13
|
+
* attacker who can set a cookie from choosing a known token value.
|
|
14
|
+
*
|
|
15
|
+
* The middleware is independent of session, authentication and CookieParser.
|
|
16
|
+
* It reads the Cookie header directly and writes Set-Cookie when needed.
|
|
17
|
+
*
|
|
18
|
+
* Configuration is expected to be validated and normalized before the
|
|
19
|
+
* middleware is created.
|
|
20
|
+
*
|
|
21
|
+
* @module CSRFMiddleware
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ━━ IMPORT MODULES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
25
|
+
// » IMPORT NODE MODULES
|
|
26
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
// ━━ TYPE DEFINITIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
29
|
+
/**
|
|
30
|
+
* Represents an HTTP exchange.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {import('#Exchange').default} Exchange
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Represents a function that delegates execution to the next middleware
|
|
37
|
+
* layer.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {() => void | Promise<void>} NextFunction
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Represents normalized CSRF middleware configuration.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {object} CSRFOptionsData
|
|
46
|
+
* @property {string} cookieName - Name of the CSRF cookie.
|
|
47
|
+
* @property {string} headerName - Name of the request header that must carry the token.
|
|
48
|
+
* @property {string[]} safeMethods - HTTP methods that skip CSRF validation.
|
|
49
|
+
* @property {number} tokenBytes - Number of random bytes used to generate the token.
|
|
50
|
+
* @property {string} path - Cookie Path attribute.
|
|
51
|
+
* @property {string|undefined} domain - Cookie Domain attribute.
|
|
52
|
+
* @property {boolean} secure - Whether the cookie is marked Secure.
|
|
53
|
+
* @property {'Strict'|'Lax'|'None'|undefined} sameSite - Cookie SameSite attribute.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Represents a parsed CSRF cookie.
|
|
58
|
+
*
|
|
59
|
+
* @typedef {(
|
|
60
|
+
* {state: 'absent', value: undefined} |
|
|
61
|
+
* {state: 'ambiguous', value: undefined} |
|
|
62
|
+
* {state: 'present', value: string}
|
|
63
|
+
* )} CookieResult
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
// ━━ FUNCTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
67
|
+
/**
|
|
68
|
+
* Reads a header value only when it is a single unambiguous string.
|
|
69
|
+
*
|
|
70
|
+
* @private
|
|
71
|
+
* @param {string|string[]|undefined} value - Header value from Request.
|
|
72
|
+
* @returns {string|undefined} The single header value, or `undefined` when absent or ambiguous.
|
|
73
|
+
*/
|
|
74
|
+
function readSingleHeaderValue(value) {
|
|
75
|
+
return typeof value === 'string' ? value : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generates a cryptographically secure token.
|
|
80
|
+
*
|
|
81
|
+
* @private
|
|
82
|
+
* @param {number} bytes - Number of random bytes used to generate the token.
|
|
83
|
+
* @returns {string} The token encoded as base64url without padding.
|
|
84
|
+
*/
|
|
85
|
+
function generateToken(bytes) {
|
|
86
|
+
return randomBytes(bytes).toString('base64url');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compares two tokens using a constant-time comparison.
|
|
91
|
+
*
|
|
92
|
+
* @private
|
|
93
|
+
* @param {string} left - First token.
|
|
94
|
+
* @param {string} right - Second token.
|
|
95
|
+
* @returns {boolean} Whether the tokens are equal.
|
|
96
|
+
*/
|
|
97
|
+
function tokensEqual(left, right) {
|
|
98
|
+
const leftBuffer = Buffer.from(left);
|
|
99
|
+
const rightBuffer = Buffer.from(right);
|
|
100
|
+
|
|
101
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads a cookie by exact name.
|
|
106
|
+
*
|
|
107
|
+
* Duplicate occurrences of the same cookie name are treated as ambiguous
|
|
108
|
+
* rather than selecting either the first or last occurrence.
|
|
109
|
+
*
|
|
110
|
+
* @private
|
|
111
|
+
* @param {string|undefined} header - Raw Cookie header.
|
|
112
|
+
* @param {string} name - Cookie name.
|
|
113
|
+
* @returns {CookieResult} Parsed cookie result.
|
|
114
|
+
*/
|
|
115
|
+
function readCookie(header, name) {
|
|
116
|
+
if (typeof header !== 'string' || header.length === 0) {
|
|
117
|
+
return { state: 'absent', value: undefined };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const segments = header.split(';');
|
|
121
|
+
const matchingSegments = segments.filter(segment => {
|
|
122
|
+
const separatorIndex = segment.indexOf('=');
|
|
123
|
+
|
|
124
|
+
if (separatorIndex === -1) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return segment.slice(0, separatorIndex).trim() === name;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (matchingSegments.length === 0) {
|
|
132
|
+
return { state: 'absent', value: undefined };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (matchingSegments.length > 1) {
|
|
136
|
+
return { state: 'ambiguous', value: undefined };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const separatorIndex = matchingSegments[0].indexOf('=');
|
|
140
|
+
const rawValue = matchingSegments[0].slice(separatorIndex + 1).trim();
|
|
141
|
+
const value =
|
|
142
|
+
rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"')
|
|
143
|
+
? rawValue.slice(1, -1)
|
|
144
|
+
: rawValue;
|
|
145
|
+
|
|
146
|
+
if (value.length === 0) {
|
|
147
|
+
return { state: 'absent', value: undefined };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { state: 'present', value };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Builds a Set-Cookie header value for the CSRF token cookie.
|
|
155
|
+
*
|
|
156
|
+
* The cookie is intentionally not marked HttpOnly so that browser clients
|
|
157
|
+
* can read it and place the value in the request header. The cookie is a
|
|
158
|
+
* session cookie because no Max-Age or Expires attribute is set.
|
|
159
|
+
*
|
|
160
|
+
* @private
|
|
161
|
+
* @param {string} name - Cookie name.
|
|
162
|
+
* @param {string} value - Cookie value.
|
|
163
|
+
* @param {CSRFOptionsData} options - CSRF cookie configuration.
|
|
164
|
+
* @returns {string} The Set-Cookie header value.
|
|
165
|
+
*/
|
|
166
|
+
function buildSetCookie(name, value, options) {
|
|
167
|
+
const parts = [`${name}=${value}`, `Path=${options.path}`];
|
|
168
|
+
|
|
169
|
+
if (typeof options.domain === 'string') {
|
|
170
|
+
parts.push(`Domain=${options.domain}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (options.secure) {
|
|
174
|
+
parts.push('Secure');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (typeof options.sameSite === 'string') {
|
|
178
|
+
parts.push(`SameSite=${options.sameSite}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return parts.join('; ');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ━━ MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
185
|
+
/**
|
|
186
|
+
* Protects state-changing requests against Cross-Site Request Forgery.
|
|
187
|
+
*
|
|
188
|
+
* @classdesc Implements the Double Submit Cookie pattern. Safe methods only
|
|
189
|
+
* ensure that a CSRF cookie exists. Other methods require the same token to be
|
|
190
|
+
* sent in the configured request header.
|
|
191
|
+
*
|
|
192
|
+
* Duplicate CSRF cookies and ambiguous CSRF headers are rejected.
|
|
193
|
+
*/
|
|
194
|
+
class CSRFMiddleware {
|
|
195
|
+
/**
|
|
196
|
+
* Normalized CSRF middleware options.
|
|
197
|
+
*
|
|
198
|
+
* @type {Readonly<CSRFOptionsData>}
|
|
199
|
+
*/
|
|
200
|
+
#options;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Creates a CSRF middleware.
|
|
204
|
+
*
|
|
205
|
+
* @param {CSRFOptionsData} options - Validated and normalized CSRF options.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```js
|
|
209
|
+
* const middleware = new CsrfMiddleware({
|
|
210
|
+
* cookieName: 'csrf-token',
|
|
211
|
+
* headerName: 'x-csrf-token',
|
|
212
|
+
* safeMethods: ['GET', 'HEAD', 'OPTIONS'],
|
|
213
|
+
* tokenBytes: 32,
|
|
214
|
+
* path: '/',
|
|
215
|
+
* domain: undefined,
|
|
216
|
+
* secure: true,
|
|
217
|
+
* sameSite: 'Lax',
|
|
218
|
+
* });
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
constructor(options) {
|
|
222
|
+
this.#options = options;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Processes an HTTP exchange and enforces CSRF protection.
|
|
227
|
+
*
|
|
228
|
+
* Safe methods ensure that the CSRF cookie exists. Other methods require a
|
|
229
|
+
* matching and unambiguous request header token. Validation failures end
|
|
230
|
+
* the response with status 403 without invoking `next()`.
|
|
231
|
+
*
|
|
232
|
+
* The current token is exposed on `exchange.state.csrfToken`.
|
|
233
|
+
*
|
|
234
|
+
* @param {Exchange} exchange - Exchange to process.
|
|
235
|
+
* @param {NextFunction} next - Function that delegates to the next middleware.
|
|
236
|
+
* @returns {Promise<void>} Resolves when request processing has completed.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* ```js
|
|
240
|
+
* await middleware.handle(exchange, next);
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
async handle(exchange, next) {
|
|
244
|
+
const { request, response, state } = exchange;
|
|
245
|
+
const { cookieName, headerName, safeMethods, tokenBytes } = this.#options;
|
|
246
|
+
|
|
247
|
+
const cookieHeader = readSingleHeaderValue(request.getHeader('cookie'));
|
|
248
|
+
const cookie = readCookie(cookieHeader, cookieName);
|
|
249
|
+
const method = request.method;
|
|
250
|
+
const isSafeMethod = typeof method === 'string' && safeMethods.includes(method);
|
|
251
|
+
|
|
252
|
+
if (cookie.state === 'ambiguous') {
|
|
253
|
+
response.status(403).json({ error: 'Forbidden' });
|
|
254
|
+
} else if (isSafeMethod) {
|
|
255
|
+
const token = cookie.state === 'present' ? cookie.value : generateToken(tokenBytes);
|
|
256
|
+
|
|
257
|
+
if (cookie.state !== 'present') {
|
|
258
|
+
response.appendHeader('Set-Cookie', buildSetCookie(cookieName, token, this.#options));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
state.csrfToken = token;
|
|
262
|
+
await next();
|
|
263
|
+
} else if (cookie.state !== 'present') {
|
|
264
|
+
response.status(403).json({ error: 'Forbidden' });
|
|
265
|
+
} else {
|
|
266
|
+
const headerToken = readSingleHeaderValue(request.getHeader(headerName));
|
|
267
|
+
|
|
268
|
+
const hasMatchingToken =
|
|
269
|
+
typeof headerToken === 'string' &&
|
|
270
|
+
headerToken.length > 0 &&
|
|
271
|
+
tokensEqual(cookie.value, headerToken);
|
|
272
|
+
|
|
273
|
+
if (hasMatchingToken) {
|
|
274
|
+
state.csrfToken = cookie.value;
|
|
275
|
+
await next();
|
|
276
|
+
} else {
|
|
277
|
+
response.status(403).json({ error: 'Forbidden' });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ━━ EXPORT MODULE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
284
|
+
export default CSRFMiddleware;
|