@devmoods/express-extras 0.37.0 → 0.38.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/dist/cache.js +1 -0
- package/dist/cli.js +1 -1
- package/dist/csrf.d.ts +15 -0
- package/dist/csrf.js +104 -0
- package/dist/faktory.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -1
- package/dist/middleware.d.ts +2 -1
- package/dist/middleware.js +7 -3
- package/dist/routes.js +4 -35
- package/package.json +13 -12
package/dist/cache.js
CHANGED
|
@@ -78,6 +78,7 @@ export function createCache(adapter) {
|
|
|
78
78
|
const expiresMs = typeof options === 'number' || options == null
|
|
79
79
|
? options
|
|
80
80
|
: options.expiresMs;
|
|
81
|
+
console.log({ expiresMs });
|
|
81
82
|
const result = await fn(...args);
|
|
82
83
|
await adapter.set(cacheKey, result, expiresMs);
|
|
83
84
|
return result;
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ export class CLI {
|
|
|
16
16
|
redLine(message, output = stderr) {
|
|
17
17
|
output.write(`${chalk.red(message)}\n`);
|
|
18
18
|
}
|
|
19
|
-
/*
|
|
19
|
+
/* c8 ignore next 10 */
|
|
20
20
|
readInput(question) {
|
|
21
21
|
const rl = readline.createInterface({ input: stdin, output: stderr });
|
|
22
22
|
return new Promise((resolve) => {
|
package/dist/csrf.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type RequestHandler, type Request, type Response } from 'express';
|
|
2
|
+
export interface CsrfMiddlewareOptions {
|
|
3
|
+
excempt?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function csrf({ excempt, }?: CsrfMiddlewareOptions): RequestHandler;
|
|
6
|
+
export declare function setCsrfIdentifier(identifier: string): void;
|
|
7
|
+
export declare function useCsrfIdentifier(): string;
|
|
8
|
+
export declare function setCsrfProtectionEnabled(enabled: boolean): void;
|
|
9
|
+
export declare function useCsrfProtectionEnabled(): boolean | undefined;
|
|
10
|
+
interface TokenOptions {
|
|
11
|
+
overwrite?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare const generateCsrfToken: (req: Request<any, any, any, any>, res: Response, options?: TokenOptions) => string;
|
|
14
|
+
export declare function doubleCsrfProtection(req: Request<any, any, any, any>): () => Promise<void>;
|
|
15
|
+
export {};
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import {} from 'express';
|
|
3
|
+
import { setContext, useContext } from './context.js';
|
|
4
|
+
import { Forbidden } from './errors.js';
|
|
5
|
+
import { readEnv } from './utils.js';
|
|
6
|
+
export function csrf({ excempt = false, } = {}) {
|
|
7
|
+
return (req, res, next) => {
|
|
8
|
+
setCsrfProtectionEnabled(!excempt);
|
|
9
|
+
next();
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const CSRF_IDENTIFIER_CONTEXT_KEY = '@csrfRequestIdentifier';
|
|
13
|
+
export function setCsrfIdentifier(identifier) {
|
|
14
|
+
setContext(CSRF_IDENTIFIER_CONTEXT_KEY, identifier);
|
|
15
|
+
}
|
|
16
|
+
export function useCsrfIdentifier() {
|
|
17
|
+
return useContext(CSRF_IDENTIFIER_CONTEXT_KEY) ?? '';
|
|
18
|
+
}
|
|
19
|
+
const CSRF_REQUIRED = '@csrfProtectionEnabled';
|
|
20
|
+
export function setCsrfProtectionEnabled(enabled) {
|
|
21
|
+
setContext(CSRF_REQUIRED, enabled);
|
|
22
|
+
}
|
|
23
|
+
export function useCsrfProtectionEnabled() {
|
|
24
|
+
return useContext(CSRF_REQUIRED);
|
|
25
|
+
}
|
|
26
|
+
const ignoredMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
27
|
+
const cookieName = '__Host-devmoods.x-csrf-token';
|
|
28
|
+
const generateTokenAndHash = (req, { overwrite = false } = {}) => {
|
|
29
|
+
const secret = readEnv('CSRF_SECRET', '');
|
|
30
|
+
const sessionIdentifier = useCsrfIdentifier();
|
|
31
|
+
const csrfCookie = req.cookies[cookieName];
|
|
32
|
+
if (typeof csrfCookie === 'string' && !overwrite) {
|
|
33
|
+
const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
|
|
34
|
+
if (validateTokenHash({
|
|
35
|
+
csrfToken,
|
|
36
|
+
csrfTokenHash,
|
|
37
|
+
secret,
|
|
38
|
+
sessionIdentifier,
|
|
39
|
+
})) {
|
|
40
|
+
return { csrfToken, csrfTokenHash };
|
|
41
|
+
}
|
|
42
|
+
throw Forbidden('Invalid CSRF token');
|
|
43
|
+
}
|
|
44
|
+
const csrfToken = randomBytes(64).toString('hex');
|
|
45
|
+
const csrfTokenHash = createHash('sha256')
|
|
46
|
+
.update(`${sessionIdentifier}${csrfToken}${secret}`)
|
|
47
|
+
.digest('hex');
|
|
48
|
+
return { csrfToken, csrfTokenHash };
|
|
49
|
+
};
|
|
50
|
+
export const generateCsrfToken = (req, res, options = {}) => {
|
|
51
|
+
const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, options);
|
|
52
|
+
const cookieContent = `${csrfToken}|${csrfTokenHash}`;
|
|
53
|
+
res.cookie(cookieName, cookieContent, {
|
|
54
|
+
sameSite: 'lax',
|
|
55
|
+
path: '/',
|
|
56
|
+
secure: process.env.NODE_ENV === 'production',
|
|
57
|
+
httpOnly: true,
|
|
58
|
+
});
|
|
59
|
+
return csrfToken;
|
|
60
|
+
};
|
|
61
|
+
const validateTokenHash = ({ csrfToken, csrfTokenHash, secret, sessionIdentifier, }) => {
|
|
62
|
+
if (typeof csrfToken !== 'string' || typeof csrfTokenHash !== 'string') {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const expectedHash = createHash('sha256')
|
|
66
|
+
.update(`${sessionIdentifier}${csrfToken}${secret}`)
|
|
67
|
+
.digest('hex');
|
|
68
|
+
return csrfTokenHash === expectedHash;
|
|
69
|
+
};
|
|
70
|
+
const validateRequest = (req) => {
|
|
71
|
+
const csrfCookie = req.cookies[cookieName];
|
|
72
|
+
if (typeof csrfCookie !== 'string') {
|
|
73
|
+
throw Forbidden('Missing CSRF cookie');
|
|
74
|
+
}
|
|
75
|
+
const [csrfToken, csrfTokenHash] = csrfCookie.split('|');
|
|
76
|
+
const csrfTokenFromRequest = req.headers['x-csrf-token'];
|
|
77
|
+
const secret = readEnv('CSRF_SECRET', '');
|
|
78
|
+
if (!csrfTokenFromRequest) {
|
|
79
|
+
throw Forbidden('Missing CSRF token');
|
|
80
|
+
}
|
|
81
|
+
if (csrfToken !== csrfTokenFromRequest) {
|
|
82
|
+
throw Forbidden('CSRF token mismatch');
|
|
83
|
+
}
|
|
84
|
+
if (!validateTokenHash({
|
|
85
|
+
csrfToken: csrfTokenFromRequest,
|
|
86
|
+
csrfTokenHash,
|
|
87
|
+
secret,
|
|
88
|
+
sessionIdentifier: useCsrfIdentifier(),
|
|
89
|
+
})) {
|
|
90
|
+
throw Forbidden('Invalid CSRF token');
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
export function doubleCsrfProtection(req) {
|
|
94
|
+
return async () => {
|
|
95
|
+
const isCsrfProtectionEnabled = useCsrfProtectionEnabled();
|
|
96
|
+
if (!isCsrfProtectionEnabled) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (ignoredMethods.has(req.method)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
validateRequest(req);
|
|
103
|
+
};
|
|
104
|
+
}
|
package/dist/faktory.js
CHANGED
|
@@ -11,7 +11,7 @@ export function createJobs({ queueName, faktory: createFactory = create, }) {
|
|
|
11
11
|
const fn = typeof optionsOrFn === 'function' ? optionsOrFn : fnOrUndefined;
|
|
12
12
|
const options = typeof optionsOrFn === 'function' ? {} : optionsOrFn;
|
|
13
13
|
if (!fn) {
|
|
14
|
-
throw new Error('job() must get a function
|
|
14
|
+
throw new Error('job() must get a function as the first or second argument');
|
|
15
15
|
}
|
|
16
16
|
const jobName = fn.name;
|
|
17
17
|
let executionCount = 0;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
/*
|
|
1
|
+
/* c8 ignore next 100 */
|
|
2
2
|
export * from './auth.js';
|
|
3
3
|
export * from './cache.js';
|
|
4
4
|
export * from './cli.js';
|
|
5
5
|
export * from './context.js';
|
|
6
|
+
export * from './csrf.js';
|
|
6
7
|
export * from './email.js';
|
|
7
8
|
export * from './errors.js';
|
|
8
9
|
export * from './faktory.js';
|
package/dist/middleware.d.ts
CHANGED
|
@@ -15,13 +15,14 @@ export interface BeforeMiddlewareOptions {
|
|
|
15
15
|
cookies?: boolean;
|
|
16
16
|
helmet?: boolean;
|
|
17
17
|
json?: boolean;
|
|
18
|
+
csrf?: boolean;
|
|
18
19
|
allowUnsafeInlineScripts?: boolean;
|
|
19
20
|
cspDirectives?: (defaults: BeforeMiddlewareCSPDirectives) => BeforeMiddlewareCSPDirectives;
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* Register middleware that must come before the app routes.
|
|
23
24
|
*/
|
|
24
|
-
export declare function before({ cookies, helmet: useHelmet, json: useJson, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
|
|
25
|
+
export declare function before({ cookies, helmet: useHelmet, json: useJson, csrf: useCsrf, allowUnsafeInlineScripts, cspDirectives, }?: BeforeMiddlewareOptions): import("compose-middleware").RequestHandler<unknown, unknown, void>;
|
|
25
26
|
/**
|
|
26
27
|
* Middleware that sends a 404
|
|
27
28
|
*
|
package/dist/middleware.js
CHANGED
|
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
|
|
|
4
4
|
import {} from 'express';
|
|
5
5
|
import helmet from 'helmet';
|
|
6
6
|
import { withRequestContext } from './context.js';
|
|
7
|
+
import { csrf, doubleCsrfProtection } from './csrf.js';
|
|
7
8
|
import { NotFound } from './errors.js';
|
|
8
9
|
import { getLogger } from './logger.js';
|
|
9
10
|
import { startTimer } from './utils.js';
|
|
@@ -14,9 +15,10 @@ export { asyncMiddleware as route };
|
|
|
14
15
|
/**
|
|
15
16
|
* Register middleware that must come before the app routes.
|
|
16
17
|
*/
|
|
17
|
-
export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
|
|
18
|
+
export function before({ cookies = false, helmet: useHelmet = true, json: useJson = true, csrf: useCsrf = false, allowUnsafeInlineScripts = false, cspDirectives = (x) => x, } = {}) {
|
|
18
19
|
return compose([
|
|
19
20
|
withRequestContext(),
|
|
21
|
+
useCsrf && csrf(),
|
|
20
22
|
logRequestDuration(),
|
|
21
23
|
useHelmet &&
|
|
22
24
|
helmet({
|
|
@@ -48,7 +50,7 @@ export function before({ cookies = false, helmet: useHelmet = true, json: useJso
|
|
|
48
50
|
},
|
|
49
51
|
}),
|
|
50
52
|
useJson && jsonParser(),
|
|
51
|
-
cookies && cookieParser(),
|
|
53
|
+
(cookies || useCsrf) && cookieParser(),
|
|
52
54
|
analyticsProxy(),
|
|
53
55
|
].filter(Boolean));
|
|
54
56
|
}
|
|
@@ -111,7 +113,9 @@ export function after() {
|
|
|
111
113
|
*/
|
|
112
114
|
export function asyncMiddleware(fn) {
|
|
113
115
|
const handler = (req, res, next) => {
|
|
114
|
-
return Promise.resolve(
|
|
116
|
+
return Promise.resolve()
|
|
117
|
+
.then(doubleCsrfProtection(req))
|
|
118
|
+
.then(() => fn(req, res, next))
|
|
115
119
|
.then((result) => {
|
|
116
120
|
if (Array.isArray(result) &&
|
|
117
121
|
(result.length === 2 || result.length === 3) &&
|
package/dist/routes.js
CHANGED
|
@@ -45,40 +45,24 @@ const buildPathParamsSchema = (pathParams) => {
|
|
|
45
45
|
required,
|
|
46
46
|
};
|
|
47
47
|
};
|
|
48
|
+
// Fork of express-route-parser
|
|
48
49
|
class ExpressPathParser {
|
|
49
50
|
getPaths() {
|
|
50
51
|
return this._appPaths;
|
|
51
52
|
}
|
|
52
53
|
constructor(app) {
|
|
53
|
-
/**
|
|
54
|
-
* Recursive traversal method for the express router and middleware tree.
|
|
55
|
-
*
|
|
56
|
-
* @param path The current path segment that we have traversed so far
|
|
57
|
-
* @param layer The current 'layer' of the router tree
|
|
58
|
-
* @param keys The keys for the parameter's in the current path branch of the traversal
|
|
59
|
-
* @returns void - base case saves result to internal object
|
|
60
|
-
*/
|
|
61
54
|
this.traverse = (path, layer, keys) => {
|
|
62
55
|
keys = [...keys, ...layer.keys];
|
|
63
56
|
if (layer.name === 'router' && layer.handle) {
|
|
64
|
-
layer.handle.stack
|
|
65
|
-
path
|
|
66
|
-
|
|
67
|
-
});
|
|
57
|
+
for (const l of layer.handle.stack) {
|
|
58
|
+
this.traverse(`${path || ''}/${pathRegexParser(layer.regexp, layer.keys)}`, l, keys);
|
|
59
|
+
}
|
|
68
60
|
}
|
|
69
61
|
if (!layer.route || !layer.route.stack.length) {
|
|
70
62
|
return;
|
|
71
63
|
}
|
|
72
64
|
this._appPaths.push(this.parseRouteLayer(layer, keys, path));
|
|
73
65
|
};
|
|
74
|
-
/**
|
|
75
|
-
* Parses a route object. Route objects are the leafs of an express router tree
|
|
76
|
-
*
|
|
77
|
-
* @param layer The layer of this route object - represents the stack of middleware and other meta data
|
|
78
|
-
* @param keys The full set of keys for this particular route
|
|
79
|
-
* @param basePath The base path as it was initial declared for this route
|
|
80
|
-
* @returns A ExpressPath object holding the meta data for a given route
|
|
81
|
-
*/
|
|
82
66
|
this.parseRouteLayer = (layer, keys, basePath) => {
|
|
83
67
|
const lastRequestHandler = layer.route.stack[layer.route.stack.length - 1];
|
|
84
68
|
const pathParams = keys.map((key) => {
|
|
@@ -105,14 +89,6 @@ class ExpressPathParser {
|
|
|
105
89
|
}
|
|
106
90
|
}
|
|
107
91
|
}
|
|
108
|
-
/** Parses an express layer's regex and converts it to the original format seen in code.
|
|
109
|
-
*
|
|
110
|
-
* @param layerRegexPath The layer's regex pattern
|
|
111
|
-
* @param keys The keys that represent the layer's path parameters
|
|
112
|
-
* @returns The path string for that layer
|
|
113
|
-
* Code inspired and modify from:
|
|
114
|
-
* https://github.com/expressjs/express/issues/3308#issuecomment-300957572
|
|
115
|
-
*/
|
|
116
92
|
const pathRegexParser = (layerRegexPath, keys) => {
|
|
117
93
|
if (typeof layerRegexPath === 'string') {
|
|
118
94
|
return layerRegexPath;
|
|
@@ -140,13 +116,6 @@ const pathRegexParser = (layerRegexPath, keys) => {
|
|
|
140
116
|
}
|
|
141
117
|
return layerRegexPath.toString();
|
|
142
118
|
};
|
|
143
|
-
/**
|
|
144
|
-
* Map's the keys/path variables to the regex inside a given path
|
|
145
|
-
*
|
|
146
|
-
* @param layerRegexPath The regex for a react router with path parameters
|
|
147
|
-
* @param keys The keys that represent the path parameters
|
|
148
|
-
* @returns The regex for a path variable converted to original string on the express route
|
|
149
|
-
*/
|
|
150
119
|
const mapKeysToPath = (layerRegexPath, keys) => {
|
|
151
120
|
if (!keys || keys.length === 0) {
|
|
152
121
|
throw Error('must include atleast one key to map');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devmoods/express-extras",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Kubernetes-ready utilities for rapid Node.js application development",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@devmoods/eslint-config": "^1.14.0",
|
|
22
22
|
"@types/koa-compose": "^3.2.8",
|
|
23
|
-
"@types/node": "22.8.
|
|
23
|
+
"@types/node": "22.8.6",
|
|
24
24
|
"@types/supertest": "^6.0.2",
|
|
25
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
26
|
-
"@typescript-eslint/parser": "^8.
|
|
27
|
-
"@vitest/coverage-v8": "^2.1.
|
|
28
|
-
"
|
|
25
|
+
"@typescript-eslint/eslint-plugin": "^8.12.2",
|
|
26
|
+
"@typescript-eslint/parser": "^8.12.2",
|
|
27
|
+
"@vitest/coverage-v8": "^2.1.4",
|
|
28
|
+
"cookiejar": "^2.1.4",
|
|
29
29
|
"eslint": "^8.57.0",
|
|
30
30
|
"eslint-plugin-import": "^2.31.0",
|
|
31
31
|
"eslint-plugin-prettier": "^5.2.1",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"prettier": "3.3.3",
|
|
34
34
|
"supertest": "7.0.0",
|
|
35
35
|
"ts-node": "10.9.2",
|
|
36
|
-
"tsx": "^4.19.
|
|
36
|
+
"tsx": "^4.19.2",
|
|
37
37
|
"typescript": "5.6.3",
|
|
38
|
-
"vitest": "^2.1.
|
|
38
|
+
"vitest": "^2.1.4",
|
|
39
39
|
"vitest-fail-on-console": "^0.7.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@devmoods/fetch": "^4.0.1",
|
|
43
|
-
"@sentry/node": "^8.
|
|
44
|
-
"@sentry/profiling-node": "^8.
|
|
43
|
+
"@sentry/node": "^8.36.0",
|
|
44
|
+
"@sentry/profiling-node": "^8.36.0",
|
|
45
45
|
"@types/bcrypt": "^5.0.2",
|
|
46
46
|
"@types/body-parser": "^1.19.5",
|
|
47
47
|
"@types/cookie-parser": "^1.4.7",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"ajv": "^8.17.1",
|
|
55
55
|
"bcrypt": "^5.1.1",
|
|
56
56
|
"body-parser": "^1.20.3",
|
|
57
|
+
"chalk": "^5.3.0",
|
|
57
58
|
"commander": "^12.1.0",
|
|
58
59
|
"compose-middleware": "^5.0.1",
|
|
59
60
|
"cookie-parser": "^1.4.7",
|
|
@@ -61,11 +62,11 @@
|
|
|
61
62
|
"express": "^4.21.1",
|
|
62
63
|
"helmet": "^8.0.0",
|
|
63
64
|
"jsonwebtoken": "^9.0.2",
|
|
64
|
-
"nodemailer": "^6.9.
|
|
65
|
+
"nodemailer": "^6.9.16",
|
|
65
66
|
"pg": "^8.13.1",
|
|
66
67
|
"redis": "^4.7.0",
|
|
67
68
|
"sql-template-strings": "^2.2.2",
|
|
68
|
-
"uuid": "^
|
|
69
|
+
"uuid": "^11.0.2"
|
|
69
70
|
},
|
|
70
71
|
"prettier": {
|
|
71
72
|
"singleQuote": true
|