pulse-js-framework 1.10.4 → 1.11.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 +11 -0
- package/cli/build.js +13 -3
- package/compiler/directives.js +356 -0
- package/compiler/lexer.js +18 -3
- package/compiler/parser/core.js +6 -0
- package/compiler/parser/view.js +2 -6
- package/compiler/preprocessor.js +43 -23
- package/compiler/sourcemap.js +3 -1
- package/compiler/transformer/actions.js +329 -0
- package/compiler/transformer/export.js +7 -0
- package/compiler/transformer/expressions.js +85 -33
- package/compiler/transformer/imports.js +3 -0
- package/compiler/transformer/index.js +2 -0
- package/compiler/transformer/store.js +1 -1
- package/compiler/transformer/style.js +45 -16
- package/compiler/transformer/view.js +23 -2
- package/loader/rollup-plugin-server-components.js +391 -0
- package/loader/vite-plugin-server-components.js +420 -0
- package/loader/webpack-loader-server-components.js +356 -0
- package/package.json +124 -82
- package/runtime/async.js +4 -0
- package/runtime/context.js +16 -3
- package/runtime/dom-adapter.js +5 -3
- package/runtime/dom-virtual-list.js +2 -1
- package/runtime/form.js +8 -3
- package/runtime/graphql/cache.js +1 -1
- package/runtime/graphql/client.js +22 -0
- package/runtime/graphql/hooks.js +12 -6
- package/runtime/graphql/subscriptions.js +2 -0
- package/runtime/hmr.js +6 -3
- package/runtime/http.js +1 -0
- package/runtime/i18n.js +2 -0
- package/runtime/lru-cache.js +3 -1
- package/runtime/native.js +46 -20
- package/runtime/pulse.js +3 -0
- package/runtime/router/core.js +5 -1
- package/runtime/router/index.js +17 -1
- package/runtime/router/psc-integration.js +301 -0
- package/runtime/security.js +58 -29
- package/runtime/server-components/actions-server.js +798 -0
- package/runtime/server-components/actions.js +389 -0
- package/runtime/server-components/client.js +447 -0
- package/runtime/server-components/error-sanitizer.js +438 -0
- package/runtime/server-components/index.js +275 -0
- package/runtime/server-components/security-csrf.js +593 -0
- package/runtime/server-components/security-errors.js +227 -0
- package/runtime/server-components/security-ratelimit.js +733 -0
- package/runtime/server-components/security-validation.js +467 -0
- package/runtime/server-components/security.js +598 -0
- package/runtime/server-components/serializer.js +617 -0
- package/runtime/server-components/server.js +382 -0
- package/runtime/server-components/types.js +383 -0
- package/runtime/server-components/utils/mutex.js +60 -0
- package/runtime/server-components/utils/path-sanitizer.js +109 -0
- package/runtime/ssr.js +2 -1
- package/runtime/store.js +19 -10
- package/runtime/utils.js +12 -128
- package/types/animation.d.ts +300 -0
- package/types/i18n.d.ts +283 -0
- package/types/persistence.d.ts +267 -0
- package/types/sse.d.ts +248 -0
- package/types/sw.d.ts +150 -0
- package/runtime/a11y.js.original +0 -1844
- package/runtime/graphql.js.original +0 -1326
- package/runtime/router.js.original +0 -1605
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Server Components - CSRF Protection
|
|
3
|
+
*
|
|
4
|
+
* Cryptographically secure CSRF token generation and validation using HMAC-SHA256.
|
|
5
|
+
* Provides zero-dependency, edge-runtime compatible protection against cross-site
|
|
6
|
+
* request forgery attacks on Server Actions.
|
|
7
|
+
*
|
|
8
|
+
* Security Properties:
|
|
9
|
+
* - 128-bit random entropy per token
|
|
10
|
+
* - HMAC-SHA256 signatures (cryptographically secure)
|
|
11
|
+
* - Time-limited tokens (default: 1 hour)
|
|
12
|
+
* - Constant-time comparison (prevents timing attacks)
|
|
13
|
+
* - Automatic cleanup of expired tokens
|
|
14
|
+
* - Optional token rotation on use
|
|
15
|
+
*
|
|
16
|
+
* Token Format: <timestamp>.<random-bytes>.<hmac-signature>
|
|
17
|
+
* Example: 1676400000000.a1b2c3d4e5f6.abc123def456
|
|
18
|
+
*
|
|
19
|
+
* @module pulse-js-framework/runtime/server-components/security-csrf
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Crypto Utilities (Node.js and Edge Runtime Compatible)
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get crypto module (Node.js crypto or Web Crypto API)
|
|
28
|
+
* @returns {Object} Crypto utilities
|
|
29
|
+
*/
|
|
30
|
+
async function getCrypto() {
|
|
31
|
+
// Check for Web Crypto API (edge runtimes: Cloudflare Workers, Deno Deploy)
|
|
32
|
+
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle) {
|
|
33
|
+
return {
|
|
34
|
+
randomBytes: (size) => {
|
|
35
|
+
const arr = new Uint8Array(size);
|
|
36
|
+
globalThis.crypto.getRandomValues(arr);
|
|
37
|
+
// Convert to Buffer-like object
|
|
38
|
+
return {
|
|
39
|
+
toString: (encoding) => {
|
|
40
|
+
if (encoding === 'hex') {
|
|
41
|
+
return Array.from(arr)
|
|
42
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
return arr;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
createHmac: async (algorithm, secret) => {
|
|
50
|
+
const encoder = new TextEncoder();
|
|
51
|
+
const keyData = encoder.encode(secret);
|
|
52
|
+
|
|
53
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
54
|
+
'raw',
|
|
55
|
+
keyData,
|
|
56
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
57
|
+
false,
|
|
58
|
+
['sign']
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
update: (data) => {
|
|
63
|
+
const dataBytes = encoder.encode(data);
|
|
64
|
+
return {
|
|
65
|
+
digest: async (encoding) => {
|
|
66
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, dataBytes);
|
|
67
|
+
if (encoding === 'hex') {
|
|
68
|
+
return Array.from(new Uint8Array(signature))
|
|
69
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
70
|
+
.join('');
|
|
71
|
+
}
|
|
72
|
+
return signature;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
timingSafeEqual: (a, b) => {
|
|
79
|
+
// Constant-time string comparison for edge runtime
|
|
80
|
+
if (a.length !== b.length) return false;
|
|
81
|
+
let result = 0;
|
|
82
|
+
for (let i = 0; i < a.length; i++) {
|
|
83
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
84
|
+
}
|
|
85
|
+
return result === 0;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
} else {
|
|
89
|
+
// Node.js crypto module
|
|
90
|
+
const crypto = await import('crypto');
|
|
91
|
+
return {
|
|
92
|
+
randomBytes: crypto.randomBytes,
|
|
93
|
+
createHmac: (algorithm, secret) => {
|
|
94
|
+
const hmac = crypto.createHmac(algorithm, secret);
|
|
95
|
+
return {
|
|
96
|
+
update: (data) => {
|
|
97
|
+
hmac.update(data);
|
|
98
|
+
return {
|
|
99
|
+
digest: (encoding) => hmac.digest(encoding)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
timingSafeEqual: (a, b) => {
|
|
105
|
+
// String-safe constant-time comparison for Node.js
|
|
106
|
+
if (a.length !== b.length) return false;
|
|
107
|
+
|
|
108
|
+
// Use Node.js crypto.timingSafeEqual if available
|
|
109
|
+
if (crypto.timingSafeEqual) {
|
|
110
|
+
try {
|
|
111
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
112
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
113
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// Fallback if buffer creation fails
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Manual constant-time comparison for strings
|
|
120
|
+
let result = 0;
|
|
121
|
+
for (let i = 0; i < a.length; i++) {
|
|
122
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
123
|
+
}
|
|
124
|
+
return result === 0;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Cache crypto instance
|
|
131
|
+
let cryptoInstance = null;
|
|
132
|
+
|
|
133
|
+
async function getCryptoInstance() {
|
|
134
|
+
if (!cryptoInstance) {
|
|
135
|
+
cryptoInstance = await getCrypto();
|
|
136
|
+
}
|
|
137
|
+
return cryptoInstance;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// CSRF Token Store
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* CSRF Token Store - In-memory storage with automatic cleanup
|
|
146
|
+
*
|
|
147
|
+
* Stores generated tokens and their metadata for validation.
|
|
148
|
+
* Automatically cleans up expired tokens to prevent memory leaks.
|
|
149
|
+
*
|
|
150
|
+
* @class CSRFTokenStore
|
|
151
|
+
*/
|
|
152
|
+
export class CSRFTokenStore {
|
|
153
|
+
#tokens = new Map(); // token → { timestamp, used }
|
|
154
|
+
#secretKey = null;
|
|
155
|
+
#cleanupInterval = null;
|
|
156
|
+
#options;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {Object} [options] - Store options
|
|
160
|
+
* @param {string} [options.secret] - HMAC secret key (auto-generated if not provided)
|
|
161
|
+
* @param {number} [options.expiresIn=3600000] - Token expiration time in ms (default: 1 hour)
|
|
162
|
+
* @param {number} [options.cleanupInterval=600000] - Cleanup interval in ms (default: 10 minutes)
|
|
163
|
+
* @param {number} [options.maxTokens=10000] - Maximum stored tokens before cleanup
|
|
164
|
+
*/
|
|
165
|
+
constructor(options = {}) {
|
|
166
|
+
this.#options = {
|
|
167
|
+
expiresIn: options.expiresIn || 3600000, // 1 hour
|
|
168
|
+
cleanupInterval: options.cleanupInterval || 600000, // 10 minutes
|
|
169
|
+
maxTokens: options.maxTokens || 10000
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Generate or use provided HMAC secret (32 bytes = 256 bits)
|
|
173
|
+
this.#secretKey = options.secret || this.#generateSecret();
|
|
174
|
+
|
|
175
|
+
// Start automatic cleanup
|
|
176
|
+
this.#startCleanup();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate cryptographically secure HMAC secret
|
|
181
|
+
* @private
|
|
182
|
+
* @returns {string} Hex-encoded secret key
|
|
183
|
+
*/
|
|
184
|
+
#generateSecret() {
|
|
185
|
+
// Use synchronous random if available, otherwise will be async on first use
|
|
186
|
+
try {
|
|
187
|
+
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues) {
|
|
188
|
+
const bytes = new Uint8Array(32);
|
|
189
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
190
|
+
return Array.from(bytes)
|
|
191
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
192
|
+
.join('');
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
// Fall through to async generation on first token generation
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Will be generated on first use if not available synchronously
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Generate CSRF token
|
|
204
|
+
*
|
|
205
|
+
* Token format: <timestamp>.<random-bytes>.<hmac-signature>
|
|
206
|
+
*
|
|
207
|
+
* @param {Object} [options] - Generation options
|
|
208
|
+
* @param {number} [options.expiresIn] - Override default expiration
|
|
209
|
+
* @returns {Promise<string>} CSRF token
|
|
210
|
+
*/
|
|
211
|
+
async generate(options = {}) {
|
|
212
|
+
const crypto = await getCryptoInstance();
|
|
213
|
+
|
|
214
|
+
// Generate secret on first use if needed
|
|
215
|
+
if (!this.#secretKey) {
|
|
216
|
+
const bytes = crypto.randomBytes(32);
|
|
217
|
+
this.#secretKey = bytes.toString('hex');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const timestamp = Date.now();
|
|
221
|
+
|
|
222
|
+
// Generate random bytes (16 bytes = 128 bits)
|
|
223
|
+
const randomBytes = crypto.randomBytes(16);
|
|
224
|
+
const random = randomBytes.toString('hex');
|
|
225
|
+
|
|
226
|
+
// Create HMAC signature
|
|
227
|
+
const data = `${timestamp}.${random}`;
|
|
228
|
+
const hmac = await crypto.createHmac('sha256', this.#secretKey);
|
|
229
|
+
const signature = await hmac.update(data).digest('hex');
|
|
230
|
+
|
|
231
|
+
const token = `${data}.${signature}`;
|
|
232
|
+
|
|
233
|
+
// Store token metadata
|
|
234
|
+
this.#tokens.set(token, {
|
|
235
|
+
timestamp,
|
|
236
|
+
used: false,
|
|
237
|
+
expiresIn: options.expiresIn || this.#options.expiresIn
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Trigger cleanup if max tokens exceeded
|
|
241
|
+
if (this.#tokens.size > this.#options.maxTokens) {
|
|
242
|
+
this.cleanup();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return token;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validate CSRF token
|
|
250
|
+
*
|
|
251
|
+
* Performs comprehensive validation in constant-time to prevent timing attacks:
|
|
252
|
+
* 1. Format check (3 parts separated by dots)
|
|
253
|
+
* 2. Expiration check
|
|
254
|
+
* 3. HMAC signature verification (constant-time comparison)
|
|
255
|
+
* 4. Token existence check
|
|
256
|
+
* 5. Single-use check (if rotateOnUse enabled)
|
|
257
|
+
*
|
|
258
|
+
* SECURITY: All validation paths take the same time to prevent timing attacks
|
|
259
|
+
* that could leak token validity information.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} token - Token to validate
|
|
262
|
+
* @param {Object} [options] - Validation options
|
|
263
|
+
* @param {number} [options.expiresIn] - Override default expiration
|
|
264
|
+
* @param {boolean} [options.rotateOnUse=false] - Enforce single-use tokens
|
|
265
|
+
* @returns {Promise<Object>} Validation result { valid: boolean, reason?: string, expired?: boolean }
|
|
266
|
+
*/
|
|
267
|
+
async validate(token, options = {}) {
|
|
268
|
+
const crypto = await getCryptoInstance();
|
|
269
|
+
|
|
270
|
+
// Always perform full validation (constant-time - don't short-circuit)
|
|
271
|
+
let valid = true;
|
|
272
|
+
let reason = null;
|
|
273
|
+
let expired = false;
|
|
274
|
+
|
|
275
|
+
// Validation checks (always run all, don't early return)
|
|
276
|
+
const isString = token && typeof token === 'string';
|
|
277
|
+
const parts = isString ? token.split('.') : ['', '', ''];
|
|
278
|
+
const hasThreeParts = parts.length === 3;
|
|
279
|
+
|
|
280
|
+
const timestampStr = parts[0] || '';
|
|
281
|
+
const random = parts[1] || '';
|
|
282
|
+
const providedSignature = parts[2] || '';
|
|
283
|
+
|
|
284
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
285
|
+
const isTimestampValid = !isNaN(timestamp) && timestamp > 0;
|
|
286
|
+
|
|
287
|
+
// Check if token exists in store (always check, even if format invalid)
|
|
288
|
+
const stored = this.#tokens.get(token || '');
|
|
289
|
+
const expiresIn = options.expiresIn || stored?.expiresIn || this.#options.expiresIn;
|
|
290
|
+
|
|
291
|
+
// Check expiration (always compute, even if invalid)
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
const age = isTimestampValid ? (now - timestamp) : Infinity;
|
|
294
|
+
const isExpired = age > expiresIn;
|
|
295
|
+
|
|
296
|
+
// Always compute HMAC (even if earlier checks failed - constant time)
|
|
297
|
+
const data = `${timestampStr}.${random}`;
|
|
298
|
+
const hmac = await crypto.createHmac('sha256', this.#secretKey);
|
|
299
|
+
const digest = await hmac.update(data);
|
|
300
|
+
const expectedSignature = await digest.digest('hex');
|
|
301
|
+
|
|
302
|
+
// Constant-time comparison (always perform)
|
|
303
|
+
const signaturesMatch = crypto.timingSafeEqual(expectedSignature, providedSignature);
|
|
304
|
+
|
|
305
|
+
// Check single-use (always check, even if other validations failed)
|
|
306
|
+
const alreadyUsed = options.rotateOnUse && stored?.used;
|
|
307
|
+
|
|
308
|
+
// Determine result (after all checks - constant time)
|
|
309
|
+
if (!isString) {
|
|
310
|
+
valid = false;
|
|
311
|
+
reason = 'MISSING_TOKEN';
|
|
312
|
+
} else if (!hasThreeParts) {
|
|
313
|
+
valid = false;
|
|
314
|
+
reason = 'INVALID_FORMAT';
|
|
315
|
+
} else if (!isTimestampValid) {
|
|
316
|
+
valid = false;
|
|
317
|
+
reason = 'INVALID_FORMAT';
|
|
318
|
+
} else if (isExpired) {
|
|
319
|
+
valid = false;
|
|
320
|
+
reason = 'EXPIRED';
|
|
321
|
+
expired = true;
|
|
322
|
+
// Remove expired token
|
|
323
|
+
if (stored) {
|
|
324
|
+
this.#tokens.delete(token);
|
|
325
|
+
}
|
|
326
|
+
} else if (!signaturesMatch) {
|
|
327
|
+
valid = false;
|
|
328
|
+
reason = 'INVALID_SIGNATURE';
|
|
329
|
+
} else if (!stored) {
|
|
330
|
+
valid = false;
|
|
331
|
+
reason = 'UNKNOWN_TOKEN';
|
|
332
|
+
} else if (alreadyUsed) {
|
|
333
|
+
valid = false;
|
|
334
|
+
reason = 'TOKEN_ALREADY_USED';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Mark as used (only if valid and stored)
|
|
338
|
+
if (valid && stored) {
|
|
339
|
+
stored.used = true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { valid, reason, expired };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Invalidate a specific token
|
|
347
|
+
*
|
|
348
|
+
* @param {string} token - Token to invalidate
|
|
349
|
+
*/
|
|
350
|
+
invalidate(token) {
|
|
351
|
+
this.#tokens.delete(token);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Cleanup expired tokens
|
|
356
|
+
*
|
|
357
|
+
* Removes tokens older than their expiration time.
|
|
358
|
+
* Called automatically on interval, but can be called manually.
|
|
359
|
+
*
|
|
360
|
+
* @returns {number} Number of tokens removed
|
|
361
|
+
*/
|
|
362
|
+
cleanup() {
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
let removed = 0;
|
|
365
|
+
|
|
366
|
+
for (const [token, { timestamp, expiresIn }] of this.#tokens.entries()) {
|
|
367
|
+
if (now - timestamp > expiresIn) {
|
|
368
|
+
this.#tokens.delete(token);
|
|
369
|
+
removed++;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return removed;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Clear all tokens (for testing)
|
|
378
|
+
*/
|
|
379
|
+
clear() {
|
|
380
|
+
this.#tokens.clear();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get token count
|
|
385
|
+
* @returns {number} Number of stored tokens
|
|
386
|
+
*/
|
|
387
|
+
size() {
|
|
388
|
+
return this.#tokens.size;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Start automatic cleanup interval
|
|
393
|
+
* @private
|
|
394
|
+
*/
|
|
395
|
+
#startCleanup() {
|
|
396
|
+
if (this.#cleanupInterval) {
|
|
397
|
+
clearInterval(this.#cleanupInterval);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this.#cleanupInterval = setInterval(() => {
|
|
401
|
+
this.cleanup();
|
|
402
|
+
}, this.#options.cleanupInterval);
|
|
403
|
+
|
|
404
|
+
// Don't prevent process from exiting
|
|
405
|
+
if (this.#cleanupInterval.unref) {
|
|
406
|
+
this.#cleanupInterval.unref();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Stop automatic cleanup (for cleanup on shutdown)
|
|
412
|
+
*/
|
|
413
|
+
dispose() {
|
|
414
|
+
if (this.#cleanupInterval) {
|
|
415
|
+
clearInterval(this.#cleanupInterval);
|
|
416
|
+
this.#cleanupInterval = null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ============================================================================
|
|
422
|
+
// Convenience Functions
|
|
423
|
+
// ============================================================================
|
|
424
|
+
|
|
425
|
+
// Global store for convenience functions (shared across generate/validate calls)
|
|
426
|
+
let convenienceStore = null;
|
|
427
|
+
let convenienceSecret = null;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Generate CSRF token
|
|
431
|
+
*
|
|
432
|
+
* Note: Uses a shared global store for the given secret. This allows
|
|
433
|
+
* validation of tokens generated with this function.
|
|
434
|
+
*
|
|
435
|
+
* @param {string} secret - HMAC secret key (32+ bytes recommended)
|
|
436
|
+
* @param {Object} [options] - Generation options
|
|
437
|
+
* @param {number} [options.expiresIn=3600000] - Token expiration in ms
|
|
438
|
+
* @returns {Promise<string>} CSRF token
|
|
439
|
+
*/
|
|
440
|
+
export async function generateCSRFToken(secret, options = {}) {
|
|
441
|
+
// Create or reuse store for this secret
|
|
442
|
+
if (!convenienceStore || convenienceSecret !== secret) {
|
|
443
|
+
if (convenienceStore) {
|
|
444
|
+
convenienceStore.dispose();
|
|
445
|
+
}
|
|
446
|
+
convenienceStore = new CSRFTokenStore({ secret, ...options });
|
|
447
|
+
convenienceSecret = secret;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return convenienceStore.generate(options);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Validate CSRF token
|
|
455
|
+
*
|
|
456
|
+
* Note: Uses a shared global store. Can validate tokens generated with
|
|
457
|
+
* generateCSRFToken() if the same secret is used.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} token - Token to validate
|
|
460
|
+
* @param {string} secret - HMAC secret key
|
|
461
|
+
* @param {Object} [options] - Validation options
|
|
462
|
+
* @param {number} [options.expiresIn=3600000] - Token expiration in ms
|
|
463
|
+
* @returns {Promise<Object>} Validation result { valid: boolean, reason?: string }
|
|
464
|
+
*/
|
|
465
|
+
export async function validateCSRFToken(token, secret, options = {}) {
|
|
466
|
+
// Create or reuse store for this secret
|
|
467
|
+
if (!convenienceStore || convenienceSecret !== secret) {
|
|
468
|
+
if (convenienceStore) {
|
|
469
|
+
convenienceStore.dispose();
|
|
470
|
+
}
|
|
471
|
+
convenienceStore = new CSRFTokenStore({ secret, ...options });
|
|
472
|
+
convenienceSecret = secret;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return convenienceStore.validate(token, options);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ============================================================================
|
|
479
|
+
// CSRF Middleware Factory
|
|
480
|
+
// ============================================================================
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Create CSRF middleware for Server Actions
|
|
484
|
+
*
|
|
485
|
+
* Validates CSRF tokens from incoming requests and optionally rotates them.
|
|
486
|
+
*
|
|
487
|
+
* @param {Object} options - Middleware options
|
|
488
|
+
* @param {string} [options.secret] - HMAC secret (auto-generated if not provided)
|
|
489
|
+
* @param {CSRFTokenStore} [options.store] - Custom token store
|
|
490
|
+
* @param {boolean} [options.enabled=true] - Enable/disable CSRF validation
|
|
491
|
+
* @param {number} [options.expiresIn=3600000] - Token expiration in ms
|
|
492
|
+
* @param {boolean} [options.rotateOnUse=false] - Generate new token after validation
|
|
493
|
+
* @param {string} [options.headerName='x-csrf-token'] - Request header name
|
|
494
|
+
* @param {string} [options.cookieName='csrf-token'] - Cookie name for double-submit
|
|
495
|
+
* @returns {Function} Middleware function
|
|
496
|
+
*
|
|
497
|
+
* @example
|
|
498
|
+
* // Basic usage
|
|
499
|
+
* const csrfMiddleware = createCSRFMiddleware({
|
|
500
|
+
* secret: process.env.CSRF_SECRET
|
|
501
|
+
* });
|
|
502
|
+
*
|
|
503
|
+
* // With token rotation
|
|
504
|
+
* const csrfMiddleware = createCSRFMiddleware({
|
|
505
|
+
* secret: process.env.CSRF_SECRET,
|
|
506
|
+
* rotateOnUse: true
|
|
507
|
+
* });
|
|
508
|
+
*
|
|
509
|
+
* // Custom store for multi-server deployments
|
|
510
|
+
* const store = new CSRFTokenStore({ secret: 'shared-secret' });
|
|
511
|
+
* const csrfMiddleware = createCSRFMiddleware({ store });
|
|
512
|
+
*/
|
|
513
|
+
export function createCSRFMiddleware(options = {}) {
|
|
514
|
+
const {
|
|
515
|
+
secret = null,
|
|
516
|
+
store = null,
|
|
517
|
+
enabled = true,
|
|
518
|
+
expiresIn = 3600000,
|
|
519
|
+
rotateOnUse = false,
|
|
520
|
+
headerName = 'x-csrf-token',
|
|
521
|
+
cookieName = 'csrf-token',
|
|
522
|
+
secureCookie = true
|
|
523
|
+
} = options;
|
|
524
|
+
|
|
525
|
+
// Use provided store or create new one
|
|
526
|
+
const tokenStore = store || new CSRFTokenStore({ secret, expiresIn });
|
|
527
|
+
|
|
528
|
+
return async (req, res, next) => {
|
|
529
|
+
// Skip if disabled
|
|
530
|
+
if (!enabled) {
|
|
531
|
+
return next ? next() : { valid: true };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Extract token from header
|
|
535
|
+
const token = req.headers?.[headerName] || req.get?.(headerName);
|
|
536
|
+
|
|
537
|
+
// Validate token
|
|
538
|
+
const validation = await tokenStore.validate(token, {
|
|
539
|
+
expiresIn,
|
|
540
|
+
rotateOnUse
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
if (!validation.valid) {
|
|
544
|
+
// CSRF validation failed
|
|
545
|
+
const error = {
|
|
546
|
+
error: 'CSRF validation failed',
|
|
547
|
+
reason: validation.reason,
|
|
548
|
+
code: 'PSC_CSRF_INVALID'
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
if (res) {
|
|
552
|
+
// Express/Fastify/Hono response
|
|
553
|
+
return res.status?.(403).json(error) || res.json?.(error, 403);
|
|
554
|
+
} else {
|
|
555
|
+
// Return error object for manual handling
|
|
556
|
+
return { valid: false, ...error };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Rotate token if configured
|
|
561
|
+
if (rotateOnUse) {
|
|
562
|
+
const newToken = await tokenStore.generate({ expiresIn });
|
|
563
|
+
|
|
564
|
+
// Set new token in response header
|
|
565
|
+
if (res) {
|
|
566
|
+
res.setHeader?.('X-New-CSRF-Token', newToken);
|
|
567
|
+
res.set?.('X-New-CSRF-Token', newToken);
|
|
568
|
+
|
|
569
|
+
// Update cookie if using double-submit pattern
|
|
570
|
+
res.cookie?.(cookieName, newToken, {
|
|
571
|
+
httpOnly: false, // Client needs to read it
|
|
572
|
+
secure: secureCookie,
|
|
573
|
+
sameSite: 'strict',
|
|
574
|
+
maxAge: expiresIn
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Continue to next middleware or return success
|
|
580
|
+
return next ? next() : { valid: true };
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ============================================================================
|
|
585
|
+
// Exports
|
|
586
|
+
// ============================================================================
|
|
587
|
+
|
|
588
|
+
export default {
|
|
589
|
+
CSRFTokenStore,
|
|
590
|
+
generateCSRFToken,
|
|
591
|
+
validateCSRFToken,
|
|
592
|
+
createCSRFMiddleware
|
|
593
|
+
};
|