@zero-server/sdk 0.9.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/LICENSE +21 -0
- package/README.md +437 -0
- package/index.js +412 -0
- package/lib/app.js +1172 -0
- package/lib/auth/authorize.js +399 -0
- package/lib/auth/enrollment.js +367 -0
- package/lib/auth/index.js +57 -0
- package/lib/auth/jwt.js +731 -0
- package/lib/auth/oauth.js +362 -0
- package/lib/auth/session.js +588 -0
- package/lib/auth/trustedDevice.js +409 -0
- package/lib/auth/twoFactor.js +1150 -0
- package/lib/auth/webauthn.js +946 -0
- package/lib/body/index.js +14 -0
- package/lib/body/json.js +109 -0
- package/lib/body/multipart.js +440 -0
- package/lib/body/raw.js +71 -0
- package/lib/body/rawBuffer.js +161 -0
- package/lib/body/sendError.js +25 -0
- package/lib/body/text.js +75 -0
- package/lib/body/typeMatch.js +42 -0
- package/lib/body/urlencoded.js +235 -0
- package/lib/cli.js +845 -0
- package/lib/cluster.js +666 -0
- package/lib/debug.js +372 -0
- package/lib/env/index.js +465 -0
- package/lib/errors.js +683 -0
- package/lib/fetch/index.js +256 -0
- package/lib/grpc/balancer.js +378 -0
- package/lib/grpc/call.js +708 -0
- package/lib/grpc/client.js +764 -0
- package/lib/grpc/codec.js +1221 -0
- package/lib/grpc/credentials.js +398 -0
- package/lib/grpc/frame.js +262 -0
- package/lib/grpc/health.js +287 -0
- package/lib/grpc/index.js +121 -0
- package/lib/grpc/metadata.js +461 -0
- package/lib/grpc/proto.js +821 -0
- package/lib/grpc/reflection.js +590 -0
- package/lib/grpc/server.js +445 -0
- package/lib/grpc/status.js +118 -0
- package/lib/grpc/watch.js +173 -0
- package/lib/http/index.js +10 -0
- package/lib/http/request.js +727 -0
- package/lib/http/response.js +799 -0
- package/lib/lifecycle.js +557 -0
- package/lib/middleware/compress.js +230 -0
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +93 -0
- package/lib/middleware/csrf.js +137 -0
- package/lib/middleware/errorHandler.js +101 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +17 -0
- package/lib/middleware/logger.js +74 -0
- package/lib/middleware/rateLimit.js +88 -0
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +326 -0
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +255 -0
- package/lib/observe/health.js +326 -0
- package/lib/observe/index.js +50 -0
- package/lib/observe/logger.js +359 -0
- package/lib/observe/metrics.js +805 -0
- package/lib/observe/tracing.js +592 -0
- package/lib/orm/adapters/json.js +290 -0
- package/lib/orm/adapters/memory.js +764 -0
- package/lib/orm/adapters/mongo.js +764 -0
- package/lib/orm/adapters/mysql.js +933 -0
- package/lib/orm/adapters/postgres.js +1144 -0
- package/lib/orm/adapters/redis.js +1534 -0
- package/lib/orm/adapters/sql-base.js +212 -0
- package/lib/orm/adapters/sqlite.js +858 -0
- package/lib/orm/audit.js +649 -0
- package/lib/orm/cache.js +394 -0
- package/lib/orm/geo.js +387 -0
- package/lib/orm/index.js +784 -0
- package/lib/orm/migrate.js +432 -0
- package/lib/orm/model.js +1706 -0
- package/lib/orm/plugin.js +375 -0
- package/lib/orm/procedures.js +836 -0
- package/lib/orm/profiler.js +233 -0
- package/lib/orm/query.js +1772 -0
- package/lib/orm/replicas.js +241 -0
- package/lib/orm/schema.js +307 -0
- package/lib/orm/search.js +380 -0
- package/lib/orm/seed/data/commerce.js +136 -0
- package/lib/orm/seed/data/internet.js +111 -0
- package/lib/orm/seed/data/locations.js +204 -0
- package/lib/orm/seed/data/names.js +338 -0
- package/lib/orm/seed/data/person.js +128 -0
- package/lib/orm/seed/data/phone.js +211 -0
- package/lib/orm/seed/data/words.js +134 -0
- package/lib/orm/seed/factory.js +178 -0
- package/lib/orm/seed/fake.js +1186 -0
- package/lib/orm/seed/index.js +18 -0
- package/lib/orm/seed/rng.js +71 -0
- package/lib/orm/seed/seeder.js +125 -0
- package/lib/orm/seed/unique.js +68 -0
- package/lib/orm/snapshot.js +366 -0
- package/lib/orm/tenancy.js +605 -0
- package/lib/orm/views.js +350 -0
- package/lib/router/index.js +436 -0
- package/lib/sse/index.js +8 -0
- package/lib/sse/stream.js +349 -0
- package/lib/ws/connection.js +451 -0
- package/lib/ws/handshake.js +125 -0
- package/lib/ws/index.js +14 -0
- package/lib/ws/room.js +223 -0
- package/package.json +73 -0
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +384 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module compress
|
|
3
|
+
* @description Response compression middleware using Node's built-in `zlib`.
|
|
4
|
+
* Supports gzip, deflate, and brotli (Node >= 11.7).
|
|
5
|
+
* Zero external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
const zlib = require('zlib');
|
|
8
|
+
const log = require('../debug')('zero:compress');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default minimum response size (in bytes) to bother compressing.
|
|
12
|
+
* Responses smaller than this are sent uncompressed.
|
|
13
|
+
* @type {number}
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_THRESHOLD = 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MIME types that are worth compressing.
|
|
19
|
+
* Binary formats (images, video, zip) are already compressed and gain little.
|
|
20
|
+
* @type {RegExp}
|
|
21
|
+
*/
|
|
22
|
+
const COMPRESSIBLE = /^text\/|^application\/(json|javascript|xml|x-www-form-urlencoded|ld\+json|graphql|wasm)|^image\/svg\+xml/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a compression middleware.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} [opts] - Configuration options.
|
|
28
|
+
* @param {number} [opts.threshold=1024] - Minimum body size in bytes to compress.
|
|
29
|
+
* @param {number} [opts.level] - Compression level (zlib.constants.Z_DEFAULT_COMPRESSION).
|
|
30
|
+
* @param {string|string[]} [opts.encoding] - Force specific encoding(s). Default: auto-negotiate.
|
|
31
|
+
* @param {Function} [opts.filter] - `(req, res) => boolean` — return false to skip compression.
|
|
32
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const { createApp, compress } = require('@zero-server/sdk');
|
|
36
|
+
* const app = createApp();
|
|
37
|
+
* app.use(compress()); // gzip/deflate/br auto-negotiated
|
|
38
|
+
* app.use(compress({ threshold: 0 })) // compress everything
|
|
39
|
+
*/
|
|
40
|
+
function compress(opts = {})
|
|
41
|
+
{
|
|
42
|
+
const threshold = opts.threshold !== undefined ? opts.threshold : DEFAULT_THRESHOLD;
|
|
43
|
+
const level = opts.level !== undefined ? opts.level : undefined;
|
|
44
|
+
const filterFn = typeof opts.filter === 'function' ? opts.filter : null;
|
|
45
|
+
const hasBrotli = typeof zlib.createBrotliCompress === 'function';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Choose the best encoding from the Accept-Encoding header.
|
|
49
|
+
* Parses quality values (RFC 7231) and picks the highest-priority match.
|
|
50
|
+
* Priority when equal quality: br > gzip > deflate.
|
|
51
|
+
* @private
|
|
52
|
+
* @param {string} header - HTTP header value.
|
|
53
|
+
* @returns {string|null} Best encoding name, or `null` if none acceptable.
|
|
54
|
+
*/
|
|
55
|
+
function negotiate(header)
|
|
56
|
+
{
|
|
57
|
+
if (!header) return null;
|
|
58
|
+
const encodings = { br: 0, gzip: 0, deflate: 0 };
|
|
59
|
+
const parts = header.toLowerCase().split(',');
|
|
60
|
+
for (let i = 0; i < parts.length; i++)
|
|
61
|
+
{
|
|
62
|
+
const part = parts[i].trim();
|
|
63
|
+
const semi = part.indexOf(';');
|
|
64
|
+
const name = (semi !== -1 ? part.substring(0, semi).trim() : part);
|
|
65
|
+
let q = 1;
|
|
66
|
+
if (semi !== -1)
|
|
67
|
+
{
|
|
68
|
+
const qMatch = /q\s*=\s*([0-9.]+)/.exec(part.substring(semi));
|
|
69
|
+
if (qMatch) q = parseFloat(qMatch[1]);
|
|
70
|
+
}
|
|
71
|
+
if (name in encodings) encodings[name] = q;
|
|
72
|
+
}
|
|
73
|
+
// Filter available encodings
|
|
74
|
+
if (!hasBrotli) encodings.br = 0;
|
|
75
|
+
// Pick highest quality; break ties with priority order
|
|
76
|
+
let best = null;
|
|
77
|
+
let bestQ = 0;
|
|
78
|
+
const order = hasBrotli ? ['br', 'gzip', 'deflate'] : ['gzip', 'deflate'];
|
|
79
|
+
for (let i = 0; i < order.length; i++)
|
|
80
|
+
{
|
|
81
|
+
if (encodings[order[i]] > bestQ)
|
|
82
|
+
{
|
|
83
|
+
bestQ = encodings[order[i]];
|
|
84
|
+
best = order[i];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a compression stream for the chosen encoding.
|
|
92
|
+
* @private
|
|
93
|
+
* @param {string} encoding - Content encoding.
|
|
94
|
+
* @returns {import('stream').Transform} Compression transform stream.
|
|
95
|
+
*/
|
|
96
|
+
function createStream(encoding)
|
|
97
|
+
{
|
|
98
|
+
const zlibOpts = {};
|
|
99
|
+
if (level !== undefined) zlibOpts.level = level;
|
|
100
|
+
switch (encoding)
|
|
101
|
+
{
|
|
102
|
+
case 'br':
|
|
103
|
+
return zlib.createBrotliCompress(level !== undefined
|
|
104
|
+
? { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } }
|
|
105
|
+
: undefined);
|
|
106
|
+
case 'gzip':
|
|
107
|
+
return zlib.createGzip(zlibOpts);
|
|
108
|
+
case 'deflate':
|
|
109
|
+
return zlib.createDeflate(zlibOpts);
|
|
110
|
+
default:
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (req, res, next) =>
|
|
116
|
+
{
|
|
117
|
+
// Skip if client doesn't accept encoding
|
|
118
|
+
const acceptEncoding = req.headers['accept-encoding'] || '';
|
|
119
|
+
const encoding = negotiate(acceptEncoding);
|
|
120
|
+
if (!encoding) return next();
|
|
121
|
+
|
|
122
|
+
// Allow user to skip compression
|
|
123
|
+
if (filterFn && !filterFn(req, res)) return next();
|
|
124
|
+
|
|
125
|
+
// Monkey-patch the raw response's write/end to pipe through compression
|
|
126
|
+
const raw = res.raw;
|
|
127
|
+
const origWrite = raw.write.bind(raw);
|
|
128
|
+
const origEnd = raw.end.bind(raw);
|
|
129
|
+
let compressStream = null;
|
|
130
|
+
let headersWritten = false;
|
|
131
|
+
const chunks = [];
|
|
132
|
+
|
|
133
|
+
/** @private */
|
|
134
|
+
function initCompress()
|
|
135
|
+
{
|
|
136
|
+
if (compressStream) return true;
|
|
137
|
+
|
|
138
|
+
// If headers were already committed (e.g. res.sse() calls
|
|
139
|
+
// writeHead before write), we can no longer modify them.
|
|
140
|
+
if (raw.headersSent) return false;
|
|
141
|
+
|
|
142
|
+
// Check Content-Type — skip non-compressible types
|
|
143
|
+
const ct = raw.getHeader('content-type') || '';
|
|
144
|
+
if (ct && !COMPRESSIBLE.test(ct))
|
|
145
|
+
{
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Never compress SSE streams — compression buffers
|
|
150
|
+
// the small frames and prevents real-time delivery.
|
|
151
|
+
if (ct.includes('text/event-stream'))
|
|
152
|
+
{
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
compressStream = createStream(encoding);
|
|
157
|
+
if (!compressStream) return false;
|
|
158
|
+
|
|
159
|
+
// Remove Content-Length (we don't know compressed size ahead of time)
|
|
160
|
+
raw.removeHeader('content-length');
|
|
161
|
+
raw.removeHeader('Content-Length');
|
|
162
|
+
raw.setHeader('Content-Encoding', encoding);
|
|
163
|
+
raw.setHeader('Vary', 'Accept-Encoding');
|
|
164
|
+
log.debug('compressing with %s', encoding);
|
|
165
|
+
|
|
166
|
+
compressStream.on('data', (chunk) => origWrite(chunk));
|
|
167
|
+
compressStream.on('end', () => origEnd());
|
|
168
|
+
compressStream.on('error', (err) =>
|
|
169
|
+
{ log.error('compression error: %s', err.message); // On compression error, remove encoding header and end raw stream
|
|
170
|
+
try { raw.removeHeader('Content-Encoding'); } catch (e) { }
|
|
171
|
+
try { origEnd(); } catch (e) { }
|
|
172
|
+
});
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
raw.write = function (chunk, enc, callback)
|
|
177
|
+
{
|
|
178
|
+
if (!headersWritten)
|
|
179
|
+
{
|
|
180
|
+
headersWritten = true;
|
|
181
|
+
const ct = raw.getHeader('content-type') || '';
|
|
182
|
+
initCompress();
|
|
183
|
+
if (compressStream)
|
|
184
|
+
{
|
|
185
|
+
compressStream.write(chunk, enc, callback);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (compressStream)
|
|
190
|
+
{
|
|
191
|
+
compressStream.write(chunk, enc, callback);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return origWrite(chunk, enc, callback);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
raw.end = function (chunk, encoding, callback)
|
|
198
|
+
{
|
|
199
|
+
if (!headersWritten)
|
|
200
|
+
{
|
|
201
|
+
headersWritten = true;
|
|
202
|
+
|
|
203
|
+
// Check threshold — if the total body is small, skip compression
|
|
204
|
+
const totalChunk = chunk ? (Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) : null;
|
|
205
|
+
if (totalChunk && totalChunk.length < threshold)
|
|
206
|
+
{
|
|
207
|
+
return origEnd(chunk, encoding, callback);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (initCompress())
|
|
211
|
+
{
|
|
212
|
+
if (chunk) compressStream.end(chunk, encoding, callback);
|
|
213
|
+
else compressStream.end(callback);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (compressStream)
|
|
218
|
+
{
|
|
219
|
+
if (chunk) compressStream.end(chunk, encoding, callback);
|
|
220
|
+
else compressStream.end(callback);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
return origEnd(chunk, encoding, callback);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
next();
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = compress;
|
|
@@ -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} `true` if the signature is valid.
|
|
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] - Configuration options.
|
|
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;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cors
|
|
3
|
+
* @description CORS middleware. Supports exact origins, wildcard `'*'`,
|
|
4
|
+
* arrays of allowed origins, and suffix matching with a leading dot
|
|
5
|
+
* (e.g. `'.example.com'` matches `sub.example.com`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a CORS middleware.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} [options] - Configuration options.
|
|
12
|
+
* @param {string|string[]} [options.origin='*'] - Allowed origin(s). Use `'*'` for any,
|
|
13
|
+
* an array for a whitelist, or a string
|
|
14
|
+
* starting with `'.'` for suffix matching.
|
|
15
|
+
* @param {string} [options.methods='GET,POST,PUT,DELETE,OPTIONS'] - Allowed HTTP methods.
|
|
16
|
+
* @param {string} [options.allowedHeaders='Content-Type,Authorization'] - Allowed request headers.
|
|
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.
|
|
20
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* app.use(cors()); // allow all origins
|
|
24
|
+
* app.use(cors({ origin: 'https://example.com' })); // single origin
|
|
25
|
+
* app.use(cors({ // fine-grained
|
|
26
|
+
* origin: ['https://my.example.com', '.example.com'],
|
|
27
|
+
* credentials: true,
|
|
28
|
+
* maxAge: 86400,
|
|
29
|
+
* }));
|
|
30
|
+
*/
|
|
31
|
+
function cors(options = {})
|
|
32
|
+
{
|
|
33
|
+
const allowOrigin = (options.hasOwnProperty('origin')) ? options.origin : '*';
|
|
34
|
+
const allowMethods = (options.methods || 'GET,POST,PUT,DELETE,OPTIONS');
|
|
35
|
+
const allowHeaders = (options.allowedHeaders || 'Content-Type,Authorization');
|
|
36
|
+
|
|
37
|
+
// RFC 6454: credentials cannot be used with wildcard origin
|
|
38
|
+
if (options.credentials && allowOrigin === '*')
|
|
39
|
+
{
|
|
40
|
+
throw new Error('CORS credentials cannot be used with wildcard origin "*". Specify explicit origins instead.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the Origin header value to echo back based on the configured
|
|
45
|
+
* allow-list. Returns `null` when the origin should not be allowed.
|
|
46
|
+
*
|
|
47
|
+
* @private
|
|
48
|
+
* @param {string|undefined} reqOrigin - The request's `Origin` header.
|
|
49
|
+
* @returns {string|null} Origin value to set, or `null`.
|
|
50
|
+
*/
|
|
51
|
+
function matchOrigin(reqOrigin)
|
|
52
|
+
{
|
|
53
|
+
if (!allowOrigin) return null; // origin explicitly disabled
|
|
54
|
+
if (typeof allowOrigin === 'string') return allowOrigin === '*' ? '*' : allowOrigin;
|
|
55
|
+
if (Array.isArray(allowOrigin))
|
|
56
|
+
{
|
|
57
|
+
if (!reqOrigin) return null;
|
|
58
|
+
for (const o of allowOrigin)
|
|
59
|
+
{
|
|
60
|
+
if (!o) continue;
|
|
61
|
+
if (o === reqOrigin) return reqOrigin;
|
|
62
|
+
// allow suffix match with leading dot (e.g. .example.com)
|
|
63
|
+
if (o[0] === '.' && reqOrigin.endsWith(o)) return reqOrigin;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (req, res, next) =>
|
|
71
|
+
{
|
|
72
|
+
const reqOrigin = req.headers && (req.headers.origin || req.headers.Origin);
|
|
73
|
+
const originValue = matchOrigin(reqOrigin);
|
|
74
|
+
|
|
75
|
+
if (originValue)
|
|
76
|
+
{
|
|
77
|
+
res.set('Access-Control-Allow-Origin', originValue);
|
|
78
|
+
// Set Vary: Origin when not using wildcard (important for caching proxies)
|
|
79
|
+
if (originValue !== '*') res.vary('Origin');
|
|
80
|
+
if (options.credentials) res.set('Access-Control-Allow-Credentials', 'true');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (allowMethods) res.set('Access-Control-Allow-Methods', allowMethods);
|
|
84
|
+
if (allowHeaders) res.set('Access-Control-Allow-Headers', allowHeaders);
|
|
85
|
+
if (options.exposedHeaders) res.set('Access-Control-Expose-Headers', options.exposedHeaders);
|
|
86
|
+
if (options.maxAge !== undefined) res.set('Access-Control-Max-Age', String(options.maxAge));
|
|
87
|
+
|
|
88
|
+
if (req.method === 'OPTIONS') return res.status(204).send();
|
|
89
|
+
next();
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = cors;
|
|
@@ -0,0 +1,137 @@
|
|
|
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-server/sdk');
|
|
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] - Configuration 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
|
+
/** @private */
|
|
49
|
+
function generateToken()
|
|
50
|
+
{
|
|
51
|
+
try
|
|
52
|
+
{
|
|
53
|
+
const salt = crypto.randomBytes(saltLen).toString('hex');
|
|
54
|
+
const hash = crypto.createHmac('sha256', secret).update(salt).digest('hex');
|
|
55
|
+
return `${salt}.${hash}`;
|
|
56
|
+
}
|
|
57
|
+
catch (e) { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @private */
|
|
61
|
+
function verifyToken(token)
|
|
62
|
+
{
|
|
63
|
+
if (!token || typeof token !== 'string') return false;
|
|
64
|
+
const parts = token.split('.');
|
|
65
|
+
if (parts.length !== 2) return false;
|
|
66
|
+
const [salt, hash] = parts;
|
|
67
|
+
try
|
|
68
|
+
{
|
|
69
|
+
const expected = crypto.createHmac('sha256', secret).update(salt).digest('hex');
|
|
70
|
+
// Constant-time comparison
|
|
71
|
+
if (expected.length !== hash.length) return false;
|
|
72
|
+
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(hash));
|
|
73
|
+
}
|
|
74
|
+
catch (e) { return false; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return function csrfMiddleware(req, res, next)
|
|
78
|
+
{
|
|
79
|
+
// Skip safe methods
|
|
80
|
+
if (ignoreMethods.has(req.method))
|
|
81
|
+
{
|
|
82
|
+
// Ensure a token exists in the cookie for the client to read
|
|
83
|
+
const existing = req.cookies && req.cookies[cookieName];
|
|
84
|
+
if (!existing || !verifyToken(existing))
|
|
85
|
+
{
|
|
86
|
+
const token = generateToken();
|
|
87
|
+
const secure = req.secure ? '; Secure' : '';
|
|
88
|
+
res.set('Set-Cookie',
|
|
89
|
+
`${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict${secure}`
|
|
90
|
+
);
|
|
91
|
+
req.csrfToken = token;
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
{
|
|
95
|
+
req.csrfToken = existing;
|
|
96
|
+
}
|
|
97
|
+
return next();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Skip ignored paths
|
|
101
|
+
const pathname = req.url.split('?')[0];
|
|
102
|
+
for (const prefix of ignorePaths)
|
|
103
|
+
{
|
|
104
|
+
if (pathname.startsWith(prefix)) return next();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Extract the token the client sent
|
|
108
|
+
const clientToken =
|
|
109
|
+
req.headers[headerName] ||
|
|
110
|
+
(req.body && req.body._csrf) ||
|
|
111
|
+
(req.query && req.query._csrf) ||
|
|
112
|
+
null;
|
|
113
|
+
|
|
114
|
+
// Extract the cookie token
|
|
115
|
+
const cookieToken = req.cookies && req.cookies[cookieName];
|
|
116
|
+
|
|
117
|
+
// Both must exist and be valid, and must match
|
|
118
|
+
if (!clientToken || !cookieToken || clientToken !== cookieToken || !verifyToken(clientToken))
|
|
119
|
+
{
|
|
120
|
+
log.warn('CSRF validation failed for %s %s', req.method, pathname);
|
|
121
|
+
if (options.onError) return options.onError(req, res);
|
|
122
|
+
res.status(403).json({ error: 'CSRF token missing or invalid' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Rotate token on each state-changing request
|
|
127
|
+
const newToken = generateToken();
|
|
128
|
+
const secure = req.secure ? '; Secure' : '';
|
|
129
|
+
res.set('Set-Cookie',
|
|
130
|
+
`${cookieName}=${newToken}; Path=/; HttpOnly; SameSite=Strict${secure}`
|
|
131
|
+
);
|
|
132
|
+
req.csrfToken = newToken;
|
|
133
|
+
next();
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = csrf;
|