zero-http 0.2.4 → 0.3.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/README.md +1250 -283
- package/documentation/config/db.js +25 -0
- package/documentation/config/middleware.js +44 -0
- package/documentation/config/tls.js +12 -0
- package/documentation/controllers/cookies.js +34 -0
- package/documentation/controllers/tasks.js +108 -0
- package/documentation/full-server.js +28 -177
- package/documentation/models/Task.js +21 -0
- package/documentation/public/data/api.json +404 -24
- package/documentation/public/data/docs.json +1139 -0
- package/documentation/public/data/examples.json +80 -2
- package/documentation/public/data/options.json +23 -8
- package/documentation/public/index.html +138 -99
- package/documentation/public/scripts/app.js +1 -3
- package/documentation/public/scripts/custom-select.js +189 -0
- package/documentation/public/scripts/data-sections.js +233 -250
- package/documentation/public/scripts/playground.js +270 -0
- package/documentation/public/scripts/ui.js +4 -3
- package/documentation/public/styles.css +56 -5
- package/documentation/public/vendor/icons/compress.svg +17 -17
- package/documentation/public/vendor/icons/database.svg +21 -0
- package/documentation/public/vendor/icons/env.svg +21 -0
- package/documentation/public/vendor/icons/fetch.svg +11 -14
- package/documentation/public/vendor/icons/security.svg +15 -0
- package/documentation/public/vendor/icons/sse.svg +12 -13
- package/documentation/public/vendor/icons/static.svg +12 -26
- package/documentation/public/vendor/icons/stream.svg +7 -13
- package/documentation/public/vendor/icons/validate.svg +17 -0
- package/documentation/routes/api.js +41 -0
- package/documentation/routes/core.js +20 -0
- package/documentation/routes/playground.js +29 -0
- package/documentation/routes/realtime.js +49 -0
- package/documentation/routes/uploads.js +71 -0
- package/index.js +62 -1
- package/lib/app.js +200 -8
- package/lib/body/json.js +28 -5
- package/lib/body/multipart.js +29 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/sendError.js +1 -0
- package/lib/body/text.js +1 -1
- package/lib/body/typeMatch.js +6 -2
- package/lib/body/urlencoded.js +5 -2
- package/lib/debug.js +345 -0
- package/lib/env/index.js +440 -0
- package/lib/errors.js +231 -0
- package/lib/http/request.js +219 -1
- package/lib/http/response.js +410 -6
- package/lib/middleware/compress.js +39 -6
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +13 -2
- package/lib/middleware/csrf.js +135 -0
- package/lib/middleware/errorHandler.js +90 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +7 -2
- package/lib/middleware/rateLimit.js +12 -1
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +95 -11
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +257 -0
- package/lib/orm/adapters/json.js +215 -0
- package/lib/orm/adapters/memory.js +383 -0
- package/lib/orm/adapters/mongo.js +444 -0
- package/lib/orm/adapters/mysql.js +272 -0
- package/lib/orm/adapters/postgres.js +394 -0
- package/lib/orm/adapters/sql-base.js +142 -0
- package/lib/orm/adapters/sqlite.js +311 -0
- package/lib/orm/index.js +276 -0
- package/lib/orm/model.js +895 -0
- package/lib/orm/query.js +807 -0
- package/lib/orm/schema.js +172 -0
- package/lib/router/index.js +136 -47
- package/lib/sse/stream.js +15 -3
- package/lib/ws/connection.js +19 -3
- package/lib/ws/handshake.js +3 -0
- package/lib/ws/index.js +3 -1
- package/lib/ws/room.js +222 -0
- package/package.json +15 -5
- package/types/app.d.ts +120 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +147 -0
- package/types/fetch.d.ts +43 -0
- package/types/index.d.ts +135 -0
- package/types/middleware.d.ts +292 -0
- package/types/orm.d.ts +610 -0
- package/types/request.d.ts +99 -0
- package/types/response.d.ts +142 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +119 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cookieParser
|
|
3
|
+
* @description Cookie parsing middleware.
|
|
4
|
+
* Parses the `Cookie` header and populates `req.cookies`.
|
|
5
|
+
* Supports signed cookies, JSON cookies, secret rotation,
|
|
6
|
+
* and timing-safe signature verification.
|
|
7
|
+
*/
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
// -- Internal helpers ------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Timing-safe HMAC-SHA256 signature comparison.
|
|
14
|
+
* Prevents timing-based side-channel attacks on cookie signatures.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} data - The cookie payload.
|
|
17
|
+
* @param {string} sig - The provided signature (base64, no padding).
|
|
18
|
+
* @param {string} secret - Secret to verify against.
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
* @private
|
|
21
|
+
*/
|
|
22
|
+
function _timingSafeVerify(data, sig, secret)
|
|
23
|
+
{
|
|
24
|
+
try
|
|
25
|
+
{
|
|
26
|
+
const expected = crypto
|
|
27
|
+
.createHmac('sha256', secret)
|
|
28
|
+
.update(data)
|
|
29
|
+
.digest('base64')
|
|
30
|
+
.replace(/=+$/, '');
|
|
31
|
+
if (expected.length !== sig.length) return false;
|
|
32
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
|
|
33
|
+
}
|
|
34
|
+
catch (e) { return false; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Verify and unsign a signed cookie value.
|
|
39
|
+
* Signed cookies have the format: `s:<value>.<signature>`.
|
|
40
|
+
* All secret(s) are attempted to support key rotation.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} val - Raw cookie value.
|
|
43
|
+
* @param {string[]} secrets - Array of secrets to try.
|
|
44
|
+
* @returns {string|false} Unsigned value on success, `false` on failure.
|
|
45
|
+
* @private
|
|
46
|
+
*/
|
|
47
|
+
function _unsign(val, secrets)
|
|
48
|
+
{
|
|
49
|
+
if (typeof val !== 'string' || !val.startsWith('s:')) return val;
|
|
50
|
+
const payload = val.slice(2);
|
|
51
|
+
const dotIdx = payload.lastIndexOf('.');
|
|
52
|
+
if (dotIdx === -1) return false;
|
|
53
|
+
|
|
54
|
+
const data = payload.slice(0, dotIdx);
|
|
55
|
+
const sig = payload.slice(dotIdx + 1);
|
|
56
|
+
|
|
57
|
+
for (const s of secrets)
|
|
58
|
+
{
|
|
59
|
+
if (_timingSafeVerify(data, sig, s)) return data;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Try to parse a value as a JSON cookie (prefixed with `j:`).
|
|
66
|
+
*
|
|
67
|
+
* @param {string} val - Cookie value.
|
|
68
|
+
* @returns {*} Parsed value or original string.
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
function _parseJSONCookie(val)
|
|
72
|
+
{
|
|
73
|
+
if (typeof val !== 'string' || !val.startsWith('j:')) return val;
|
|
74
|
+
try { return JSON.parse(val.slice(2)); }
|
|
75
|
+
catch (e) { return val; }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -- Middleware factory ----------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a cookie parsing middleware.
|
|
82
|
+
*
|
|
83
|
+
* Features:
|
|
84
|
+
* - Signed cookies with HMAC-SHA256 and timing-safe verification
|
|
85
|
+
* - Secret rotation (array of secrets, newest first)
|
|
86
|
+
* - JSON cookies (`j:` prefix, auto-parsed)
|
|
87
|
+
* - `req.secret` / `req.secrets` exposed for downstream middleware
|
|
88
|
+
* - URI-decode toggle
|
|
89
|
+
*
|
|
90
|
+
* @param {string|string[]} [secret] - Secret(s) for signing / verifying cookies.
|
|
91
|
+
* @param {object} [opts]
|
|
92
|
+
* @param {boolean} [opts.decode=true] - URI-decode cookie values.
|
|
93
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* app.use(cookieParser());
|
|
97
|
+
* app.use(cookieParser('my-secret'));
|
|
98
|
+
* app.use(cookieParser(['new-secret', 'old-secret'])); // key rotation
|
|
99
|
+
*/
|
|
100
|
+
function cookieParser(secret, opts = {})
|
|
101
|
+
{
|
|
102
|
+
const secrets = secret
|
|
103
|
+
? (Array.isArray(secret) ? secret : [secret])
|
|
104
|
+
: [];
|
|
105
|
+
const decode = opts.decode !== false;
|
|
106
|
+
|
|
107
|
+
return (req, res, next) =>
|
|
108
|
+
{
|
|
109
|
+
const header = req.headers.cookie;
|
|
110
|
+
req.cookies = {};
|
|
111
|
+
req.signedCookies = {};
|
|
112
|
+
|
|
113
|
+
// Expose secret(s) for downstream use (res.cookie signed:true, csrf, etc.)
|
|
114
|
+
if (secrets.length)
|
|
115
|
+
{
|
|
116
|
+
req.secret = secrets[0];
|
|
117
|
+
req.secrets = secrets;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!header)
|
|
121
|
+
{
|
|
122
|
+
return next();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pairs = header.split(';');
|
|
126
|
+
for (const pair of pairs)
|
|
127
|
+
{
|
|
128
|
+
const eqIdx = pair.indexOf('=');
|
|
129
|
+
if (eqIdx === -1) continue;
|
|
130
|
+
|
|
131
|
+
const name = pair.slice(0, eqIdx).trim();
|
|
132
|
+
let val = pair.slice(eqIdx + 1).trim();
|
|
133
|
+
|
|
134
|
+
// Remove surrounding quotes if any
|
|
135
|
+
if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"')
|
|
136
|
+
{
|
|
137
|
+
val = val.slice(1, -1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// URI decode
|
|
141
|
+
if (decode)
|
|
142
|
+
{
|
|
143
|
+
try { val = decodeURIComponent(val); } catch (e) { /* keep raw */ }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Signed cookies → verify, then JSON-parse if j: prefixed
|
|
147
|
+
if (secrets.length > 0 && val.startsWith('s:'))
|
|
148
|
+
{
|
|
149
|
+
const unsigned = _unsign(val, secrets);
|
|
150
|
+
if (unsigned !== false)
|
|
151
|
+
{
|
|
152
|
+
req.signedCookies[name] = _parseJSONCookie(unsigned);
|
|
153
|
+
}
|
|
154
|
+
// Failed-verification signed cookies are silently dropped
|
|
155
|
+
}
|
|
156
|
+
// JSON cookies → auto-parse
|
|
157
|
+
else if (val.startsWith('j:'))
|
|
158
|
+
{
|
|
159
|
+
req.cookies[name] = _parseJSONCookie(val);
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
{
|
|
163
|
+
req.cookies[name] = val;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
next();
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// -- Static helpers --------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sign a cookie value with the given secret.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} val - Cookie value to sign.
|
|
177
|
+
* @param {string} secret - Signing secret.
|
|
178
|
+
* @returns {string} Signed value in format `s:<value>.<signature>`.
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* const signed = cookieParser.sign('hello', 'my-secret');
|
|
182
|
+
* // => 's:hello.DGDyS...'
|
|
183
|
+
*/
|
|
184
|
+
cookieParser.sign = function sign(val, secret)
|
|
185
|
+
{
|
|
186
|
+
const sig = crypto
|
|
187
|
+
.createHmac('sha256', secret)
|
|
188
|
+
.update(String(val))
|
|
189
|
+
.digest('base64')
|
|
190
|
+
.replace(/=+$/, '');
|
|
191
|
+
return `s:${val}.${sig}`;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Verify and unsign a signed cookie value.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} val - Signed cookie value (`s:data.sig`).
|
|
198
|
+
* @param {string|string[]} secret - Secret or array of secrets (for rotation).
|
|
199
|
+
* @returns {string|false} Unsigned value on success, `false` on failure.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* const value = cookieParser.unsign('s:hello.DGDyS...', 'my-secret');
|
|
203
|
+
* // => 'hello' or false
|
|
204
|
+
*/
|
|
205
|
+
cookieParser.unsign = function unsign(val, secret)
|
|
206
|
+
{
|
|
207
|
+
const secrets = Array.isArray(secret) ? secret : [secret];
|
|
208
|
+
return _unsign(val, secrets);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Serialize a value as a JSON cookie string (prefixed with `j:`).
|
|
213
|
+
*
|
|
214
|
+
* @param {*} val - Value to serialize (object, array, etc.).
|
|
215
|
+
* @returns {string} JSON cookie string.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* const jcookie = cookieParser.jsonCookie({ cart: [1,2,3] });
|
|
219
|
+
* // => 'j:{"cart":[1,2,3]}'
|
|
220
|
+
*/
|
|
221
|
+
cookieParser.jsonCookie = function jsonCookie(val)
|
|
222
|
+
{
|
|
223
|
+
return 'j:' + JSON.stringify(val);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Parse a JSON cookie string (must start with `j:`).
|
|
228
|
+
*
|
|
229
|
+
* @param {string} str - JSON cookie string.
|
|
230
|
+
* @returns {*} Parsed value, or the original string if not a valid JSON cookie.
|
|
231
|
+
*/
|
|
232
|
+
cookieParser.parseJSON = function parseJSON(str)
|
|
233
|
+
{
|
|
234
|
+
return _parseJSONCookie(str);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
module.exports = cookieParser;
|
package/lib/middleware/cors.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* starting with `'.'` for suffix matching.
|
|
15
15
|
* @param {string} [options.methods='GET,POST,PUT,DELETE,OPTIONS'] - Allowed HTTP methods.
|
|
16
16
|
* @param {string} [options.allowedHeaders='Content-Type,Authorization'] - Allowed request headers.
|
|
17
|
-
* @param {
|
|
17
|
+
* @param {string} [options.exposedHeaders] - Headers the browser is allowed to read.
|
|
18
|
+
* @param {boolean} [options.credentials=false] - Whether to set `Access-Control-Allow-Credentials`.
|
|
19
|
+
* @param {number} [options.maxAge] - Preflight cache duration in seconds.
|
|
18
20
|
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
19
21
|
*/
|
|
20
22
|
function cors(options = {})
|
|
@@ -23,6 +25,12 @@ function cors(options = {})
|
|
|
23
25
|
const allowMethods = (options.methods || 'GET,POST,PUT,DELETE,OPTIONS');
|
|
24
26
|
const allowHeaders = (options.allowedHeaders || 'Content-Type,Authorization');
|
|
25
27
|
|
|
28
|
+
// RFC 6454: credentials cannot be used with wildcard origin
|
|
29
|
+
if (options.credentials && allowOrigin === '*')
|
|
30
|
+
{
|
|
31
|
+
throw new Error('CORS credentials cannot be used with wildcard origin "*". Specify explicit origins instead.');
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
/**
|
|
27
35
|
* Resolve the Origin header value to echo back based on the configured
|
|
28
36
|
* allow-list. Returns `null` when the origin should not be allowed.
|
|
@@ -57,12 +65,15 @@ function cors(options = {})
|
|
|
57
65
|
if (originValue)
|
|
58
66
|
{
|
|
59
67
|
res.set('Access-Control-Allow-Origin', originValue);
|
|
60
|
-
//
|
|
68
|
+
// Set Vary: Origin when not using wildcard (important for caching proxies)
|
|
69
|
+
if (originValue !== '*') res.vary('Origin');
|
|
61
70
|
if (options.credentials) res.set('Access-Control-Allow-Credentials', 'true');
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
if (allowMethods) res.set('Access-Control-Allow-Methods', allowMethods);
|
|
65
74
|
if (allowHeaders) res.set('Access-Control-Allow-Headers', allowHeaders);
|
|
75
|
+
if (options.exposedHeaders) res.set('Access-Control-Expose-Headers', options.exposedHeaders);
|
|
76
|
+
if (options.maxAge !== undefined) res.set('Access-Control-Max-Age', String(options.maxAge));
|
|
66
77
|
|
|
67
78
|
if (req.method === 'OPTIONS') return res.status(204).send();
|
|
68
79
|
next();
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module middleware/csrf
|
|
3
|
+
* @description CSRF (Cross-Site Request Forgery) protection middleware.
|
|
4
|
+
* Uses the double-submit cookie + header/body token pattern.
|
|
5
|
+
*
|
|
6
|
+
* Safe methods (GET, HEAD, OPTIONS) are skipped automatically.
|
|
7
|
+
* For state-changing requests (POST, PUT, PATCH, DELETE), the
|
|
8
|
+
* middleware checks for a matching token in:
|
|
9
|
+
* 1. `req.headers['x-csrf-token']`
|
|
10
|
+
* 2. `req.body._csrf` (if body parsed)
|
|
11
|
+
* 3. `req.query._csrf`
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const { createApp, csrf } = require('zero-http');
|
|
15
|
+
* const app = createApp();
|
|
16
|
+
*
|
|
17
|
+
* app.use(csrf()); // default options
|
|
18
|
+
* app.use(csrf({ cookie: 'tok' })); // custom cookie name
|
|
19
|
+
*
|
|
20
|
+
* // In a route, read the token for forms / SPA:
|
|
21
|
+
* app.get('/form', (req, res) => {
|
|
22
|
+
* res.json({ csrfToken: req.csrfToken });
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const log = require('../debug')('zero:csrf');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {string} [options.cookie='_csrf'] - Name of the double-submit cookie.
|
|
31
|
+
* @param {string} [options.header='x-csrf-token'] - Request header that carries the token.
|
|
32
|
+
* @param {number} [options.saltLength=18] - Bytes of randomness for token generation.
|
|
33
|
+
* @param {string} [options.secret] - HMAC secret. Auto-generated per process if omitted.
|
|
34
|
+
* @param {string[]} [options.ignoreMethods] - HTTP methods to skip. Default: GET, HEAD, OPTIONS.
|
|
35
|
+
* @param {string[]} [options.ignorePaths] - Path prefixes to skip (e.g. ['/api/webhooks']).
|
|
36
|
+
* @param {Function} [options.onError] - Custom error handler `(req, res) => {}`.
|
|
37
|
+
* @returns {Function} Middleware function.
|
|
38
|
+
*/
|
|
39
|
+
function csrf(options = {})
|
|
40
|
+
{
|
|
41
|
+
const cookieName = options.cookie || '_csrf';
|
|
42
|
+
const headerName = (options.header || 'x-csrf-token').toLowerCase();
|
|
43
|
+
const saltLen = options.saltLength || 18;
|
|
44
|
+
const secret = options.secret || crypto.randomBytes(32).toString('hex');
|
|
45
|
+
const ignoreMethods = new Set((options.ignoreMethods || ['GET', 'HEAD', 'OPTIONS']).map(m => m.toUpperCase()));
|
|
46
|
+
const ignorePaths = options.ignorePaths || [];
|
|
47
|
+
|
|
48
|
+
function generateToken()
|
|
49
|
+
{
|
|
50
|
+
try
|
|
51
|
+
{
|
|
52
|
+
const salt = crypto.randomBytes(saltLen).toString('hex');
|
|
53
|
+
const hash = crypto.createHmac('sha256', secret).update(salt).digest('hex');
|
|
54
|
+
return `${salt}.${hash}`;
|
|
55
|
+
}
|
|
56
|
+
catch (e) { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function verifyToken(token)
|
|
60
|
+
{
|
|
61
|
+
if (!token || typeof token !== 'string') return false;
|
|
62
|
+
const parts = token.split('.');
|
|
63
|
+
if (parts.length !== 2) return false;
|
|
64
|
+
const [salt, hash] = parts;
|
|
65
|
+
try
|
|
66
|
+
{
|
|
67
|
+
const expected = crypto.createHmac('sha256', secret).update(salt).digest('hex');
|
|
68
|
+
// Constant-time comparison
|
|
69
|
+
if (expected.length !== hash.length) return false;
|
|
70
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(hash));
|
|
71
|
+
}
|
|
72
|
+
catch (e) { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return function csrfMiddleware(req, res, next)
|
|
76
|
+
{
|
|
77
|
+
// Skip safe methods
|
|
78
|
+
if (ignoreMethods.has(req.method))
|
|
79
|
+
{
|
|
80
|
+
// Ensure a token exists in the cookie for the client to read
|
|
81
|
+
const existing = req.cookies && req.cookies[cookieName];
|
|
82
|
+
if (!existing || !verifyToken(existing))
|
|
83
|
+
{
|
|
84
|
+
const token = generateToken();
|
|
85
|
+
const secure = req.secure ? '; Secure' : '';
|
|
86
|
+
res.set('Set-Cookie',
|
|
87
|
+
`${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}`
|
|
88
|
+
);
|
|
89
|
+
req.csrfToken = token;
|
|
90
|
+
}
|
|
91
|
+
else
|
|
92
|
+
{
|
|
93
|
+
req.csrfToken = existing;
|
|
94
|
+
}
|
|
95
|
+
return next();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Skip ignored paths
|
|
99
|
+
const pathname = req.url.split('?')[0];
|
|
100
|
+
for (const prefix of ignorePaths)
|
|
101
|
+
{
|
|
102
|
+
if (pathname.startsWith(prefix)) return next();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Extract the token the client sent
|
|
106
|
+
const clientToken =
|
|
107
|
+
req.headers[headerName] ||
|
|
108
|
+
(req.body && req.body._csrf) ||
|
|
109
|
+
(req.query && req.query._csrf) ||
|
|
110
|
+
null;
|
|
111
|
+
|
|
112
|
+
// Extract the cookie token
|
|
113
|
+
const cookieToken = req.cookies && req.cookies[cookieName];
|
|
114
|
+
|
|
115
|
+
// Both must exist and be valid, and must match
|
|
116
|
+
if (!clientToken || !cookieToken || clientToken !== cookieToken || !verifyToken(clientToken))
|
|
117
|
+
{
|
|
118
|
+
log.warn('CSRF validation failed for %s %s', req.method, pathname);
|
|
119
|
+
if (options.onError) return options.onError(req, res);
|
|
120
|
+
res.status(403).json({ error: 'CSRF token missing or invalid' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Rotate token on each state-changing request
|
|
125
|
+
const newToken = generateToken();
|
|
126
|
+
const secure = req.secure ? '; Secure' : '';
|
|
127
|
+
res.set('Set-Cookie',
|
|
128
|
+
`${cookieName}=${newToken}; Path=/; HttpOnly; SameSite=Strict${secure}`
|
|
129
|
+
);
|
|
130
|
+
req.csrfToken = newToken;
|
|
131
|
+
next();
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = csrf;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module middleware/errorHandler
|
|
3
|
+
* @description Configurable error-handling middleware that formats error responses
|
|
4
|
+
* based on environment (dev vs production), supports custom formatters,
|
|
5
|
+
* and integrates with HttpError classes.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} [opts]
|
|
8
|
+
* @param {boolean} [opts.stack] - Include stack traces in responses (default: true when NODE_ENV !== 'production').
|
|
9
|
+
* @param {boolean} [opts.log] - Log errors to console (default: true).
|
|
10
|
+
* @param {function} [opts.logger] - Custom log function (default: console.error).
|
|
11
|
+
* @param {function} [opts.formatter] - Custom response formatter: (err, req, isDev) => object.
|
|
12
|
+
* @param {function} [opts.onError] - Callback on every error: (err, req, res) => void.
|
|
13
|
+
* @returns {function} Error-handling middleware `(err, req, res, next) => void`.
|
|
14
|
+
*/
|
|
15
|
+
const { HttpError, isHttpError } = require('../errors');
|
|
16
|
+
|
|
17
|
+
function errorHandler(opts = {})
|
|
18
|
+
{
|
|
19
|
+
const isDev = opts.stack !== undefined
|
|
20
|
+
? opts.stack
|
|
21
|
+
: (process.env.NODE_ENV !== 'production');
|
|
22
|
+
|
|
23
|
+
const shouldLog = opts.log !== undefined ? opts.log : true;
|
|
24
|
+
const logFn = typeof opts.logger === 'function' ? opts.logger : console.error;
|
|
25
|
+
const formatter = typeof opts.formatter === 'function' ? opts.formatter : null;
|
|
26
|
+
const onError = typeof opts.onError === 'function' ? opts.onError : null;
|
|
27
|
+
|
|
28
|
+
return (err, req, res, next) =>
|
|
29
|
+
{
|
|
30
|
+
// Resolve status code
|
|
31
|
+
let statusCode = err.statusCode || err.status || 500;
|
|
32
|
+
if (typeof statusCode !== 'number' || statusCode < 100 || statusCode > 599) statusCode = 500;
|
|
33
|
+
|
|
34
|
+
// Log the error
|
|
35
|
+
if (shouldLog)
|
|
36
|
+
{
|
|
37
|
+
const method = req.method || 'UNKNOWN';
|
|
38
|
+
const url = req.url || req.originalUrl || '/';
|
|
39
|
+
const prefix = `[${method} ${url}]`;
|
|
40
|
+
|
|
41
|
+
if (statusCode >= 500)
|
|
42
|
+
{
|
|
43
|
+
logFn(`${prefix} ${statusCode} - ${err.message}`);
|
|
44
|
+
if (err.stack) logFn(err.stack);
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
{
|
|
48
|
+
logFn(`${prefix} ${statusCode} - ${err.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Callback hook
|
|
53
|
+
if (onError) onError(err, req, res);
|
|
54
|
+
|
|
55
|
+
// Don't send if headers already sent
|
|
56
|
+
if (res.headersSent || (res.raw && res.raw.headersSent))
|
|
57
|
+
{
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build response body
|
|
62
|
+
let body;
|
|
63
|
+
|
|
64
|
+
if (formatter)
|
|
65
|
+
{
|
|
66
|
+
body = formatter(err, req, isDev);
|
|
67
|
+
}
|
|
68
|
+
else if (isHttpError(err))
|
|
69
|
+
{
|
|
70
|
+
body = err.toJSON ? err.toJSON() : { error: err.message, code: err.code, statusCode };
|
|
71
|
+
if (isDev && err.stack) body.stack = err.stack.split('\n');
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
{
|
|
75
|
+
// Generic error
|
|
76
|
+
body = {
|
|
77
|
+
error: statusCode >= 500 && !isDev
|
|
78
|
+
? 'Internal Server Error' // Hide internal details in production
|
|
79
|
+
: (err.message || 'Internal Server Error'),
|
|
80
|
+
statusCode,
|
|
81
|
+
};
|
|
82
|
+
if (err.code) body.code = err.code;
|
|
83
|
+
if (isDev && err.stack) body.stack = err.stack.split('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
res.status(statusCode).json(body);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = errorHandler;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module helmet
|
|
3
|
+
* @description Security headers middleware.
|
|
4
|
+
* Sets common security-related HTTP response headers to help
|
|
5
|
+
* protect against well-known web vulnerabilities (XSS, clickjacking,
|
|
6
|
+
* MIME sniffing, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Inspired by the `helmet` npm package but zero-dependency.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a security headers middleware.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {object|false} [opts.contentSecurityPolicy] - CSP directive object or `false` to disable.
|
|
16
|
+
* @param {boolean} [opts.crossOriginEmbedderPolicy=false] - Set COEP header.
|
|
17
|
+
* @param {string|false} [opts.crossOriginOpenerPolicy='same-origin'] - COOP value.
|
|
18
|
+
* @param {string|false} [opts.crossOriginResourcePolicy='same-origin'] - CORP value.
|
|
19
|
+
* @param {boolean} [opts.dnsPrefetchControl=true] - Set X-DNS-Prefetch-Control: off.
|
|
20
|
+
* @param {string|false} [opts.frameguard='deny'] - X-Frame-Options value ('deny' | 'sameorigin').
|
|
21
|
+
* @param {boolean} [opts.hidePoweredBy=true] - Remove X-Powered-By header.
|
|
22
|
+
* @param {boolean|number}[opts.hsts=true] - Set Strict-Transport-Security.
|
|
23
|
+
* @param {number} [opts.hstsMaxAge=15552000] - HSTS max-age in seconds (default ~180 days).
|
|
24
|
+
* @param {boolean} [opts.hstsIncludeSubDomains=true] - HSTS includeSubDomains directive.
|
|
25
|
+
* @param {boolean} [opts.hstsPreload=false] - HSTS preload directive.
|
|
26
|
+
* @param {boolean} [opts.ieNoOpen=true] - Set X-Download-Options: noopen.
|
|
27
|
+
* @param {boolean} [opts.noSniff=true] - Set X-Content-Type-Options: nosniff.
|
|
28
|
+
* @param {string|false} [opts.permittedCrossDomainPolicies='none'] - X-Permitted-Cross-Domain-Policies.
|
|
29
|
+
* @param {string|false} [opts.referrerPolicy='no-referrer'] - Referrer-Policy value.
|
|
30
|
+
* @param {boolean} [opts.xssFilter=false] - Set X-XSS-Protection (legacy, off by default).
|
|
31
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* app.use(helmet());
|
|
35
|
+
* app.use(helmet({ frameguard: 'sameorigin', hsts: false }));
|
|
36
|
+
* app.use(helmet({
|
|
37
|
+
* contentSecurityPolicy: {
|
|
38
|
+
* directives: {
|
|
39
|
+
* defaultSrc: ["'self'"],
|
|
40
|
+
* scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
41
|
+
* styleSrc: ["'self'", "'unsafe-inline'"],
|
|
42
|
+
* imgSrc: ["'self'", "data:", "https:"],
|
|
43
|
+
* }
|
|
44
|
+
* }
|
|
45
|
+
* }));
|
|
46
|
+
*/
|
|
47
|
+
function helmet(opts = {})
|
|
48
|
+
{
|
|
49
|
+
return (req, res, next) =>
|
|
50
|
+
{
|
|
51
|
+
const raw = res.raw || res;
|
|
52
|
+
|
|
53
|
+
// -- Content-Security-Policy --------------------
|
|
54
|
+
if (opts.contentSecurityPolicy !== false)
|
|
55
|
+
{
|
|
56
|
+
const csp = opts.contentSecurityPolicy || {};
|
|
57
|
+
const directives = csp.directives || {
|
|
58
|
+
defaultSrc: ["'self'"],
|
|
59
|
+
baseUri: ["'self'"],
|
|
60
|
+
fontSrc: ["'self'", 'https:', 'data:'],
|
|
61
|
+
formAction: ["'self'"],
|
|
62
|
+
frameAncestors: ["'self'"],
|
|
63
|
+
imgSrc: ["'self'", 'data:'],
|
|
64
|
+
objectSrc: ["'none'"],
|
|
65
|
+
scriptSrc: ["'self'"],
|
|
66
|
+
scriptSrcAttr: ["'none'"],
|
|
67
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
68
|
+
upgradeInsecureRequests: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const cspString = Object.entries(directives)
|
|
72
|
+
.map(([key, values]) =>
|
|
73
|
+
{
|
|
74
|
+
const directive = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
75
|
+
if (Array.isArray(values) && values.length === 0) return directive;
|
|
76
|
+
return `${directive} ${Array.isArray(values) ? values.join(' ') : values}`;
|
|
77
|
+
})
|
|
78
|
+
.join('; ');
|
|
79
|
+
|
|
80
|
+
if (cspString)
|
|
81
|
+
{
|
|
82
|
+
try { raw.setHeader('Content-Security-Policy', cspString); } catch (e) { }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// -- Cross-Origin-Embedder-Policy ---------------
|
|
87
|
+
if (opts.crossOriginEmbedderPolicy)
|
|
88
|
+
{
|
|
89
|
+
try { raw.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); } catch (e) { }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// -- Cross-Origin-Opener-Policy -----------------
|
|
93
|
+
if (opts.crossOriginOpenerPolicy !== false)
|
|
94
|
+
{
|
|
95
|
+
const coop = opts.crossOriginOpenerPolicy || 'same-origin';
|
|
96
|
+
try { raw.setHeader('Cross-Origin-Opener-Policy', coop); } catch (e) { }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// -- Cross-Origin-Resource-Policy ---------------
|
|
100
|
+
if (opts.crossOriginResourcePolicy !== false)
|
|
101
|
+
{
|
|
102
|
+
const corp = opts.crossOriginResourcePolicy || 'same-origin';
|
|
103
|
+
try { raw.setHeader('Cross-Origin-Resource-Policy', corp); } catch (e) { }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- DNS Prefetch Control -----------------------
|
|
107
|
+
if (opts.dnsPrefetchControl !== false)
|
|
108
|
+
{
|
|
109
|
+
try { raw.setHeader('X-DNS-Prefetch-Control', 'off'); } catch (e) { }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -- Frameguard (X-Frame-Options) ---------------
|
|
113
|
+
if (opts.frameguard !== false)
|
|
114
|
+
{
|
|
115
|
+
const frame = (opts.frameguard || 'deny').toUpperCase();
|
|
116
|
+
try { raw.setHeader('X-Frame-Options', frame); } catch (e) { }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -- Hide X-Powered-By -------------------------
|
|
120
|
+
if (opts.hidePoweredBy !== false)
|
|
121
|
+
{
|
|
122
|
+
try { raw.removeHeader('X-Powered-By'); } catch (e) { }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -- HSTS ---------------------------------------
|
|
126
|
+
if (opts.hsts !== false)
|
|
127
|
+
{
|
|
128
|
+
const maxAge = opts.hstsMaxAge || 15552000;
|
|
129
|
+
let hstsValue = `max-age=${maxAge}`;
|
|
130
|
+
if (opts.hstsIncludeSubDomains !== false) hstsValue += '; includeSubDomains';
|
|
131
|
+
if (opts.hstsPreload) hstsValue += '; preload';
|
|
132
|
+
try { raw.setHeader('Strict-Transport-Security', hstsValue); } catch (e) { }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- IE No Open --------------------------------
|
|
136
|
+
if (opts.ieNoOpen !== false)
|
|
137
|
+
{
|
|
138
|
+
try { raw.setHeader('X-Download-Options', 'noopen'); } catch (e) { }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -- No Sniff -----------------------------------
|
|
142
|
+
if (opts.noSniff !== false)
|
|
143
|
+
{
|
|
144
|
+
try { raw.setHeader('X-Content-Type-Options', 'nosniff'); } catch (e) { }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -- Permitted Cross Domain Policies ------------
|
|
148
|
+
if (opts.permittedCrossDomainPolicies !== false)
|
|
149
|
+
{
|
|
150
|
+
const pcdp = opts.permittedCrossDomainPolicies || 'none';
|
|
151
|
+
try { raw.setHeader('X-Permitted-Cross-Domain-Policies', pcdp); } catch (e) { }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -- Referrer Policy ----------------------------
|
|
155
|
+
if (opts.referrerPolicy !== false)
|
|
156
|
+
{
|
|
157
|
+
const rp = opts.referrerPolicy || 'no-referrer';
|
|
158
|
+
try { raw.setHeader('Referrer-Policy', rp); } catch (e) { }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// -- XSS Filter (legacy) -----------------------
|
|
162
|
+
if (opts.xssFilter)
|
|
163
|
+
{
|
|
164
|
+
try { raw.setHeader('X-XSS-Protection', '1; mode=block'); } catch (e) { }
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
{
|
|
168
|
+
// Modern best practice: disable legacy XSS auditor
|
|
169
|
+
try { raw.setHeader('X-XSS-Protection', '0'); } catch (e) { }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
next();
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = helmet;
|