@chidchanun/bcp 0.2.5 → 0.2.6

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.
@@ -0,0 +1,596 @@
1
+ import {
2
+ createHmac,
3
+ randomBytes,
4
+ timingSafeEqual,
5
+ } from "node:crypto";
6
+
7
+ import {
8
+ cookies,
9
+ headers,
10
+ requestMethod,
11
+ requestUrl,
12
+ type CookieSameSite,
13
+ } from "./request-context.js";
14
+
15
+ const DEFAULT_CSRF_COOKIE_NAME =
16
+ "bcp_csrf";
17
+ const DEFAULT_CSRF_HEADER_NAME =
18
+ "x-bcp-csrf";
19
+ const DEFAULT_CSRF_MAX_AGE =
20
+ 60 * 60 * 2;
21
+ const MINIMUM_SECRET_BYTES =
22
+ 32;
23
+ const SAFE_METHODS =
24
+ new Set([
25
+ "GET",
26
+ "HEAD",
27
+ "OPTIONS",
28
+ ]);
29
+
30
+ export interface SameOriginOptions {
31
+ allowedOrigins?: readonly string[];
32
+ allowMissingOrigin?: boolean;
33
+ }
34
+
35
+ export interface CsrfTokenOptions {
36
+ secret?: string;
37
+ cookieName?: string;
38
+ headerName?: string;
39
+ maxAge?: number;
40
+ path?: string;
41
+ domain?: string;
42
+ secure?: boolean;
43
+ sameSite?: CookieSameSite;
44
+ }
45
+
46
+ export interface VerifyCsrfRequestOptions
47
+ extends
48
+ CsrfTokenOptions,
49
+ SameOriginOptions {
50
+ token?: string;
51
+ }
52
+
53
+ export class RequestSecurityError
54
+ extends Error {
55
+ readonly status = 403;
56
+ readonly code:
57
+ | "INVALID_ORIGIN"
58
+ | "INVALID_CSRF_TOKEN";
59
+
60
+ constructor(
61
+ code:
62
+ | "INVALID_ORIGIN"
63
+ | "INVALID_CSRF_TOKEN",
64
+ message: string
65
+ ) {
66
+ super(message);
67
+ this.name =
68
+ "RequestSecurityError";
69
+ this.code =
70
+ code;
71
+ }
72
+ }
73
+
74
+ export function isSafeHttpMethod(
75
+ method: string
76
+ ): boolean {
77
+ return SAFE_METHODS.has(
78
+ String(method)
79
+ .trim()
80
+ .toUpperCase()
81
+ );
82
+ }
83
+
84
+ export async function isSameOriginRequest(
85
+ options:
86
+ SameOriginOptions = {}
87
+ ): Promise<boolean> {
88
+ const method =
89
+ await requestMethod();
90
+
91
+ if (
92
+ isSafeHttpMethod(
93
+ method
94
+ )
95
+ ) {
96
+ return true;
97
+ }
98
+
99
+ const url =
100
+ await requestUrl();
101
+ const requestHeaders =
102
+ await headers();
103
+ const allowedOrigins =
104
+ new Set<string>([
105
+ url.origin,
106
+ ...normalizeAllowedOrigins(
107
+ options.allowedOrigins
108
+ ),
109
+ ]);
110
+ const originHeader =
111
+ requestHeaders.get(
112
+ "origin"
113
+ );
114
+
115
+ if (originHeader) {
116
+ const origin =
117
+ tryNormalizeOrigin(
118
+ originHeader
119
+ );
120
+
121
+ return origin !== null &&
122
+ allowedOrigins.has(
123
+ origin
124
+ );
125
+ }
126
+
127
+ const referer =
128
+ requestHeaders.get(
129
+ "referer"
130
+ );
131
+
132
+ if (referer) {
133
+ const origin =
134
+ tryNormalizeOrigin(
135
+ referer
136
+ );
137
+
138
+ return origin !== null &&
139
+ allowedOrigins.has(
140
+ origin
141
+ );
142
+ }
143
+
144
+ return options.allowMissingOrigin ===
145
+ true;
146
+ }
147
+
148
+ export async function requireSameOriginRequest(
149
+ options:
150
+ SameOriginOptions = {}
151
+ ): Promise<void> {
152
+ if (
153
+ !await isSameOriginRequest(
154
+ options
155
+ )
156
+ ) {
157
+ throw new RequestSecurityError(
158
+ "INVALID_ORIGIN",
159
+ "BCP Security: request origin is not allowed."
160
+ );
161
+ }
162
+ }
163
+
164
+ export async function createCsrfToken(
165
+ options:
166
+ CsrfTokenOptions = {}
167
+ ): Promise<string> {
168
+ const secret =
169
+ resolveCsrfSecret(
170
+ options.secret
171
+ );
172
+ const maxAge =
173
+ resolveMaxAge(
174
+ options.maxAge
175
+ );
176
+ const issuedAt =
177
+ currentUnixTime();
178
+ const nonce =
179
+ randomBytes(32)
180
+ .toString(
181
+ "base64url"
182
+ );
183
+ const payload =
184
+ `v1.${issuedAt}.${nonce}`;
185
+ const signature =
186
+ signToken(
187
+ payload,
188
+ secret
189
+ );
190
+ const token =
191
+ `${payload}.${signature}`;
192
+ const cookieStore =
193
+ await cookies();
194
+
195
+ cookieStore.set(
196
+ resolveCookieName(
197
+ options.cookieName
198
+ ),
199
+ token,
200
+ {
201
+ httpOnly: true,
202
+ secure:
203
+ options.secure ??
204
+ process.env.NODE_ENV ===
205
+ "production",
206
+ sameSite:
207
+ options.sameSite ??
208
+ "lax",
209
+ path:
210
+ options.path ??
211
+ "/",
212
+ domain:
213
+ options.domain,
214
+ maxAge,
215
+ }
216
+ );
217
+
218
+ return token;
219
+ }
220
+
221
+ export async function destroyCsrfToken(
222
+ options:
223
+ CsrfTokenOptions = {}
224
+ ): Promise<void> {
225
+ const cookieStore =
226
+ await cookies();
227
+
228
+ cookieStore.delete(
229
+ resolveCookieName(
230
+ options.cookieName
231
+ ),
232
+ {
233
+ path:
234
+ options.path ??
235
+ "/",
236
+ domain:
237
+ options.domain,
238
+ }
239
+ );
240
+ }
241
+
242
+ export async function verifyCsrfToken(
243
+ token?: string,
244
+ options:
245
+ CsrfTokenOptions = {}
246
+ ): Promise<boolean> {
247
+ const cookieStore =
248
+ await cookies();
249
+ const cookieToken =
250
+ cookieStore.get(
251
+ resolveCookieName(
252
+ options.cookieName
253
+ )
254
+ )?.value;
255
+ const submittedToken =
256
+ token ??
257
+ (
258
+ await headers()
259
+ ).get(
260
+ resolveHeaderName(
261
+ options.headerName
262
+ )
263
+ ) ??
264
+ undefined;
265
+
266
+ if (
267
+ !cookieToken ||
268
+ !submittedToken ||
269
+ !safeStringEqual(
270
+ cookieToken,
271
+ submittedToken
272
+ )
273
+ ) {
274
+ return false;
275
+ }
276
+
277
+ const parts =
278
+ submittedToken.split(".");
279
+
280
+ if (
281
+ parts.length !== 4 ||
282
+ parts[0] !== "v1"
283
+ ) {
284
+ return false;
285
+ }
286
+
287
+ const issuedAt =
288
+ Number(
289
+ parts[1]
290
+ );
291
+
292
+ if (
293
+ !Number.isInteger(
294
+ issuedAt
295
+ ) ||
296
+ issuedAt < 0
297
+ ) {
298
+ return false;
299
+ }
300
+
301
+ const maxAge =
302
+ resolveMaxAge(
303
+ options.maxAge
304
+ );
305
+ const now =
306
+ currentUnixTime();
307
+
308
+ if (
309
+ issuedAt > now + 60 ||
310
+ now - issuedAt > maxAge
311
+ ) {
312
+ return false;
313
+ }
314
+
315
+ const payload =
316
+ parts
317
+ .slice(0, 3)
318
+ .join(".");
319
+ const expectedSignature =
320
+ signToken(
321
+ payload,
322
+ resolveCsrfSecret(
323
+ options.secret
324
+ )
325
+ );
326
+
327
+ return safeStringEqual(
328
+ parts[3],
329
+ expectedSignature
330
+ );
331
+ }
332
+
333
+ export async function verifyCsrfRequest(
334
+ options:
335
+ VerifyCsrfRequestOptions = {}
336
+ ): Promise<boolean> {
337
+ if (
338
+ isSafeHttpMethod(
339
+ await requestMethod()
340
+ )
341
+ ) {
342
+ return true;
343
+ }
344
+
345
+ if (
346
+ !await isSameOriginRequest(
347
+ options
348
+ )
349
+ ) {
350
+ return false;
351
+ }
352
+
353
+ return verifyCsrfToken(
354
+ options.token,
355
+ options
356
+ );
357
+ }
358
+
359
+ export async function requireCsrfRequest(
360
+ options:
361
+ VerifyCsrfRequestOptions = {}
362
+ ): Promise<void> {
363
+ if (
364
+ !await isSameOriginRequest(
365
+ options
366
+ )
367
+ ) {
368
+ throw new RequestSecurityError(
369
+ "INVALID_ORIGIN",
370
+ "BCP Security: request origin is not allowed."
371
+ );
372
+ }
373
+
374
+ if (
375
+ isSafeHttpMethod(
376
+ await requestMethod()
377
+ )
378
+ ) {
379
+ return;
380
+ }
381
+
382
+ if (
383
+ !await verifyCsrfToken(
384
+ options.token,
385
+ options
386
+ )
387
+ ) {
388
+ throw new RequestSecurityError(
389
+ "INVALID_CSRF_TOKEN",
390
+ "BCP Security: CSRF token is missing, expired, or invalid."
391
+ );
392
+ }
393
+ }
394
+
395
+ function resolveCsrfSecret(
396
+ explicitSecret:
397
+ string | undefined
398
+ ): string {
399
+ const secret =
400
+ explicitSecret ??
401
+ process.env.BCP_CSRF_SECRET ??
402
+ process.env.BCP_SESSION_SECRET;
403
+
404
+ if (!secret) {
405
+ throw new Error(
406
+ "BCP Security: BCP_CSRF_SECRET or BCP_SESSION_SECRET is required for CSRF tokens."
407
+ );
408
+ }
409
+
410
+ if (
411
+ Buffer.byteLength(
412
+ secret,
413
+ "utf8"
414
+ ) <
415
+ MINIMUM_SECRET_BYTES
416
+ ) {
417
+ throw new Error(
418
+ `BCP Security: CSRF secret must be at least ${MINIMUM_SECRET_BYTES} bytes.`
419
+ );
420
+ }
421
+
422
+ return secret;
423
+ }
424
+
425
+ function resolveCookieName(
426
+ value:
427
+ string | undefined
428
+ ): string {
429
+ const result =
430
+ value?.trim() ||
431
+ DEFAULT_CSRF_COOKIE_NAME;
432
+
433
+ if (
434
+ !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(
435
+ result
436
+ )
437
+ ) {
438
+ throw new TypeError(
439
+ "BCP Security: CSRF cookie name is invalid."
440
+ );
441
+ }
442
+
443
+ return result;
444
+ }
445
+
446
+ function resolveHeaderName(
447
+ value:
448
+ string | undefined
449
+ ): string {
450
+ const result =
451
+ value?.trim().toLowerCase() ||
452
+ DEFAULT_CSRF_HEADER_NAME;
453
+
454
+ if (
455
+ !/^[a-z0-9!#$%&'*+\-.^_`|~]+$/.test(
456
+ result
457
+ )
458
+ ) {
459
+ throw new TypeError(
460
+ "BCP Security: CSRF header name is invalid."
461
+ );
462
+ }
463
+
464
+ return result;
465
+ }
466
+
467
+ function resolveMaxAge(
468
+ value:
469
+ number | undefined
470
+ ): number {
471
+ const maxAge =
472
+ value ??
473
+ DEFAULT_CSRF_MAX_AGE;
474
+
475
+ if (
476
+ !Number.isFinite(
477
+ maxAge
478
+ ) ||
479
+ maxAge <= 0
480
+ ) {
481
+ throw new TypeError(
482
+ "BCP Security: CSRF maxAge must be a positive finite number of seconds."
483
+ );
484
+ }
485
+
486
+ return Math.floor(
487
+ maxAge
488
+ );
489
+ }
490
+
491
+ function normalizeAllowedOrigins(
492
+ values:
493
+ readonly string[] |
494
+ undefined
495
+ ): string[] {
496
+ if (!values) {
497
+ return [];
498
+ }
499
+
500
+ return Array.from(
501
+ new Set(
502
+ values.map(
503
+ normalizeOrigin
504
+ )
505
+ )
506
+ );
507
+ }
508
+
509
+ function normalizeOrigin(
510
+ value: string
511
+ ): string {
512
+ const origin =
513
+ tryNormalizeOrigin(
514
+ value
515
+ );
516
+
517
+ if (!origin) {
518
+ throw new TypeError(
519
+ `BCP Security: invalid origin \"${value}\".`
520
+ );
521
+ }
522
+
523
+ return origin;
524
+ }
525
+
526
+ function tryNormalizeOrigin(
527
+ value: string
528
+ ): string | null {
529
+ try {
530
+ const url =
531
+ new URL(
532
+ value.trim()
533
+ );
534
+
535
+ if (
536
+ url.protocol !== "http:" &&
537
+ url.protocol !== "https:"
538
+ ) {
539
+ return null;
540
+ }
541
+
542
+ return url.origin;
543
+ } catch {
544
+ return null;
545
+ }
546
+ }
547
+
548
+ function signToken(
549
+ payload: string,
550
+ secret: string
551
+ ): string {
552
+ return createHmac(
553
+ "sha256",
554
+ secret
555
+ )
556
+ .update(
557
+ `bcp-csrf:${payload}`,
558
+ "utf8"
559
+ )
560
+ .digest(
561
+ "base64url"
562
+ );
563
+ }
564
+
565
+ function safeStringEqual(
566
+ left: string,
567
+ right: string
568
+ ): boolean {
569
+ const leftBuffer =
570
+ Buffer.from(
571
+ left,
572
+ "utf8"
573
+ );
574
+ const rightBuffer =
575
+ Buffer.from(
576
+ right,
577
+ "utf8"
578
+ );
579
+
580
+ return (
581
+ leftBuffer.length ===
582
+ rightBuffer.length &&
583
+ timingSafeEqual(
584
+ leftBuffer,
585
+ rightBuffer
586
+ )
587
+ );
588
+ }
589
+
590
+ function currentUnixTime():
591
+ number {
592
+ return Math.floor(
593
+ Date.now() /
594
+ 1000
595
+ );
596
+ }