@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 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
- /* v8 ignore next 10 */
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 has the first or second argument');
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
@@ -2,6 +2,7 @@ export * from './auth.js';
2
2
  export * from './cache.js';
3
3
  export * from './cli.js';
4
4
  export * from './context.js';
5
+ export * from './csrf.js';
5
6
  export * from './email.js';
6
7
  export * from './errors.js';
7
8
  export * from './faktory.js';
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
- /* v8 ignore next 100 */
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';
@@ -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
  *
@@ -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(fn(req, res, next))
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.forEach((l) => {
65
- path = path || '';
66
- this.traverse(`${path}/${pathRegexParser(layer.regexp, layer.keys)}`, l, keys);
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.37.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.1",
23
+ "@types/node": "22.8.6",
24
24
  "@types/supertest": "^6.0.2",
25
- "@typescript-eslint/eslint-plugin": "^8.11.0",
26
- "@typescript-eslint/parser": "^8.11.0",
27
- "@vitest/coverage-v8": "^2.1.3",
28
- "chalk": "^5.3.0",
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.1",
36
+ "tsx": "^4.19.2",
37
37
  "typescript": "5.6.3",
38
- "vitest": "^2.1.3",
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.35.0",
44
- "@sentry/profiling-node": "^8.35.0",
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.15",
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": "^10.0.0"
69
+ "uuid": "^11.0.2"
69
70
  },
70
71
  "prettier": {
71
72
  "singleQuote": true