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"