Skip to content

Commit

Permalink
feat: support optional stateless association of token with session
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
psibean committed Aug 19, 2024
1 parent 48cfc6f commit 4c0ffed
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 25 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -213,6 +214,25 @@ const doubleCsrfUtilities = doubleCsrf({
<p>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.</p>
</p>

<h3>getSessionIdentifier</h3>

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

<p>
<b>Optional</b><br />
<b>Default:</b> <code>() => ""</code><br />
</p>

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

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

<p>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.</p>

<h3>cookieName</h3>

```ts
Expand Down
35 changes: 23 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from "./types";

export function doubleCsrf({
getSecret,
getSessionIdentifier = () => "",
cookieName = "__Host-psifi.x-csrf-token",
cookieOptions: {
sameSite = "lax",
Expand Down Expand Up @@ -70,7 +71,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) {
Expand All @@ -84,7 +92,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 };
Expand Down Expand Up @@ -121,18 +129,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;
Expand All @@ -156,11 +166,12 @@ export function doubleCsrf({

return (
csrfToken === csrfTokenFromRequest &&
validateTokenAndHashPair(
csrfTokenFromRequest,
validateTokenAndHashPair({
csrfToken: csrfTokenFromRequest,
csrfTokenHash,
possibleSecrets,
)
sessionIdentifier: getSessionIdentifier(req),
})
);
};

Expand Down
111 changes: 111 additions & 0 deletions src/tests/getSessionIdentifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* 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, {
overwrite: false,
validateOnReuse: 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, {
overwrite: 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();
});
});
9 changes: 5 additions & 4 deletions src/tests/utils/helpers.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

Expand Down
34 changes: 28 additions & 6 deletions src/tests/utils/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand All @@ -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<string, string | string[]>();
mockResponseHeaders.set("set-cookie", [] as string[]);
Expand All @@ -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;
Expand All @@ -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) => {
Expand All @@ -69,6 +85,7 @@ export type GenerateMocksWithTokenOptions = {
signed: boolean;
generateToken: CsrfTokenCreator;
validateRequest: CsrfRequestValidator;
sessionIdentifier?: string;
};

// Generate the request and response mocks.
Expand All @@ -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);
Expand All @@ -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,
Expand Down
26 changes: 23 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,14 @@ export type RequestMethod =
| "TRACE";
export type CsrfIgnoredMethods = Array<RequestMethod>;
export type CsrfRequestValidator = (req: Request) => boolean;
export type CsrfTokenAndHashPairValidatorOptions = {
csrfToken: string;
csrfTokenHash: string;
possibleSecrets: Array<string>;
sessionIdentifier: string;
};
export type CsrfTokenAndHashPairValidator = (
token: string,
hash: string,
possibleSecrets: Array<string>,
options: CsrfTokenAndHashPairValidatorOptions,
) => boolean;
export type CsrfCookieSetter = (
res: Response,
Expand Down Expand Up @@ -88,6 +92,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"
Expand Down

0 comments on commit 4c0ffed

Please sign in to comment.