From a07ff815724811ae8530886d5d947b2e8112e60c Mon Sep 17 00:00:00 2001 From: psibean Date: Wed, 15 May 2024 23:31:04 +0930 Subject: [PATCH 01/12] fix: ensure types are correctly exported --- src/index.ts | 2 ++ src/types.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index abfe2ab..ec70d42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ import type { RequestMethod, } from "./types"; +export * from "./types"; + export function doubleCsrf({ getSecret, cookieName = "__Host-psifi.x-csrf-token", diff --git a/src/types.ts b/src/types.ts index 142d90c..811d8a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,3 +176,5 @@ export interface DoubleCsrfUtilities { */ doubleCsrfProtection: doubleCsrfProtection; } + +export {}; \ No newline at end of file From e27da6a196452b0ff35c58928fc9fbd2761df501 Mon Sep 17 00:00:00 2001 From: psibean Date: Wed, 15 May 2024 23:32:45 +0930 Subject: [PATCH 02/12] chore(release): 3.0.5 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2ec6d..971e575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [3.0.5](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.4...v3.0.5) (2024-05-15) + + +### Bug Fixes + +* ensure types are correctly exported ([a07ff81](https://github.com/Psifi-Solutions/csrf-csrf/commit/a07ff815724811ae8530886d5d947b2e8112e60c)) + ### [3.0.4](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.3...v3.0.4) (2024-04-03) diff --git a/package-lock.json b/package-lock.json index 56749b9..27a80b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "csrf-csrf", - "version": "3.0.4", + "version": "3.0.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "csrf-csrf", - "version": "3.0.4", + "version": "3.0.5", "license": "ISC", "dependencies": { "http-errors": "^2.0.0" diff --git a/package.json b/package.json index b4af756..9ed948f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "csrf-csrf", - "version": "3.0.4", + "version": "3.0.5", "description": "A utility package to help implement stateless CSRF protection using the Double Submit Cookie Pattern in express.", "type": "module", "main": "./lib/cjs/index.cjs", From 48f6c560be6dd1e70811ba7f35f3c7cced3c365c Mon Sep 17 00:00:00 2001 From: psibean Date: Fri, 17 May 2024 18:21:08 +0930 Subject: [PATCH 03/12] chore(release): 3.0.6 --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 971e575..1e5f462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [3.0.6](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.5...v3.0.6) (2024-05-17) + +* No changes, just a bump to fix broken release + ### [3.0.5](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.4...v3.0.5) (2024-05-15) diff --git a/package-lock.json b/package-lock.json index 27a80b0..4113fad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "csrf-csrf", - "version": "3.0.5", + "version": "3.0.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "csrf-csrf", - "version": "3.0.5", + "version": "3.0.6", "license": "ISC", "dependencies": { "http-errors": "^2.0.0" diff --git a/package.json b/package.json index 9ed948f..2ad3b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "csrf-csrf", - "version": "3.0.5", + "version": "3.0.6", "description": "A utility package to help implement stateless CSRF protection using the Double Submit Cookie Pattern in express.", "type": "module", "main": "./lib/cjs/index.cjs", From a1864c9db6c85c40c7e98e59f944fa53c0b033f1 Mon Sep 17 00:00:00 2001 From: psibean Date: Tue, 6 Aug 2024 21:12:20 +0930 Subject: [PATCH 04/12] build: build and test against versioned branches --- .github/workflows/build-and-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 421453d..6013b3b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -5,9 +5,9 @@ name: Build and Test on: push: - branches: [ "main" ] + branches: [ "main", "v[0-9].x.x" ] pull_request: - branches: [ "main" ] + branches: [ "main", "v[0-9].x.x" ] permissions: checks: write From 2c858f54d8e275178607e5482badf17daf291d40 Mon Sep 17 00:00:00 2001 From: psibean Date: Tue, 6 Aug 2024 21:20:16 +0930 Subject: [PATCH 05/12] chore: remove unnecessary default export --- src/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index 811d8a7..142d90c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,5 +176,3 @@ export interface DoubleCsrfUtilities { */ doubleCsrfProtection: doubleCsrfProtection; } - -export {}; \ No newline at end of file From 710d2f6082f1ac8ab884b10913b1b86195f86bd2 Mon Sep 17 00:00:00 2001 From: psibean Date: Mon, 19 Aug 2024 00:50:48 +0930 Subject: [PATCH 06/12] feat: support optional stateless association of token with session Added the getSessionIdentifier parameter to the csrf-csrf configuration. By providing the getSessionIdentifier callback, generated tokens will only be valid for the original session identifier they were generated for. For example: (req) => req.session.id The token will now be signed with the session id included, this means a generated CSRF token will only be valid for the session it was generated for. This also means that if you rotate your sessions (which you should) you will also need to generate a new CSRF token for the session after rotating it. --- README.md | 20 +++++ src/index.ts | 35 +++++--- src/tests/getSessionIdentifier.test.ts | 106 +++++++++++++++++++++++++ src/tests/utils/helpers.ts | 9 ++- src/tests/utils/mock.ts | 34 ++++++-- src/types.ts | 26 +++++- 6 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 src/tests/getSessionIdentifier.test.ts diff --git a/README.md b/README.md index 86b6f74..455c02f 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ When creating your doubleCsrf, you have a few options available for configuratio ```js const doubleCsrfUtilities = doubleCsrf({ getSecret: () => "Secret", // A function that optionally takes the request and returns a secret + getSessionIdentifier: (req) => "", // A function that should return the session identifier for a given request cookieName: "__Host-psifi.x-csrf-token", // The name of the cookie to be used, recommend using Host prefix. cookieOptions: { sameSite = "lax", // Recommend you make this strict if posible @@ -213,6 +214,25 @@ const doubleCsrfUtilities = doubleCsrf({

In case multiple are provided, the first one will be used for hashing. For validation, all secrets will be tried, preferring the first one in the array. Having multiple valid secrets can be useful when you need to rotate secrets, but you don't want to invalidate the previous secret (which might still be used by some users) right away.

+

getSessionIdentifier

+ +```ts +(req: Request) => string; +``` + +

+ Optional
+ Default: () => ""
+

+ +

A function that takes in the request and returns the unique session identifier for that request. For example:

+ +```ts +(req: Request) => req.session.id; +``` + +

This will ensure that CSRF tokens are signed with the unique identifier included, this means tokens will only be valid for the session that they were requested by and generated for.

+

cookieName

```ts diff --git a/src/index.ts b/src/index.ts index ec70d42..5a71ec8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export * from "./types"; export function doubleCsrf({ getSecret, + getSessionIdentifier = () => "", cookieName = "__Host-psifi.x-csrf-token", cookieOptions: { sameSite = "lax", @@ -64,7 +65,14 @@ export function doubleCsrf({ // generate a new token based on validateOnReuse. if (typeof csrfCookie === "string" && !overwrite) { const [csrfToken, csrfTokenHash] = csrfCookie.split("|"); - if (validateTokenAndHashPair(csrfToken, csrfTokenHash, possibleSecrets)) { + if ( + validateTokenAndHashPair({ + csrfToken, + csrfTokenHash, + possibleSecrets, + sessionIdentifier: getSessionIdentifier(req), + }) + ) { // If the pair is valid, reuse it return { csrfToken, csrfTokenHash }; } else if (validateOnReuse) { @@ -78,7 +86,7 @@ export function doubleCsrf({ // the 'newest' or preferred secret is the first one in the array const secret = possibleSecrets[0]; const csrfTokenHash = createHash("sha256") - .update(`${csrfToken}${secret}`) + .update(`${getSessionIdentifier(req)}${csrfToken}${secret}`) .digest("hex"); return { csrfToken, csrfTokenHash }; @@ -110,18 +118,20 @@ export function doubleCsrf({ : (req: Request) => req.cookies[cookieName] as string; // given a secret array, iterates over it and checks whether one of the secrets makes the token and hash pair valid - const validateTokenAndHashPair: CsrfTokenAndHashPairValidator = ( - token, - hash, + const validateTokenAndHashPair: CsrfTokenAndHashPairValidator = ({ + csrfToken, + csrfTokenHash, possibleSecrets, - ) => { - if (typeof token !== "string" || typeof hash !== "string") return false; + sessionIdentifier, + }) => { + if (typeof csrfToken !== "string" || typeof csrfTokenHash !== "string") + return false; for (const secret of possibleSecrets) { const expectedHash = createHash("sha256") - .update(`${token}${secret}`) + .update(`${sessionIdentifier}${csrfToken}${secret}`) .digest("hex"); - if (hash === expectedHash) return true; + if (csrfTokenHash === expectedHash) return true; } return false; @@ -145,11 +155,12 @@ export function doubleCsrf({ return ( csrfToken === csrfTokenFromRequest && - validateTokenAndHashPair( - csrfTokenFromRequest, + validateTokenAndHashPair({ + csrfToken: csrfTokenFromRequest, csrfTokenHash, possibleSecrets, - ) + sessionIdentifier: getSessionIdentifier(req), + }) ); }; diff --git a/src/tests/getSessionIdentifier.test.ts b/src/tests/getSessionIdentifier.test.ts new file mode 100644 index 0000000..62fb58e --- /dev/null +++ b/src/tests/getSessionIdentifier.test.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { assert, expect } from "chai"; +import { doubleCsrf } from "../index.js"; +import { + generateMocksWithToken, + next, + RequestWithSessionId, +} from "./utils/mock.js"; +import { + getSingleSecret, + attachResponseValuesToRequest, +} from "./utils/helpers.js"; + +describe("csrf-csrf with getSessionIdentifier", () => { + const cookieName = "xsrf-protection"; + const sessionIdentifier = "asdf68236tr3g34fgds9fgsd9g23grb3"; + + const { + invalidCsrfTokenError, + generateToken, + validateRequest, + doubleCsrfProtection, + } = doubleCsrf({ + cookieName, + getSecret: getSingleSecret, + getSessionIdentifier: (req) => + (req as RequestWithSessionId).session.id ?? "", + }); + + it("should have a valid CSRF token for the session it was generated for", () => { + const { mockRequest, mockResponse } = generateMocksWithToken({ + cookieName, + generateToken, + validateRequest, + signed: false, + sessionIdentifier, + }); + + expect(() => { + doubleCsrfProtection(mockRequest, mockResponse, next); + }, "CSRF protection should be valid").not.to.throw(invalidCsrfTokenError); + }); + + it("should not be a valid CSRF token for a session it was not generated for", () => { + const { mockRequest, mockResponse } = generateMocksWithToken({ + cookieName, + generateToken, + validateRequest, + signed: false, + sessionIdentifier, + }); + + (mockRequest as RequestWithSessionId).session.id = "sdf9342dfa245r13tgvrf"; + + expect(() => { + doubleCsrfProtection(mockRequest, mockResponse, next); + }, "CSRF protection should be invalid").to.throw(invalidCsrfTokenError); + }); + + it("should throw when validateOnReuse is true and session has been rotated", () => { + const { mockRequest, mockResponse } = generateMocksWithToken({ + cookieName, + generateToken, + validateRequest, + signed: false, + sessionIdentifier, + }); + + (mockRequest as RequestWithSessionId).session.id = "sdf9342dfa245r13tgvrf"; + + assert.isFalse(validateRequest(mockRequest)); + expect(() => + generateToken(mockRequest, mockResponse, false, true), + ).to.throw(invalidCsrfTokenError); + }); + + it("should generate a new valid token after session has been rotated", () => { + const { csrfToken, mockRequest, mockResponse } = generateMocksWithToken({ + cookieName, + generateToken, + validateRequest, + signed: false, + sessionIdentifier, + }); + + (mockRequest as RequestWithSessionId).session.id = "sdf9342dfa245r13tgvrf"; + console.log("generating a new token"); + const newCsrfToken = generateToken(mockRequest, mockResponse, true); + console.log("new token generated"); + assert.notEqual( + newCsrfToken, + csrfToken, + "New token and original token should not match", + ); + attachResponseValuesToRequest({ + request: mockRequest, + response: mockResponse, + bodyResponseToken: newCsrfToken, + cookieName, + }); + assert.isTrue(validateRequest(mockRequest)); + expect(() => + doubleCsrfProtection(mockRequest, mockResponse, next), + ).not.to.throw(); + }); +}); diff --git a/src/tests/utils/helpers.ts b/src/tests/utils/helpers.ts index 2659ee0..fadefa1 100644 --- a/src/tests/utils/helpers.ts +++ b/src/tests/utils/helpers.ts @@ -1,4 +1,5 @@ import type { Request, Response } from "express"; +import { HEADER_KEY } from "./constants"; const SECRET_1 = "secrets must be unique and must not"; const SECRET_2 = "be used elsewhere, nor be sentences"; @@ -75,14 +76,14 @@ export const attachResponseValuesToRequest = ({ request, response, bodyResponseToken, - cookieName, - headerKey, + cookieName = "__Host-psifi.x-csrf-token", + headerKey = HEADER_KEY, }: { request: Request; response: Response; bodyResponseToken: string; - cookieName: string; - headerKey: string; + cookieName?: string; + headerKey?: string; }) => { const { cookieValue } = getCookieValueFromResponse(response); diff --git a/src/tests/utils/mock.ts b/src/tests/utils/mock.ts index 09c6c2a..13e89d7 100644 --- a/src/tests/utils/mock.ts +++ b/src/tests/utils/mock.ts @@ -8,7 +8,7 @@ import { COOKIE_SECRET, HEADER_KEY } from "./constants.js"; import { getCookieFromRequest, getCookieValueFromResponse } from "./helpers.js"; // Create some request and response mocks -export const generateMocks = () => { +export const generateMocks = (sessionIdentifier?: string) => { const mockRequest = { headers: { cookie: "", @@ -18,6 +18,9 @@ export const generateMocks = () => { secret: COOKIE_SECRET, } as unknown as Request; + if (sessionIdentifier) { + (mockRequest as RequestWithSessionId).session = { id: sessionIdentifier }; + } // Internally mock the headers as a map. const mockResponseHeaders = new Map(); mockResponseHeaders.set("set-cookie", [] as string[]); @@ -41,9 +44,16 @@ export const generateMocks = () => { : value; const data: string = serializeCookie(name, parsesValue, options); const previous = mockResponse.getHeader("set-cookie") || []; - const header = Array.isArray(previous) - ? previous.concat(data) - : [previous, data]; + let header; + if (Array.isArray(previous)) { + header = previous + .filter((header) => !header.startsWith(name)) + .concat(data); + } else if (typeof previous === "string" && previous.startsWith(name)) { + header = [data]; + } else { + header = [previous, data]; + } mockResponse.setHeader("set-cookie", header as string[]); return mockResponse; @@ -56,6 +66,12 @@ export const generateMocks = () => { }; }; +export type RequestWithSessionId = Request & { + session: { + id?: string; + }; +}; + // Mock the next callback and allow for error throwing. // eslint-disable-next-line @typescript-eslint/no-explicit-any export const next = (err: any) => { @@ -69,6 +85,7 @@ export type GenerateMocksWithTokenOptions = { signed: boolean; generateToken: CsrfTokenCreator; validateRequest: CsrfRequestValidator; + sessionIdentifier?: string; }; // Generate the request and response mocks. @@ -78,8 +95,10 @@ export const generateMocksWithToken = ({ signed, generateToken, validateRequest, + sessionIdentifier, }: GenerateMocksWithTokenOptions) => { - const { mockRequest, mockResponse, mockResponseHeaders } = generateMocks(); + const { mockRequest, mockResponse, mockResponseHeaders } = + generateMocks(sessionIdentifier); const csrfToken = generateToken(mockRequest, mockResponse); const { setCookie, cookieValue } = getCookieValueFromResponse(mockResponse); @@ -102,7 +121,10 @@ export const generateMocksWithToken = ({ mockRequest.headers[HEADER_KEY] = csrfToken; // Once a token has been generated, the request should be setup as valid - assert.isTrue(validateRequest(mockRequest)); + assert.isTrue( + validateRequest(mockRequest), + "mockRequest should be valid after being setup with a token", + ); return { csrfToken, cookieValue, diff --git a/src/types.ts b/src/types.ts index 142d90c..abbe0f1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,10 +37,14 @@ export type RequestMethod = | "TRACE"; export type CsrfIgnoredMethods = Array; export type CsrfRequestValidator = (req: Request) => boolean; +export type CsrfTokenAndHashPairValidatorOptions = { + csrfToken: string; + csrfTokenHash: string; + possibleSecrets: Array; + sessionIdentifier: string; +}; export type CsrfTokenAndHashPairValidator = ( - token: string, - hash: string, - possibleSecrets: Array, + options: CsrfTokenAndHashPairValidatorOptions, ) => boolean; export type CsrfCookieSetter = ( res: Response, @@ -82,6 +86,22 @@ export interface DoubleCsrfConfig { */ getSecret: CsrfSecretRetriever; + /** + * A callback which takes in the request and returns the unique session identifier for that request. + * The session identifier will be used when hashing the csrf token, this means a CSRF token can only + * be used by the session for which it was generated. + * Can also return a JWT if you're using that as your session identifier. + * + * @param req The request object + * @returns The unique session identifier for the incoming request + * @default () => '' + * @example + * ```js + * const getSessionIdentifier = (req) => req.session.id; + * ``` + */ + getSessionIdentifier: (req: Request) => string; + /** * The name of the HTTPOnly cookie that will be set on the response. * @default "__Host-psifi.x-csrf-token" From 6af54bd1c3d9852781534a3be23889e52a5b85ac Mon Sep 17 00:00:00 2001 From: psibean Date: Sat, 21 Sep 2024 14:30:28 +0930 Subject: [PATCH 07/12] chore: bump supported security version --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ed5b5c1..64a880e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | -------- | ------------------ | -| >= 2.3.0 | :white_check_mark: | -| < 2.3.0 | :x: | +| >= 3.0.7 | :white_check_mark: | +| < 3.0.7 | :x: | ## Reporting a Vulnerability From 4737f662c07506ba2fb03a0916acdcd320cb3947 Mon Sep 17 00:00:00 2001 From: psibean Date: Sat, 21 Sep 2024 14:48:01 +0930 Subject: [PATCH 08/12] chore(release): 3.0.7 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5f462..2ef0b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.0.7](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.6...v3.0.7) (2024-09-21) + +* Marked >= 3.0.7 as security supported version + +### Features + +* support optional stateless association of token with session ([710d2f6](https://github.com/Psifi-Solutions/csrf-csrf/commit/710d2f6082f1ac8ab884b10913b1b86195f86bd2)) + +Added the `getSessionIdentifier` parameter to the `csrf-csrf` configuration. By providing the `getSessionIdentifier` callback, generated tokens will only be valid for the original session identifier they were generated for. + +For example: (req) => req.session.id + +The token will now be signed with the session id included, this means a generated CSRF token will only be valid for the session it was generated for. This also means that if you rotate your sessions (which you should) you will also need to generate a new CSRF token for the session after rotating it. + ### [3.0.6](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.5...v3.0.6) (2024-05-17) * No changes, just a bump to fix broken release diff --git a/package-lock.json b/package-lock.json index 4113fad..f22b270 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "csrf-csrf", - "version": "3.0.6", + "version": "3.0.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "csrf-csrf", - "version": "3.0.6", + "version": "3.0.7", "license": "ISC", "dependencies": { "http-errors": "^2.0.0" diff --git a/package.json b/package.json index 2ad3b9c..ad7fa60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "csrf-csrf", - "version": "3.0.6", + "version": "3.0.7", "description": "A utility package to help implement stateless CSRF protection using the Double Submit Cookie Pattern in express.", "type": "module", "main": "./lib/cjs/index.cjs", From c25fd2fbb22a804d3e74a979506e5b5f485b790a Mon Sep 17 00:00:00 2001 From: Psi Date: Sat, 6 Apr 2024 02:57:26 +1030 Subject: [PATCH 09/12] refactor: simplify generateToken parameters BREAKING CHANGE: Parameter update to generateToken. The third and fourth parameters for generateToken have been combined into an object. The third parameter is keyed by overwrite, the fourth parameter is keyed by validateOnReuse. Any calls to generateToken (also via req.csrfToken) will need to be updated accordingly: generateToken(req, res) > generateToken(req, res) // no change generateToken(req, res, true) > generateToken(req, res, { overwrite: true }); generateToken(req, res, true, false) > generateToken(req, res, { overwrite: true, validateOnReuse: false }) req.csrfToken(true) > req.csrfToken({ overwrite: true }); req.csrfToken(true, true) > req.csrfToken({ overwrite: true, validateOnReuse: true }); --- README.md | 22 +++++++++++++--------- example/complete/package.json | 4 ++-- example/complete/src/index.js | 2 +- example/simple/package.json | 4 ++-- example/simple/src/index.js | 2 +- src/index.ts | 17 ++++++++--------- src/tests/doublecsrf.test.ts | 16 ++++++---------- src/tests/testsuite.ts | 34 +++++++++++++++++++--------------- src/types.ts | 13 +++++++++---- 9 files changed, 61 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 455c02f..46ea90a 100644 --- a/README.md +++ b/README.md @@ -372,32 +372,36 @@ Used to customise the error response statusCode, the contained erro ( request: Request, response: Response, - overwrite?: boolean, // Set to true to force a new token to be generated - validateOnReuse?: boolean, // Set to false to generate a new token if token re-use is invalid + { + overwrite?: boolean, // Set to true to force a new token to be generated + validateOnReuse?: boolean, // Set to false to generate a new token if token re-use is invalid + } // optional ) => string; ```

By default if a csrf-csrf cookie already exists on an incoming request, generateToken will not overwrite it, it will simply return the existing token so long as the token is valid. If you wish to force a token generation, you can use the third parameter:

```ts -generateToken(req, res, true); // This will force a new token to be generated, and a new cookie to be set, even if one already exists +generateToken(req, res, { overwrite: true }); // This will force a new token to be generated, and a new cookie to be set, even if one already exists ```

If the 'overwrite' parameter is set to false (default), the existing token will be re-used and returned. However, the cookie value will also be validated. If the validation fails an error will be thrown. If you don't want an error to be thrown, you can set the 'validateOnReuse' (by default, true) to false. In this case instead of throwing an error, a new token will be generated and returned.

```ts -generateToken(req, res, true); // As overwrite is true, an error will never be thrown. -generateToken(req, res, false); // As validateOnReuse is true (default), an error will be thrown if the cookie is invalid. -generateToken(req, res, false, false); // As validateOnReuse is false, an error will never be thrown, even if the cookie is invalid. Instead, a new cookie will be generated if it is found to be invalid. +generateToken(req, res, { overwrite: true }); // As overwrite is true, an error will never be thrown. +generateToken(req, res, { overwrite: false }); // As validateOnReuse is true (default), an error will be thrown if the cookie is invalid. +generateToken(req, res, { overwrite: false, validateOnReuse: false }); // As validateOnReuse is false, if the cookie is invalid a new token will be generated without any error being thrown and despite overwrite being false ```

Instead of importing and using generateToken, you can also use req.csrfToken any time after the doubleCsrfProtection middleware has executed on your incoming request.

```ts -req.csrfToken(); // same as generateToken(req, res) and generateToken(req, res, false); -req.csrfToken(true); // same as generateToken(req, res, true); -req.csrfToken(false, false); // same as generateToken(req, res, false, false); +req.csrfToken(); // same as generateToken(req, res); +req.csrfToken({ overwrite: true }); // same as generateToken(req, res, { overwrite: true, validateOnReuse }); +req.csrfToken({ overwrite: false, validateOnReuse: false }); // same as generateToken(req, res, { overwrite: false, validateOnReuse: false }); +req.csrfToken(req, res, { overwrite: false }); +req.csrfToken(req, res, { overwrite: false, validateOnReuse: false }); ```

The generateToken function serves the purpose of establishing a CSRF (Cross-Site Request Forgery) protection mechanism by generating a token and an associated cookie. This function also provides the option to utilize a third parameter called overwrite, and a fourth parameter called validateOnReuse. By default, overwrite is set to false, and validateOnReuse is set to true.

diff --git a/example/complete/package.json b/example/complete/package.json index a4e0bb0..95f2492 100644 --- a/example/complete/package.json +++ b/example/complete/package.json @@ -12,7 +12,7 @@ "license": "ISC", "dependencies": { "cookie-parser": "^1.4.6", - "csrf-csrf": "latest", - "express": "^4.18.1" + "csrf-csrf": "file:../..", + "express": "^4.19.2" } } diff --git a/example/complete/src/index.js b/example/complete/src/index.js index dd9adae..1f47e12 100644 --- a/example/complete/src/index.js +++ b/example/complete/src/index.js @@ -24,7 +24,7 @@ const { invalidCsrfTokenError, generateToken, doubleCsrfProtection } = doubleCsrf({ getSecret: () => CSRF_SECRET, cookieName: CSRF_COOKIE_NAME, - cookieOptions: { sameSite: false, secure: false, signed: true }, // not ideal for production, development only + cookieOptions: { sameSite: false, secure: false }, // not ideal for production, development only }); app.use(cookieParser(COOKIES_SECRET)); diff --git a/example/simple/package.json b/example/simple/package.json index 4924ee9..44e9d04 100644 --- a/example/simple/package.json +++ b/example/simple/package.json @@ -11,8 +11,8 @@ "author": "psibean", "license": "ISC", "dependencies": { - "csrf-csrf": "latest", - "express": "^4.18.1", + "csrf-csrf": "../..", + "express": "^4.19.2", "cookie-parser": "^1.4.6" } } diff --git a/example/simple/src/index.js b/example/simple/src/index.js index 281bf07..072b731 100644 --- a/example/simple/src/index.js +++ b/example/simple/src/index.js @@ -11,7 +11,7 @@ const port = 5555; const { doubleCsrfProtection } = doubleCsrf({ getSecret: () => "this is a test", // NEVER DO THIS cookieName: "x-csrf-test", // Prefer "__Host-" prefixed names if possible - cookieOptions: { sameSite: false, secure: false, signed: true }, // not ideal for production, development only + cookieOptions: { sameSite: false, secure: false }, // not ideal for production, development only }); app.use(cookieParser("some super secret thing, please do not copy this")); diff --git a/src/index.ts b/src/index.ts index 5a71ec8..5b909de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import type { doubleCsrfProtection, DoubleCsrfUtilities, RequestMethod, + GenerateCsrfTokenConfig, + GenerateCsrfTokenOptions, } from "./types"; export * from "./types"; @@ -49,8 +51,7 @@ export function doubleCsrf({ const generateTokenAndHash = ( req: Request, - overwrite: boolean, - validateOnReuse: boolean, + { overwrite, validateOnReuse }: GenerateCsrfTokenConfig, ) => { const getSecretResult = getSecret(req); const possibleSecrets = Array.isArray(getSecretResult) @@ -100,14 +101,12 @@ export function doubleCsrf({ const generateToken: CsrfTokenCreator = ( req: Request, res: Response, - overwrite = false, - validateOnReuse = true, + { overwrite = false, validateOnReuse = true } = {}, ) => { - const { csrfToken, csrfTokenHash } = generateTokenAndHash( - req, + const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, { overwrite, validateOnReuse, - ); + }); const cookieContent = `${csrfToken}|${csrfTokenHash}`; res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true }); return csrfToken; @@ -166,8 +165,8 @@ export function doubleCsrf({ const doubleCsrfProtection: doubleCsrfProtection = (req, res, next) => { // TODO: next major update, breaking change, make a single object parameter - req.csrfToken = (overwrite?: boolean, validateOnReuse?: boolean) => - generateToken(req, res, overwrite, validateOnReuse); + req.csrfToken = (options: GenerateCsrfTokenOptions) => + generateToken(req, res, options); if (ignoredMethodsSet.has(req.method as RequestMethod)) { next(); } else if (validateRequest(req)) { diff --git a/src/tests/doublecsrf.test.ts b/src/tests/doublecsrf.test.ts index 763d1c3..a8c8efa 100644 --- a/src/tests/doublecsrf.test.ts +++ b/src/tests/doublecsrf.test.ts @@ -200,11 +200,9 @@ describe("csrf-csrf token-rotation", () => { const mockResponse = getEmptyResponse(); - const token = generateTokenWithSecret1And2( - mockRequest, - mockResponse, - true, - ); + const token = generateTokenWithSecret1And2(mockRequest, mockResponse, { + overwrite: true, + }); attachResponseValuesToRequest({ request: mockRequest, @@ -223,11 +221,9 @@ describe("csrf-csrf token-rotation", () => { const mockResponse = getEmptyResponse(); - const token = generateTokenWithSecret2And1( - mockRequest, - mockResponse, - true, - ); + const token = generateTokenWithSecret2And1(mockRequest, mockResponse, { + overwrite: true, + }); attachResponseValuesToRequest({ request: mockRequest, diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts index 0f179f9..d631592 100644 --- a/src/tests/testsuite.ts +++ b/src/tests/testsuite.ts @@ -119,7 +119,9 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { // reset the mock response to have no cookies (in reality this would just be a new instance of Response) mockResponse.setHeader("set-cookie", []); - const generatedToken = generateToken(mockRequest, mockResponse, true); + const generatedToken = generateToken(mockRequest, mockResponse, { + overwrite: true, + }); const newCookieValue = getCookieFromResponse(mockResponse); assert.notEqual(newCookieValue, oldCookieValue); @@ -139,7 +141,10 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { (decodedCookieValue as string).split("|")[0] + "|invalid-hash"); expect(() => - generateToken(mockRequest, mockResponse, false, true), + generateToken(mockRequest, mockResponse, { + overwrite: false, + validateOnReuse: true, + }), ).to.throw(invalidCsrfTokenError.message); // just an invalid value in the cookie @@ -151,7 +156,10 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { : (mockRequest.cookies[cookieName] = "invalid-value"); expect(() => - generateToken(mockRequest, mockResponse, false, true), + generateToken(mockRequest, mockResponse, { + overwrite: false, + validateOnReuse: true, + }), ).to.throw(invalidCsrfTokenError.message); }); @@ -178,12 +186,10 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { (decodedCookieValue as string).split("|")[0] + "|invalid-hash"); assert.doesNotThrow( () => - (generatedToken = generateToken( - mockRequest, - mockResponse, - false, - false, - )), + (generatedToken = generateToken(mockRequest, mockResponse, { + overwrite: false, + validateOnReuse: false, + })), ); newCookieValue = getCookieFromResponse(mockResponse); assert.notEqual(newCookieValue, oldCookieValue); @@ -199,12 +205,10 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { assert.doesNotThrow( () => - (generatedToken = generateToken( - mockRequest, - mockResponse, - false, - false, - )), + (generatedToken = generateToken(mockRequest, mockResponse, { + overwrite: false, + validateOnReuse: false, + })), ); newCookieValue = getCookieFromResponse(mockResponse); diff --git a/src/types.ts b/src/types.ts index abbe0f1..4370064 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,7 +12,9 @@ declare module "http" { declare module "express-serve-static-core" { export interface Request { - csrfToken?: (overwrite?: boolean) => ReturnType; + csrfToken?: ( + options?: GenerateCsrfTokenOptions, + ) => ReturnType; } } @@ -55,8 +57,7 @@ export type CsrfCookieSetter = ( export type CsrfTokenCreator = ( req: Request, res: Response, - ovewrite?: boolean, - validateOnReuse?: boolean, + options?: GenerateCsrfTokenOptions, ) => string; export type CsrfErrorConfig = { statusCode: number; @@ -64,7 +65,11 @@ export type CsrfErrorConfig = { code: string | undefined; }; export type CsrfErrorConfigOptions = Partial; - +export type GenerateCsrfTokenConfig = { + overwrite: boolean; + validateOnReuse: boolean; +}; +export type GenerateCsrfTokenOptions = Partial; export interface DoubleCsrfConfig { /** * A function that returns a secret or an array of secrets. From 456b3179eac02deeb90cd7112f7ddbd6377c9758 Mon Sep 17 00:00:00 2001 From: Psi Date: Sun, 7 Apr 2024 11:06:02 +0930 Subject: [PATCH 10/12] feat: expose per token cookie settings (#60) When calling `generateToken` the third options object parameter can now take a cookieOptions property to override any of the initial cookieOptions that were provided. This commit also removes the forced httpOnly true option. E.g. generateToken(req, res, { cookieOptions }) --- README.md | 3 ++- src/index.ts | 20 ++++++++++++++++---- src/types.ts | 7 ++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 46ea90a..f111dc5 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ string; Default: "__Host-psifi.x-csrf-token"

-

Optional: The name of the httpOnly cookie that will be used to track CSRF protection. If you change this it is recommend that you continue to use the __Host- or __Secure- security prefix.

+

Optional: The name of the cookie that will be used to track CSRF protection. If you change this it is recommend that you continue to use the __Host- or __Secure- security prefix.

Change for development

@@ -373,6 +373,7 @@ Used to customise the error response statusCode, the contained erro request: Request, response: Response, { + cookieOptions?: CookieOptions, // overrides cookieOptions previously configured just for this call overwrite?: boolean, // Set to true to force a new token to be generated validateOnReuse?: boolean, // Set to false to generate a new token if token re-use is invalid } // optional diff --git a/src/index.ts b/src/index.ts index 5b909de..9dc505b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export function doubleCsrf({ sameSite = "lax", path = "/", secure = true, + httpOnly = true, ...remainingCookieOptions } = {}, size = 64, @@ -38,10 +39,11 @@ export function doubleCsrf({ } = {}, }: DoubleCsrfConfigOptions): DoubleCsrfUtilities { const ignoredMethodsSet = new Set(ignoredMethods); - const cookieOptions = { + const defaultCookieOptions = { sameSite, path, secure, + httpOnly, ...remainingCookieOptions, }; @@ -51,7 +53,10 @@ export function doubleCsrf({ const generateTokenAndHash = ( req: Request, - { overwrite, validateOnReuse }: GenerateCsrfTokenConfig, + { + overwrite, + validateOnReuse, + }: Omit, ) => { const getSecretResult = getSecret(req); const possibleSecrets = Array.isArray(getSecretResult) @@ -101,14 +106,21 @@ export function doubleCsrf({ const generateToken: CsrfTokenCreator = ( req: Request, res: Response, - { overwrite = false, validateOnReuse = true } = {}, + { + cookieOptions = defaultCookieOptions, + overwrite = false, + validateOnReuse = true, + } = {}, ) => { const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, { overwrite, validateOnReuse, }); const cookieContent = `${csrfToken}|${csrfTokenHash}`; - res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true }); + res.cookie(cookieName, cookieContent, { + ...defaultCookieOptions, + ...cookieOptions, + }); return csrfToken; }; diff --git a/src/types.ts b/src/types.ts index 4370064..402bfce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ import type { HttpError } from "http-errors"; export type SameSiteType = boolean | "lax" | "strict" | "none"; export type TokenRetriever = (req: Request) => string | null | undefined; -export type DoubleCsrfCookieOptions = Omit; +export type CsrfTokenCookieOverrides = Omit; declare module "http" { interface IncomingHttpHeaders { "x-csrf-token"?: string | undefined; @@ -52,7 +52,7 @@ export type CsrfCookieSetter = ( res: Response, name: string, value: string, - options: DoubleCsrfCookieOptions, + options: CookieOptions, ) => void; export type CsrfTokenCreator = ( req: Request, @@ -68,6 +68,7 @@ export type CsrfErrorConfigOptions = Partial; export type GenerateCsrfTokenConfig = { overwrite: boolean; validateOnReuse: boolean; + cookieOptions: CsrfTokenCookieOverrides; }; export type GenerateCsrfTokenOptions = Partial; export interface DoubleCsrfConfig { @@ -123,7 +124,7 @@ export interface DoubleCsrfConfig { * The options for HTTPOnly cookie that will be set on the response. * @default { sameSite: "lax", path: "/", secure: true } */ - cookieOptions: DoubleCsrfCookieOptions; + cookieOptions: CookieOptions; /** * The methods that will be ignored by the middleware. From 1941deae25301343a3eddd8155888ecbf3137fa1 Mon Sep 17 00:00:00 2001 From: psibean Date: Wed, 15 May 2024 23:31:04 +0930 Subject: [PATCH 11/12] fix: ensure types are correctly exported --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 402bfce..e1c4667 100644 --- a/src/types.ts +++ b/src/types.ts @@ -202,3 +202,5 @@ export interface DoubleCsrfUtilities { */ doubleCsrfProtection: doubleCsrfProtection; } + +export {}; \ No newline at end of file From 2bfb4a6fd112e784a54e04d4ac74ca23e7aa70d6 Mon Sep 17 00:00:00 2001 From: psibean Date: Sat, 21 Sep 2024 15:07:08 +0930 Subject: [PATCH 12/12] chore: update getSessionIdentifier tests with 3.0.4..3.0.7 rebase --- src/tests/getSessionIdentifier.test.ts | 9 +++++++-- src/types.ts | 2 -- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/tests/getSessionIdentifier.test.ts b/src/tests/getSessionIdentifier.test.ts index 62fb58e..d6ddb4e 100644 --- a/src/tests/getSessionIdentifier.test.ts +++ b/src/tests/getSessionIdentifier.test.ts @@ -70,7 +70,10 @@ describe("csrf-csrf with getSessionIdentifier", () => { assert.isFalse(validateRequest(mockRequest)); expect(() => - generateToken(mockRequest, mockResponse, false, true), + generateToken(mockRequest, mockResponse, { + overwrite: false, + validateOnReuse: true, + }), ).to.throw(invalidCsrfTokenError); }); @@ -85,7 +88,9 @@ describe("csrf-csrf with getSessionIdentifier", () => { (mockRequest as RequestWithSessionId).session.id = "sdf9342dfa245r13tgvrf"; console.log("generating a new token"); - const newCsrfToken = generateToken(mockRequest, mockResponse, true); + const newCsrfToken = generateToken(mockRequest, mockResponse, { + overwrite: true, + }); console.log("new token generated"); assert.notEqual( newCsrfToken, diff --git a/src/types.ts b/src/types.ts index e1c4667..402bfce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -202,5 +202,3 @@ export interface DoubleCsrfUtilities { */ doubleCsrfProtection: doubleCsrfProtection; } - -export {}; \ No newline at end of file