From d249bfe640fa56bffe3e78798a29d5db341ff79c Mon Sep 17 00:00:00 2001 From: David Date: Mon, 14 Aug 2023 01:41:12 +0200 Subject: [PATCH 01/11] feat: reuse session csrf tokens when possible This allows multiple tabs to be open at the same time, without compromising security. Now, the cookie includes both a hash and the token, separated by "|". This allows the token generator to extract the token from an existing csrf session cookie and reuse it. --- README.md | 4 +-- src/index.ts | 57 +++++++++++++++++++++++++++++++++-------- src/tests/testsuite.ts | 1 - src/tests/utils/mock.ts | 3 ++- 4 files changed, 51 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e8b4bd5..554bd03 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ This section will guide you through using the default setup, which does sufficiently implement the Double Submit Cookie Pattern. If you'd like to customise the configuration, see the configuration section.

- You will need to be using cookie-parser and the middleware should be registered before Double CSRF. This utility will set a cookie containing a hash of the csrf token and provide the non-hashed csrf token so you can include it within your response. + You will need to be using cookie-parser and the middleware should be registered before Double CSRF. This utility will set a cookie containing both the csrf token and a hash of the csrf token and provide the non-hashed csrf token so you can include it within your response.

Requires TypeScript >= 3.8

@@ -98,7 +98,7 @@ const { doubleCsrf } = require("csrf-csrf"); ```js const { invalidCsrfTokenError, // This is just for convenience if you plan on making your own middleware. - generateToken, // Use this in your routes to provide a CSRF hash cookie and token. + generateToken, // Use this in your routes to provide a CSRF hash + token cookie and token. validateRequest, // Also a convenience if you plan on making your own middleware. doubleCsrfProtection, // This is the default CSRF protection middleware. } = doubleCsrf(doubleCsrfOptions); diff --git a/src/index.ts b/src/index.ts index 97eaa78..d92b569 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,11 @@ export type RequestMethod = | "PATCH"; export type CsrfIgnoredMethods = RequestMethod[]; export type CsrfRequestValidator = (req: Request) => boolean; +export type CsrfTokenAndHashPairValidator = ( + token: string, + hash: string, + secret: string +) => boolean; export type CsrfCookieSetter = ( res: Response, name: string, @@ -92,6 +97,20 @@ export function doubleCsrf({ }); const generateTokenAndHash = (req: Request) => { + const csrfCookie = getCsrfCookieFromRequest(req); + // 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 (typeof csrfCookie === "string") { + 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 + throw invalidCsrfTokenError; + } + return { csrfToken, csrfTokenHash }; + } + // else, generate the token and hash from scratch const csrfToken = randomBytes(size).toString("hex"); const secret = getSecret(req); const csrfTokenHash = createHash("sha256") @@ -107,30 +126,48 @@ export function doubleCsrf({ // Do NOT send the csrfToken as a cookie, embed it in your HTML response, or as JSON. const generateToken = (res: Response, req: Request) => { const { csrfToken, csrfTokenHash } = generateTokenAndHash(req); - res.cookie(cookieName, csrfTokenHash, { ...cookieOptions, httpOnly: true }); + const cookieContent = `${csrfToken}|${csrfTokenHash}`; + res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true }); return csrfToken; }; - const getTokenHashFromRequest = remainingCOokieOptions.signed + const getCsrfCookieFromRequest = remainingCOokieOptions.signed ? (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 + const validateTokenAndHashPair: CsrfTokenAndHashPairValidator = ( + token, + hash, + secret + ) => { + if (typeof token !== "string" || typeof hash !== "string") return false; + + const expectedHash = createHash("sha256") + .update(`${token}${secret}`) + .digest("hex"); + + return expectedHash === hash; + }; + const validateRequest: CsrfRequestValidator = (req) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const csrfTokenHash = getTokenHashFromRequest(req); - // This is the csrfTokenHash previously set on the response cookie via generateToken - if (typeof csrfTokenHash !== "string") return false; + const csrfCookie = getCsrfCookieFromRequest(req); + if (typeof csrfCookie != "string") return false; + + // cookie has the form {token}|{hash} + const [csrfToken, csrfTokenHash] = csrfCookie.split("|"); // csrf token from the request const csrfTokenFromRequest = getTokenFromRequest(req) as string; - // Hash the token with the provided secret and it should match the previous hash from the cookie - const expectedCsrfTokenHash = createHash("sha256") - .update(`${csrfTokenFromRequest}${getSecret(req)}`) - .digest("hex"); + const csrfSecret = getSecret(req); - return csrfTokenHash === expectedCsrfTokenHash; + return ( + csrfToken === csrfTokenFromRequest && + validateTokenAndHashPair(csrfTokenFromRequest, csrfTokenHash, csrfSecret) + ); }; const doubleCsrfProtection: doubleCsrfProtection = (req, res, next) => { diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts index 410b863..d7ae297 100644 --- a/src/tests/testsuite.ts +++ b/src/tests/testsuite.ts @@ -51,7 +51,6 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { it("should attach a hashed token to the request and return a token", () => { const { mockRequest, hashedToken, setCookie } = generateMocksWithTokenIntenral(); - const cookieHash = signed ? `s:${sign(hashedToken as string, mockRequest.secret as string)}` : hashedToken; diff --git a/src/tests/utils/mock.ts b/src/tests/utils/mock.ts index 30d7cab..4499603 100644 --- a/src/tests/utils/mock.ts +++ b/src/tests/utils/mock.ts @@ -89,7 +89,8 @@ export const generateMocksWithToken = ({ parse(mockRequest.headers.cookie)[cookieName], mockRequest.secret as string ) - : cookieValue; + : // signedCookie already decodes the value, but we need it if it's not signed. + decodeURIComponent(cookieValue); // Have to delete the cookies object otherwise cookieParser will skip it's parsing. delete mockRequest["cookies"]; cookieParserMiddleware(mockRequest, mockResponse, next); From 680c1611f3fa6894efb9358c17883ad0d73f9a17 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 14 Aug 2023 02:05:21 +0200 Subject: [PATCH 02/11] feat: add overwrite param to generateToken --- src/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index d92b569..215f9d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,12 +96,13 @@ export function doubleCsrf({ code: "EBADCSRFTOKEN", }); - const generateTokenAndHash = (req: Request) => { + const generateTokenAndHash = (req: Request, overwrite: boolean = true) => { 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 (typeof csrfCookie === "string") { + if (typeof csrfCookie === "string" && !overwrite) { const [csrfToken, csrfTokenHash] = csrfCookie.split("|"); const csrfSecret = getSecret(req); if (!validateTokenAndHashPair(csrfToken, csrfTokenHash, csrfSecret)) { @@ -124,8 +125,9 @@ 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. - const generateToken = (res: Response, req: Request) => { - const { csrfToken, csrfTokenHash } = generateTokenAndHash(req); + + const generateToken = (res: Response, req: Request, overwrite?: boolean) => { + const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, overwrite); const cookieContent = `${csrfToken}|${csrfTokenHash}`; res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true }); return csrfToken; @@ -171,7 +173,7 @@ export function doubleCsrf({ }; const doubleCsrfProtection: doubleCsrfProtection = (req, res, next) => { - req.csrfToken = () => generateToken(res, req); + req.csrfToken = (overwrite?: boolean) => generateToken(res, req, overwrite); if (ignoredMethodsSet.has(req.method as RequestMethod)) { next(); } else if (validateRequest(req)) { From 85f3337f984c003f6b62dd3d809ae527c45e2850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Tue, 15 Aug 2023 13:42:27 +0200 Subject: [PATCH 03/11] fix: misuse of regular inequality --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 215f9d9..50dd124 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,9 +154,8 @@ export function doubleCsrf({ const validateRequest: CsrfRequestValidator = (req) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const csrfCookie = getCsrfCookieFromRequest(req); - if (typeof csrfCookie != "string") return false; + if (typeof csrfCookie !== "string") return false; // cookie has the form {token}|{hash} const [csrfToken, csrfTokenHash] = csrfCookie.split("|"); From 9b0f021a55bf1ec234367e46b3bdfd10b8b8dcf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Tue, 15 Aug 2023 15:47:13 +0200 Subject: [PATCH 04/11] test: update wording to match the new functionality --- src/tests/testsuite.ts | 20 ++++++++++++-------- src/tests/utils/helpers.ts | 2 +- src/tests/utils/mock.ts | 9 ++++----- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts index d7ae297..e5764ce 100644 --- a/src/tests/testsuite.ts +++ b/src/tests/testsuite.ts @@ -48,16 +48,19 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { }); describe("generateToken", () => { - it("should attach a hashed token to the request and return a token", () => { - const { mockRequest, hashedToken, setCookie } = + it("should attach both a token and its hash to the response and return a token", () => { + const { mockRequest, encodedCookieValue, setCookie } = generateMocksWithTokenIntenral(); - const cookieHash = signed - ? `s:${sign(hashedToken as string, mockRequest.secret as string)}` - : hashedToken; + const cookieValue = signed + ? `s:${sign( + encodedCookieValue as string, + mockRequest.secret as string + )}` + : encodedCookieValue; const expectedSetCookieValue = serializeCookie( cookieName, - cookieHash as string, + cookieValue as string, { path, httpOnly: true, @@ -76,10 +79,11 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { }); it("should return false when a token is generated but not received in request", () => { - const { mockRequest, hashedToken } = generateMocksWithTokenIntenral(); + const { mockRequest, encodedCookieValue } = + generateMocksWithTokenIntenral(); assert.equal( getCookieFromRequest(cookieName, signed, mockRequest), - hashedToken + encodedCookieValue ); // Wipe token diff --git a/src/tests/utils/helpers.ts b/src/tests/utils/helpers.ts index 1aab3f2..5de4420 100644 --- a/src/tests/utils/helpers.ts +++ b/src/tests/utils/helpers.ts @@ -16,7 +16,7 @@ export const { getSecret, switchSecret } = (() => { /** * Parses the response 'Set-Cookie' header. * @param res The response object - * @returns The set-cookie header string and the csrf token hash value + * @returns The set-cookie header string and the cookie value containing both the csrf token and its hash */ export const getCookieValueFromResponse = (res: Response) => { const setCookie = res.getHeader("set-cookie") as string | string[]; diff --git a/src/tests/utils/mock.ts b/src/tests/utils/mock.ts index 4499603..b8fed7d 100644 --- a/src/tests/utils/mock.ts +++ b/src/tests/utils/mock.ts @@ -84,7 +84,7 @@ export const generateMocksWithToken = ({ const csrfToken = generateToken(mockResponse, mockRequest); const { setCookie, cookieValue } = getCookieValueFromResponse(mockResponse); mockRequest.headers.cookie = `${cookieName}=${cookieValue};`; - const hashedToken = signed + const encodedCookieValue = signed ? signedCookie( parse(mockRequest.headers.cookie)[cookieName], mockRequest.secret as string @@ -96,18 +96,17 @@ export const generateMocksWithToken = ({ cookieParserMiddleware(mockRequest, mockResponse, next); assert.equal( getCookieFromRequest(cookieName, signed, mockRequest), - hashedToken + encodedCookieValue ); mockRequest.headers[HEADER_KEY] = csrfToken; - // Once a token has ben generated, the request should be setup as valid + // Once a token has been generated, the request should be setup as valid assert.isTrue(validateRequest(mockRequest)); - return { csrfToken, cookieValue, - hashedToken, + encodedCookieValue, mockRequest, mockResponse, mockResponseHeaders, From 0eb8164d7798641cfdf2f53e82c5e036bb005662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Tue, 15 Aug 2023 15:57:46 +0200 Subject: [PATCH 05/11] fix: attach CsrfTokenCreator type to generateToken --- src/index.ts | 12 ++++++++++-- src/tests/testsuite.ts | 9 +++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 50dd124..ff5f5a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,11 @@ export type CsrfCookieSetter = ( value: string, options: DoubleCsrfCookieOptions ) => void; -export type CsrfTokenCreator = (res: Response, req: Request) => string; +export type CsrfTokenCreator = ( + res: Response, + req: Request, + ovewrite?: boolean +) => string; export interface DoubleCsrfConfig { getSecret: CsrfSecretRetriever; @@ -126,7 +130,11 @@ export function doubleCsrf({ // 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. - const generateToken = (res: Response, req: Request, overwrite?: boolean) => { + const generateToken: CsrfTokenCreator = ( + res: Response, + req: Request, + overwrite?: boolean + ) => { const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, overwrite); const cookieContent = `${csrfToken}|${csrfTokenHash}`; res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true }); diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts index e5764ce..66fd3ca 100644 --- a/src/tests/testsuite.ts +++ b/src/tests/testsuite.ts @@ -70,6 +70,15 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { ); assert.equal(setCookie, expectedSetCookieValue); }); + + it("should reuse a csrf token if a csrf cookie is already present", () => { + const { mockRequest, mockResponse } = generateMocks(); + mockRequest.cookies = { + [cookieName]: "test", + }; + const token = generateToken(mockResponse, mockRequest, false); + assert.equal(token, "test"); + }); }); describe("validateRequest", () => { From 621a6702f7f877864914e6800da60afacbcf05ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Tue, 15 Aug 2023 18:18:34 +0200 Subject: [PATCH 06/11] test: add test for generateToken and fix wording --- src/tests/testsuite.ts | 87 ++++++++++++++++++++++++++++++++------ src/tests/utils/helpers.ts | 14 ++++++ src/tests/utils/mock.ts | 6 +-- 3 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts index 66fd3ca..45a0ab0 100644 --- a/src/tests/testsuite.ts +++ b/src/tests/testsuite.ts @@ -6,7 +6,11 @@ import { serialize as serializeCookie } from "cookie"; import { sign } from "cookie-signature"; import { generateMocks, generateMocksWithToken, next } from "./utils/mock.js"; import { HEADER_KEY, TEST_TOKEN } from "./utils/constants.js"; -import { getCookieFromRequest, switchSecret } from "./utils/helpers.js"; +import { + getCookieFromRequest, + getCookieFromResponse, + switchSecret, +} from "./utils/helpers.js"; type CreateTestsuite = ( name: string, @@ -49,14 +53,14 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { describe("generateToken", () => { it("should attach both a token and its hash to the response and return a token", () => { - const { mockRequest, encodedCookieValue, setCookie } = + const { mockRequest, decodedCookieValue, setCookie } = generateMocksWithTokenIntenral(); const cookieValue = signed ? `s:${sign( - encodedCookieValue as string, + decodedCookieValue as string, mockRequest.secret as string )}` - : encodedCookieValue; + : decodedCookieValue; const expectedSetCookieValue = serializeCookie( cookieName, @@ -71,13 +75,70 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { assert.equal(setCookie, expectedSetCookieValue); }); - it("should reuse a csrf token if a csrf cookie is already present", () => { - const { mockRequest, mockResponse } = generateMocks(); - mockRequest.cookies = { - [cookieName]: "test", - }; - const token = generateToken(mockResponse, mockRequest, false); - assert.equal(token, "test"); + it("should reuse a csrf token if a csrf cookie is already present, and overwrite is set to false", () => { + const { + mockRequest, + mockResponse, + csrfToken, + cookieValue: oldCookieValue, + } = generateMocksWithTokenIntenral(); + + // 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(mockResponse, mockRequest, false); + const newCookieValue = getCookieFromResponse(mockResponse); + + assert.equal(generatedToken, csrfToken); + assert.equal(newCookieValue, oldCookieValue); + }); + + it("should generate a new token even if a csrf cookie is already present, if overwrite is set to true", () => { + const { + mockRequest, + mockResponse, + csrfToken, + cookieValue: oldCookieValue, + } = generateMocksWithTokenIntenral(); + + // reset the mock response to have no cookies (in reality this would just be a new instance of Response) + mockResponse.setHeader("set-cookie", []); + + // overwrite is true by default + const generatedToken = generateToken(mockResponse, mockRequest); + const newCookieValue = getCookieFromResponse(mockResponse); + + assert.notEqual(newCookieValue, oldCookieValue); + 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", () => { + const { mockRequest, mockResponse, decodedCookieValue } = + generateMocksWithTokenIntenral(); + // 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"); + + expect(() => generateToken(mockResponse, mockRequest, false)).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(mockResponse, mockRequest, false)).to.throw( + invalidCsrfTokenError.message + ); }); }); @@ -88,11 +149,11 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { }); it("should return false when a token is generated but not received in request", () => { - const { mockRequest, encodedCookieValue } = + const { mockRequest, decodedCookieValue } = generateMocksWithTokenIntenral(); assert.equal( getCookieFromRequest(cookieName, signed, mockRequest), - encodedCookieValue + decodedCookieValue ); // Wipe token diff --git a/src/tests/utils/helpers.ts b/src/tests/utils/helpers.ts index 5de4420..234294b 100644 --- a/src/tests/utils/helpers.ts +++ b/src/tests/utils/helpers.ts @@ -42,3 +42,17 @@ export const getCookieFromRequest = ( ) => // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access signed ? req.signedCookies[cookieName] : req.cookies[cookieName]; + +// as of now, we only have one cookie, so we can just return the first one +export const getCookieFromResponse = (res: Response) => { + const setCookie = res.getHeader("set-cookie") as string | string[]; + const setCookieString: string = Array.isArray(setCookie) + ? setCookie[0] + : setCookie; + const cookieValue = setCookieString.substring( + setCookieString.indexOf("=") + 1, + setCookieString.indexOf(";") + ); + + return cookieValue; +}; diff --git a/src/tests/utils/mock.ts b/src/tests/utils/mock.ts index b8fed7d..1d44775 100644 --- a/src/tests/utils/mock.ts +++ b/src/tests/utils/mock.ts @@ -84,7 +84,7 @@ export const generateMocksWithToken = ({ const csrfToken = generateToken(mockResponse, mockRequest); const { setCookie, cookieValue } = getCookieValueFromResponse(mockResponse); mockRequest.headers.cookie = `${cookieName}=${cookieValue};`; - const encodedCookieValue = signed + const decodedCookieValue = signed ? signedCookie( parse(mockRequest.headers.cookie)[cookieName], mockRequest.secret as string @@ -96,7 +96,7 @@ export const generateMocksWithToken = ({ cookieParserMiddleware(mockRequest, mockResponse, next); assert.equal( getCookieFromRequest(cookieName, signed, mockRequest), - encodedCookieValue + decodedCookieValue ); mockRequest.headers[HEADER_KEY] = csrfToken; @@ -106,7 +106,7 @@ export const generateMocksWithToken = ({ return { csrfToken, cookieValue, - encodedCookieValue, + decodedCookieValue, mockRequest, mockResponse, mockResponseHeaders, From a2c73043508d2f005c1d74e371a7b5238c8d9171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Tue, 15 Aug 2023 18:21:14 +0200 Subject: [PATCH 07/11] style: fix eslint type inference error --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index ff5f5a6..0501287 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,7 @@ export function doubleCsrf({ code: "EBADCSRFTOKEN", }); - const generateTokenAndHash = (req: Request, overwrite: boolean = true) => { + const generateTokenAndHash = (req: Request, overwrite = true) => { 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 From a137b74273368922e6319d6f50353dbc396fe716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Tue, 15 Aug 2023 18:48:31 +0200 Subject: [PATCH 08/11] docs: update README to reflect new functionality --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 554bd03..f5215db 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ const { This will extract the default utilities, you can configure these and re-export them from your own module. 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.

- To create a route which generates a CSRF token and hash cookie: + To create a route which generates a CSRF token and a cookie containing ´${token|tokenHash}´:

```js @@ -179,6 +179,18 @@ const doubleCsrfUtilities = doubleCsrf({

If you plan on using express-session then please ensure your cookie-parser middleware is registered after express-session, as express session parses it's own cookies and may cionflict.

+

generateToken

+ +

The generateToken function serves the purpose of establishing a CSRF (Cross-Site Request Forgery) protection mechanism by generating a token and associated cookie. This function also provides the option to utilize a third parameter called overwrite. By default, this parameter 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 synchronization logic in your frontend).

+ +```ts +(response: Response, request: Request, overwrite?: boolean) => string; +``` +

getSecret

```ts From dfa1c42e94192af5eeb339fee8eda34c4fe58f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Wed, 16 Aug 2023 15:24:31 +0200 Subject: [PATCH 09/11] docs: update docs to match new changes/better readability --- README.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f5215db..a52d91d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Do follow fastify/csrf-protection recommendations for secret security.
  • - Do keep secure and signed as true in production. + Do keep secure as true in production.
  • Do make sure you do not compromise your security by not following best practices. @@ -79,9 +79,9 @@ This section will guide you through using the default setup, which does sufficiently implement the Double Submit Cookie Pattern. If you'd like to customise the configuration, see the configuration section.

    - You will need to be using cookie-parser and the middleware should be registered before Double CSRF. This utility will set a cookie containing both the csrf token and a hash of the csrf token and provide the non-hashed csrf token so you can include it within your response. + You will need to be using cookie-parser and the middleware should be registered before Double CSRF. In case you want to use signed CSRF cookies, you will need to provide cookie-parser with a unique secret for cookie signing. This utility will set a cookie containing both the csrf token and a hash of the csrf token and provide the non-hashed csrf token so you can include it within your response.

    -

    Requires TypeScript >= 3.8

    +

    If you're using TypeScript, requires TypeScript >= 3.8

    ``` npm install cookie-parser csrf-csrf @@ -181,16 +181,29 @@ const doubleCsrfUtilities = doubleCsrf({

    generateToken

    -

    The generateToken function serves the purpose of establishing a CSRF (Cross-Site Request Forgery) protection mechanism by generating a token and associated cookie. This function also provides the option to utilize a third parameter called overwrite. By default, this parameter 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 synchronization logic in your frontend).

    - ```ts (response: Response, request: Request, overwrite?: boolean) => 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:

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

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

    + +``` +req.csrfToken(); // same as generateToken(res, req) and generateToken(res, req, false); +req.csrfToken(true); // same as generateToken(res, req, true); +``` + +

    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.

    +

    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).

    +

    getSecret

    ```ts From 15890aa60d79a7d216c05e30e8251aad070b8685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Wed, 16 Aug 2023 15:30:18 +0200 Subject: [PATCH 10/11] feat: make generateToken overwrite param default to false --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 0501287..8c4f02c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,7 @@ export function doubleCsrf({ code: "EBADCSRFTOKEN", }); - const generateTokenAndHash = (req: Request, overwrite = true) => { + const generateTokenAndHash = (req: Request, overwrite = false) => { 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 From 0213459a806434955ea5a9435fc8a6b44710aac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Gonz=C3=A1lez=20Mart=C3=ADnez?= Date: Wed, 16 Aug 2023 15:32:36 +0200 Subject: [PATCH 11/11] test: adopt that overwrite is false by default --- src/tests/testsuite.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tests/testsuite.ts b/src/tests/testsuite.ts index 45a0ab0..608e2ab 100644 --- a/src/tests/testsuite.ts +++ b/src/tests/testsuite.ts @@ -86,7 +86,8 @@ 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(mockResponse, mockRequest, false); + // overwrite is false by default + const generatedToken = generateToken(mockResponse, mockRequest); const newCookieValue = getCookieFromResponse(mockResponse); assert.equal(generatedToken, csrfToken); @@ -104,8 +105,7 @@ 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", []); - // overwrite is true by default - const generatedToken = generateToken(mockResponse, mockRequest); + const generatedToken = generateToken(mockResponse, mockRequest, true); const newCookieValue = getCookieFromResponse(mockResponse); assert.notEqual(newCookieValue, oldCookieValue); @@ -124,7 +124,7 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { : (mockRequest.cookies[cookieName] = (decodedCookieValue as string).split("|")[0] + "|invalid-hash"); - expect(() => generateToken(mockResponse, mockRequest, false)).to.throw( + expect(() => generateToken(mockResponse, mockRequest)).to.throw( invalidCsrfTokenError.message ); @@ -136,7 +136,7 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => { )}`) : (mockRequest.cookies[cookieName] = "invalid-value"); - expect(() => generateToken(mockResponse, mockRequest, false)).to.throw( + expect(() => generateToken(mockResponse, mockRequest)).to.throw( invalidCsrfTokenError.message ); });