From 51da818ef1dfb729b894457e4316e028df0b380f Mon Sep 17 00:00:00 2001
From: Psi
Date: Sun, 5 Nov 2023 15:59:26 +1030
Subject: [PATCH] feat: support multiple secrets (backwards compatible)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: allow returning multiple secrets in getSecret
* fix: picking a secret in generateTokenAndHash
* feat: add validateOnGeneration option to generateToken
* fix: typing in CsrfTokenCreator
* test: some basic tests for new functionality - wip
* test: revert tests to what they were before, but test both passing a single and multiple secrets
* test: add additional tests for secret rotation scenarios
* test: wip - improve tests for token rotation
* test: refactor tests to be tidier, and add test cases
* test: add validateOnGeneration tests
* refactor: move overwrite/validateOnGeneration defaults to generateToken
* test: change some wording
* fix: accept validateOnGeneration param in req.csrfToken
* docs: update README with latest information and fix minor details
* chore(dev): rename validateOnGeneration > validateOnReuse
Some comment and readme adjustments
* chore(dev): add CHANGELOG.md to .prettierignore
---------
Co-authored-by: David González Martínez
---
.prettierignore | 3 +-
CHANGELOG.md | 3 +-
README.md | 34 +++--
src/index.ts | 85 +++++++++----
src/tests/doublecsrf.test.ts | 237 ++++++++++++++++++++++++++++++++++-
src/tests/testsuite.ts | 68 +++++++++-
src/tests/utils/helpers.ts | 46 ++++++-
7 files changed, 422 insertions(+), 54 deletions(-)
diff --git a/.prettierignore b/.prettierignore
index f72ade2..69c06dc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,5 @@
node_modules
.*
lib/
-package-lock.json
\ No newline at end of file
+package-lock.json
+CHANGELOG.md
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03ab8ab..58fa2b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,10 +4,9 @@ All notable changes to this project will be documented in this file. See [standa
### [3.0.1](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.0...v3.0.1) (2023-09-15)
-
### Bug Fixes
-* types for TypeScript moduleResolution ([#32](https://github.com/Psifi-Solutions/csrf-csrf/issues/32)) ([6a5cd2c](https://github.com/Psifi-Solutions/csrf-csrf/commit/6a5cd2c43e4940577856cc08a565da79c4e1348b))
+- types for TypeScript moduleResolution ([#32](https://github.com/Psifi-Solutions/csrf-csrf/issues/32)) ([6a5cd2c](https://github.com/Psifi-Solutions/csrf-csrf/commit/6a5cd2c43e4940577856cc08a565da79c4e1348b))
## 3.0.0 (2023-08-18)
diff --git a/README.md b/README.md
index 91332e1..ca083eb 100644
--- a/README.md
+++ b/README.md
@@ -113,8 +113,8 @@ const {
```js
-const myRoute = (request, response) => {
- const csrfToken = generateToken(request, response);
+const myRoute = (req, res) => {
+ const csrfToken = generateToken(req, res);
// You could also pass the token into the context of a HTML response.
res.json({ csrfToken });
};
@@ -193,7 +193,7 @@ const doubleCsrfUtilities = doubleCsrf({
sameSite = "lax", // Recommend you make this strict if posible
path = "/",
secure = true,
- ...remainingCOokieOptions // See cookieOptions below
+ ...remainingCookieOptions // See cookieOptions below
},
size: 64, // The size of the generated tokens in bits
ignoredMethods: ["GET", "HEAD", "OPTIONS"], // A list of request methods that will not be protected.
@@ -204,12 +204,14 @@ const doubleCsrfUtilities = doubleCsrf({
getSecret
```ts
-(request: Request) => string;
+(request?: Request) => string | string[]
```
Required
-This should return a secret key to be used for hashing the CSRF tokens.
+This should return a secret key or an array of secret keys to be used for hashing the CSRF tokens.
+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.
+
cookieName
@@ -324,27 +326,43 @@ number;
generateToken
```ts
-(request: Request, response: Response, overwrite?: boolean) => string;
+(
+ 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
+) => 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. If you wish to force a token generation, you can use the third parameter:
+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
```
+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.
+```
+
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);
```
-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
. By default, this parameter is set to 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.
It returns a CSRF token and attaches a cookie to the response object. The cookie content is `${token}|${tokenHash}`
.
You should only transmit your token to the frontend as part of a response payload, do not include the token in response headers or in a cookie, and do not transmit the token hash by any other means.
When overwrite
is set to false, the function behaves in a way that preserves the existing CSRF cookie and its corresponding token and hash. In other words, if a valid CSRF cookie is already present in the incoming request, the function will reuse this cookie along with its associated token.
On the other hand, if overwrite
is set to true, the function will generate a new token and cookie each time it is invoked. This behavior can potentially lead to certain complications, particularly when multiple tabs are being used to interact with your web application. In such scenarios, the creation of new cookies with every call to the function can disrupt the proper functioning of your web app across different tabs, as the changes might not be synchronized effectively (you would need to write your own synchronization logic).
+If overwrite is set to false, the function will also validate the existing cookie information. If the information is found to be invalid (for instance, if the secret has been changed from the time the cookie was generated), 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. If it is false, instead of throwing an error, a new cookie will be generated.
invalidCsrfTokenError
diff --git a/src/index.ts b/src/index.ts
index 106cf90..4f41d14 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -20,7 +20,7 @@ declare module "express-serve-static-core" {
}
}
-export type CsrfSecretRetriever = (req?: Request) => string;
+export type CsrfSecretRetriever = (req?: Request) => string | Array;
export type DoubleCsrfConfigOptions = Partial & {
getSecret: CsrfSecretRetriever;
};
@@ -40,12 +40,12 @@ export type RequestMethod =
| "OPTIONS"
| "TRACE"
| "PATCH";
-export type CsrfIgnoredMethods = RequestMethod[];
+export type CsrfIgnoredMethods = Array;
export type CsrfRequestValidator = (req: Request) => boolean;
export type CsrfTokenAndHashPairValidator = (
token: string,
hash: string,
- secret: string
+ possibleSecrets: Array
) => boolean;
export type CsrfCookieSetter = (
res: Response,
@@ -56,7 +56,8 @@ export type CsrfCookieSetter = (
export type CsrfTokenCreator = (
req: Request,
res: Response,
- ovewrite?: boolean
+ ovewrite?: boolean,
+ validateOnReuse?: boolean
) => string;
export interface DoubleCsrfConfig {
@@ -100,24 +101,37 @@ export function doubleCsrf({
code: "EBADCSRFTOKEN",
});
- const generateTokenAndHash = (req: Request, overwrite = false) => {
+ const generateTokenAndHash = (
+ req: Request,
+ overwrite: boolean,
+ validateOnReuse: boolean
+ ) => {
+ const getSecretResult = getSecret(req);
+ const possibleSecrets = Array.isArray(getSecretResult)
+ ? getSecretResult
+ : [getSecretResult];
+
const csrfCookie = getCsrfCookieFromRequest(req);
- // if ovewrite is set, then even if there is already a csrf cookie, do not reuse it
- // if csrfCookie is present, it means that there is already a session, so we extract
- // the hash/token from it, validate it and reuse the token. This makes possible having
- // multiple tabs open at the same time
+ // If ovewrite is true, always generate a new token.
+ // If overwrite is false and there is no existing token, generate a new token.
+ // If overwrite is false and there is an existin token then validate the token and hash pair
+ // the existing cookie and reuse it if it is valid. If it isn't valid, then either throw or
+ // generate a new token based on validateOnReuse.
if (typeof csrfCookie === "string" && !overwrite) {
const [csrfToken, csrfTokenHash] = csrfCookie.split("|");
- const csrfSecret = getSecret(req);
- if (!validateTokenAndHashPair(csrfToken, csrfTokenHash, csrfSecret)) {
- // if the pair is not valid, then the cookie has been modified by a third party
+ if (validateTokenAndHashPair(csrfToken, csrfTokenHash, possibleSecrets)) {
+ // If the pair is valid, reuse it
+ return { csrfToken, csrfTokenHash };
+ } else if (validateOnReuse) {
+ // If the pair is invalid, but we want to validate on generation, throw an error
+ // only if the option is set
throw invalidCsrfTokenError;
}
- return { csrfToken, csrfTokenHash };
}
- // else, generate the token and hash from scratch
+ // otherwise, generate a completely new token
const csrfToken = randomBytes(size).toString("hex");
- const secret = getSecret(req);
+ // the 'newest' or preferred secret is the first one in the array
+ const secret = possibleSecrets[0];
const csrfTokenHash = createHash("sha256")
.update(`${csrfToken}${secret}`)
.digest("hex");
@@ -129,13 +143,18 @@ export function doubleCsrf({
// This should be used in routes or middleware to provide users with a token.
// The value returned from this should ONLY be sent to the client via a response payload.
// Do NOT send the csrfToken as a cookie, embed it in your HTML response, or as JSON.
-
+ // TODO: next major update, breaking change, combine extra params as a singl eobject parameter
const generateToken: CsrfTokenCreator = (
req: Request,
res: Response,
- overwrite?: boolean
+ overwrite = false,
+ validateOnReuse = true
) => {
- const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, overwrite);
+ const { csrfToken, csrfTokenHash } = generateTokenAndHash(
+ req,
+ overwrite,
+ validateOnReuse
+ );
const cookieContent = `${csrfToken}|${csrfTokenHash}`;
res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true });
return csrfToken;
@@ -145,19 +164,22 @@ export function doubleCsrf({
? (req: Request) => req.signedCookies[cookieName] as string
: (req: Request) => req.cookies[cookieName] as string;
- // validates if a token and its hash matches, given the secret that was originally included in the hash
+ // 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,
- secret
+ possibleSecrets
) => {
if (typeof token !== "string" || typeof hash !== "string") return false;
- const expectedHash = createHash("sha256")
- .update(`${token}${secret}`)
- .digest("hex");
+ for (const secret of possibleSecrets) {
+ const expectedHash = createHash("sha256")
+ .update(`${token}${secret}`)
+ .digest("hex");
+ if (hash === expectedHash) return true;
+ }
- return expectedHash === hash;
+ return false;
};
const validateRequest: CsrfRequestValidator = (req) => {
@@ -171,16 +193,25 @@ export function doubleCsrf({
// csrf token from the request
const csrfTokenFromRequest = getTokenFromRequest(req) as string;
- const csrfSecret = getSecret(req);
+ const getSecretResult = getSecret(req);
+ const possibleSecrets = Array.isArray(getSecretResult)
+ ? getSecretResult
+ : [getSecretResult];
return (
csrfToken === csrfTokenFromRequest &&
- validateTokenAndHashPair(csrfTokenFromRequest, csrfTokenHash, csrfSecret)
+ validateTokenAndHashPair(
+ csrfTokenFromRequest,
+ csrfTokenHash,
+ possibleSecrets
+ )
);
};
const doubleCsrfProtection: doubleCsrfProtection = (req, res, next) => {
- req.csrfToken = (overwrite?: boolean) => generateToken(req, res, overwrite);
+ // TODO: next major update, breaking change, make a single object parameter
+ req.csrfToken = (overwrite?: boolean, validateOnReuse?: boolean) =>
+ generateToken(req, res, overwrite, validateOnReuse);
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 86dc991..88d6e2d 100644
--- a/src/tests/doublecsrf.test.ts
+++ b/src/tests/doublecsrf.test.ts
@@ -1,14 +1,239 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { assert } from "chai";
+import { DoubleCsrfConfigOptions, doubleCsrf } from "../index.js";
import { createTestSuite } from "./testsuite.js";
-import { getSecret } from "./utils/helpers.js";
+import {
+ getSingleSecret,
+ getMultipleSecrets,
+ attachResponseValuesToRequest,
+} from "./utils/helpers.js";
+import { generateMocks, generateMocksWithToken } from "./utils/mock.js";
+import { HEADER_KEY } from "./utils/constants.js";
-createTestSuite("csrf-csrf unsigned", { getSecret });
-createTestSuite("csrf-csrf signed", {
- getSecret,
+createTestSuite("csrf-csrf unsigned, single secret", {
+ getSecret: getSingleSecret,
+});
+createTestSuite("csrf-csrf signed, single secret", {
cookieOptions: { signed: true },
+ getSecret: getSingleSecret,
});
-createTestSuite("csrf-csrf signed with custom options", {
- getSecret,
+createTestSuite("csrf-csrf signed with custom options, single secret", {
+ getSecret: getSingleSecret,
cookieOptions: { signed: true, sameSite: "strict" },
size: 128,
cookieName: "__Host.test-the-thing.token",
});
+
+createTestSuite("csrf-csrf unsigned, multiple secrets", {
+ getSecret: getMultipleSecrets,
+});
+createTestSuite("csrf-csrf signed, multiple secrets", {
+ cookieOptions: { signed: true },
+ getSecret: getMultipleSecrets,
+});
+createTestSuite("csrf-csrf signed with custom options, multiple secrets", {
+ getSecret: getMultipleSecrets,
+ cookieOptions: { signed: true, sameSite: "strict" },
+ size: 128,
+ cookieName: "__Host.test-the-thing.token",
+});
+
+describe("csrf-csrf token-rotation", () => {
+ // Initialise the package with the passed in test suite settings and a mock secret
+ const doubleCsrfOptions: Omit = {};
+
+ const {
+ cookieName = "__Host-psifi.x-csrf-token",
+ cookieOptions: { signed = false } = {},
+ } = doubleCsrfOptions;
+
+ const SECRET1 = "secret1";
+ const SECRET2 = "secret2";
+
+ const generateMocksWithMultipleSecrets = (secrets: string[] | string) => {
+ const { generateToken, validateRequest } = doubleCsrf({
+ ...doubleCsrfOptions,
+ getSecret: () => secrets,
+ });
+
+ return {
+ ...generateMocksWithToken({
+ cookieName,
+ signed,
+ generateToken,
+ validateRequest,
+ }),
+ validateRequest,
+ generateToken,
+ };
+ };
+
+ context("validating requests with combination of different secret/s", () => {
+ // Generate request --> CSRF token with secret1
+ // We will then match a request with token and secret1 with other combinations of secrets
+ const { mockRequest, validateRequest } =
+ generateMocksWithMultipleSecrets(SECRET1);
+ assert.isTrue(validateRequest(mockRequest));
+
+ it("should be valid with 1 matching secret", () => {
+ assert.isTrue(
+ generateMocksWithMultipleSecrets(SECRET1).validateRequest(mockRequest)
+ );
+ });
+
+ it("should be valid with 1/1 matching secret in array", () => {
+ assert.isTrue(
+ generateMocksWithMultipleSecrets([SECRET1]).validateRequest(mockRequest)
+ );
+ });
+
+ it("should be valid with 1/2 matching secrets in array, first secret matches", () => {
+ assert.isTrue(
+ generateMocksWithMultipleSecrets([SECRET1, SECRET2]).validateRequest(
+ mockRequest
+ )
+ );
+ });
+
+ it("should be valid with 1/2 matching secrets in array, second secret matches", () => {
+ assert.isTrue(
+ generateMocksWithMultipleSecrets([SECRET2, SECRET1]).validateRequest(
+ mockRequest
+ )
+ );
+ });
+
+ it("should be invalid with 0/1 matching secret in array", () => {
+ assert.isFalse(
+ generateMocksWithMultipleSecrets([SECRET2]).validateRequest(mockRequest)
+ );
+ });
+
+ it("should be invalid with 0/2 matching secrets in array", () => {
+ assert.isFalse(
+ generateMocksWithMultipleSecrets(SECRET2).validateRequest(mockRequest)
+ );
+ });
+
+ it("should be invalid with 0/3 matching secrets in array", () => {
+ assert.isFalse(
+ generateMocksWithMultipleSecrets([
+ "invalid0",
+ "invalid1",
+ "invalid2",
+ ]).validateRequest(mockRequest)
+ );
+ });
+ });
+
+ context(
+ "should generate tokens correctly, simulating token rotations",
+ () => {
+ const getEmptyResponse = () => {
+ const { mockResponse, mockRequest } = generateMocks();
+ return mockResponse;
+ };
+
+ const {
+ validateRequest: validateRequestWithSecret1,
+ generateToken: generateTokenWithSecret1,
+ } = generateMocksWithMultipleSecrets(SECRET1);
+
+ const {
+ validateRequest: validateRequestWithSecret2,
+ generateToken: generateTokenWithSecret2,
+ } = generateMocksWithMultipleSecrets(SECRET2);
+
+ const {
+ validateRequest: validateRequestWithSecret1And2,
+ generateToken: generateTokenWithSecret1And2,
+ } = generateMocksWithMultipleSecrets([SECRET1, SECRET2]);
+
+ const {
+ validateRequest: validateRequestWithSecret2And1,
+ generateToken: generateTokenWithSecret2And1,
+ } = generateMocksWithMultipleSecrets([SECRET2, SECRET1]);
+
+ it("should reuse existing token on request with SECRET1, while current is [SECRET1, SECRET2]", () => {
+ //
+ const { mockRequest } = generateMocksWithMultipleSecrets(SECRET1);
+ const mockResponse = getEmptyResponse();
+
+ const token = generateTokenWithSecret1And2(mockRequest, mockResponse);
+ attachResponseValuesToRequest({
+ request: mockRequest,
+ response: mockResponse,
+ headerKey: HEADER_KEY,
+ cookieName,
+ bodyResponseToken: token,
+ });
+
+ assert.isTrue(validateRequestWithSecret1(mockRequest));
+ assert.isFalse(validateRequestWithSecret2(mockRequest));
+ });
+
+ it("should reuse existing token on request with SECRET1, while current is [SECRET2, SECRET1]", () => {
+ const { mockRequest } = generateMocksWithMultipleSecrets(SECRET1);
+ const mockResponse = getEmptyResponse();
+
+ const token = generateTokenWithSecret2And1(mockRequest, mockResponse);
+ attachResponseValuesToRequest({
+ request: mockRequest,
+ response: mockResponse,
+ headerKey: HEADER_KEY,
+ cookieName,
+ bodyResponseToken: token,
+ });
+
+ assert.isTrue(validateRequestWithSecret1(mockRequest));
+ assert.isFalse(validateRequestWithSecret2(mockRequest));
+ });
+
+ it("should generate new token (with secret 1) on request with SECRET2, while current is [SECRET1, SECRET2], if overwrite is true", () => {
+ const { mockRequest } = generateMocksWithMultipleSecrets(SECRET2);
+
+ const mockResponse = getEmptyResponse();
+
+ const token = generateTokenWithSecret1And2(
+ mockRequest,
+ mockResponse,
+ true
+ );
+
+ attachResponseValuesToRequest({
+ request: mockRequest,
+ response: mockResponse,
+ headerKey: HEADER_KEY,
+ cookieName,
+ bodyResponseToken: token,
+ });
+
+ assert.isFalse(validateRequestWithSecret2(mockRequest));
+ assert.isTrue(validateRequestWithSecret1(mockRequest));
+ });
+
+ it("should generate new token (with secret 2) on request with SECRET2, while current is [SECRET2, SECRET1], if overwrite is true", () => {
+ const { mockRequest } = generateMocksWithMultipleSecrets(SECRET2);
+
+ const mockResponse = getEmptyResponse();
+
+ const token = generateTokenWithSecret2And1(
+ mockRequest,
+ mockResponse,
+ true
+ );
+
+ attachResponseValuesToRequest({
+ request: mockRequest,
+ response: mockResponse,
+ headerKey: HEADER_KEY,
+ cookieName,
+ bodyResponseToken: token,
+ });
+
+ assert.isTrue(validateRequestWithSecret2(mockRequest));
+ assert.isFalse(validateRequestWithSecret1(mockRequest));
+ });
+ }
+ );
+});
diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts
index 9580a02..bdb8410 100644
--- a/src/tests/testsuite.ts
+++ b/src/tests/testsuite.ts
@@ -14,6 +14,7 @@ import {
type CreateTestsuite = (
name: string,
+ // We will handle options for getSecret inside the test suite
doubleCsrfOptions: DoubleCsrfConfigOptions
) => void;
@@ -112,7 +113,7 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => {
assert.notEqual(generatedToken, csrfToken);
});
- it("should throw if csrf cookie is present, it is invalid (wrong token + hash pair, or not a correct value) and overwrite is false", () => {
+ it("should throw if csrf cookie is present and invalid, overwrite is false, and validateOnReuse is enabled", () => {
const { mockRequest, mockResponse, decodedCookieValue } =
generateMocksWithTokenIntenral();
// modify the cookie to make the token/hash pair invalid
@@ -124,9 +125,56 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => {
: (mockRequest.cookies[cookieName] =
(decodedCookieValue as string).split("|")[0] + "|invalid-hash");
- expect(() => generateToken(mockRequest, mockResponse)).to.throw(
- invalidCsrfTokenError.message
+ expect(() =>
+ generateToken(mockRequest, mockResponse, false, true)
+ ).to.throw(invalidCsrfTokenError.message);
+
+ // just an invalid value in the cookie
+ signed
+ ? (mockRequest.signedCookies[cookieName] = `s:${sign(
+ "invalid-value",
+ mockRequest.secret as string
+ )}`)
+ : (mockRequest.cookies[cookieName] = "invalid-value");
+
+ expect(() =>
+ generateToken(mockRequest, mockResponse, false, true)
+ ).to.throw(invalidCsrfTokenError.message);
+ });
+
+ it("should not throw if csrf cookie is present and invalid when overwrite is false, and validateOnReuse is disabled", () => {
+ const {
+ mockRequest,
+ mockResponse,
+ decodedCookieValue,
+ cookieValue: oldCookieValue,
+ csrfToken,
+ } = generateMocksWithTokenIntenral();
+
+ let generatedToken = "";
+ let newCookieValue = "";
+
+ mockResponse.setHeader("set-cookie", []);
+ // modify the cookie to make the token/hash pair invalid
+ signed
+ ? (mockRequest.signedCookies[cookieName] = `s:${sign(
+ (decodedCookieValue as string).split("|")[0] + "|invalid-hash",
+ mockRequest.secret as string
+ )}`)
+ : (mockRequest.cookies[cookieName] =
+ (decodedCookieValue as string).split("|")[0] + "|invalid-hash");
+ assert.doesNotThrow(
+ () =>
+ (generatedToken = generateToken(
+ mockRequest,
+ mockResponse,
+ false,
+ false
+ ))
);
+ newCookieValue = getCookieFromResponse(mockResponse);
+ assert.notEqual(newCookieValue, oldCookieValue);
+ assert.notEqual(generatedToken, csrfToken);
// just an invalid value in the cookie
signed
@@ -136,9 +184,19 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => {
)}`)
: (mockRequest.cookies[cookieName] = "invalid-value");
- expect(() => generateToken(mockRequest, mockResponse)).to.throw(
- invalidCsrfTokenError.message
+ assert.doesNotThrow(
+ () =>
+ (generatedToken = generateToken(
+ mockRequest,
+ mockResponse,
+ false,
+ false
+ ))
);
+
+ newCookieValue = getCookieFromResponse(mockResponse);
+ assert.notEqual(newCookieValue, oldCookieValue);
+ assert.notEqual(generatedToken, csrfToken);
});
});
diff --git a/src/tests/utils/helpers.ts b/src/tests/utils/helpers.ts
index 234294b..cfee576 100644
--- a/src/tests/utils/helpers.ts
+++ b/src/tests/utils/helpers.ts
@@ -1,14 +1,19 @@
import type { Request, Response } from "express";
+const SECRET_1 = "secrets must be unique and must not";
+const SECRET_2 = "be used elsewhere, nor be sentences";
+
+const MULTIPLE_SECRETS_1 = ["secret1", "secret2"];
+const MULTIPLE_SECRETS_2 = ["secret3", "secret4"];
+
// We do this to create a closure where we can externally switch the boolean value
-export const { getSecret, switchSecret } = (() => {
+export const { getSingleSecret, getMultipleSecrets, switchSecret } = (() => {
let secretSwitcher = false;
return {
- getSecret: () =>
- secretSwitcher
- ? "secrets must be unique and must not"
- : "be used elsewhere, nor be sentences",
+ getSingleSecret: () => (secretSwitcher ? SECRET_1 : SECRET_2),
+ getMultipleSecrets: () =>
+ secretSwitcher ? MULTIPLE_SECRETS_1 : MULTIPLE_SECRETS_2,
switchSecret: () => (secretSwitcher = !secretSwitcher),
};
})();
@@ -56,3 +61,34 @@ export const getCookieFromResponse = (res: Response) => {
return cookieValue;
};
+
+/**
+ * Given a request object, it will attach to it the CSRF header and cookie values from a given response object.
+ * @param mockRequest The mock request object
+ * @param mockResponse The mock response object
+ * @param bodyResponseToken The CSRF token from the response body
+ * @param cookieName The name of the CSRF cookie
+ * @param headerKey The name of the CSRF header
+ * @returns The request object with the CSRF header and cookie values attached
+ */
+export const attachResponseValuesToRequest = ({
+ request,
+ response,
+ bodyResponseToken,
+ cookieName,
+ headerKey,
+}: {
+ request: Request;
+ response: Response;
+ bodyResponseToken: string;
+ cookieName: string;
+ headerKey: string;
+}) => {
+ const { cookieValue } = getCookieValueFromResponse(response);
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ request.cookies[cookieName] = decodeURIComponent(cookieValue);
+ request.headers.cookie = `${cookieName}=${cookieValue};`;
+
+ request.headers[headerKey] = bodyResponseToken;
+};