@chidchanun/bcp 0.2.5 → 0.2.7
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 +166 -67
- package/docs/README.md +43 -53
- package/docs/api-manifest.json +16 -3
- package/docs/api-reference.md +78 -6
- package/docs/auth-route-guards.md +58 -50
- package/docs/authorization-security.md +298 -0
- package/docs/development-logging.md +44 -1
- package/docs/docs-web-manifest.json +10 -6
- package/docs/observability.md +445 -0
- package/docs/platform-manifest.json +18 -4
- package/docs/releases/0.2.6.md +194 -0
- package/docs/releases/0.2.7.md +205 -0
- package/docs/security.md +55 -1
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/client/src/auth.ts +20 -0
- package/packages/client/src/observability.ts +22 -0
- package/packages/client/src/server.ts +16 -0
- package/packages/server/src/auth-guard.ts +111 -15
- package/packages/server/src/authorization.ts +368 -0
- package/packages/server/src/observability.ts +1258 -0
- package/packages/server/src/request-security.ts +596 -0
|
@@ -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
|
+
}
|