diff --git a/README.md b/README.md
index 8919743..ee9e9c6 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) => req.session.id, // A function that returns the session identifier for the 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
+(request: Request) => string;
+```
+
+
+ Optional
+ Default:
+
+
+```
+(req) => req.session.id
+```
+
+This function should return the session identifier for the incoming request. This is used as part of the csrf token hash to ensure generated tokens can only be used by the sessions that originally requested them.
+
+If you are rotating your sessions, you will need to ensure a new CSRF token is generated at the same time. This should typically be done when a session has some sort of authorization elevation (e.g. signed in, signed out, sudo).
+
cookieName
```ts
@@ -268,6 +288,19 @@ string;
For development you will need to set secure
to false unless you're running HTTPS locally. Ensure secure is true in your live environment by using environment variables.
+delimiter
+
+```ts
+string;
+```
+
+
+ Optional
+ Default: "|"
+
+
+The delimiter is used when concatenating the plain CSRF token with the hash, constructing the value for the cookie. It is also used when splitting the cookie value. This is how a token can be reused when there is no state. Note that the plain token value within the cookie is only intended to be used for token re-use, it is not used as the source for token validation.
+
getTokenFromRequest
```ts
@@ -285,6 +318,19 @@ string;
This function should return the token sent by the frontend, the doubleCsrfProtection middleware will validate the value returned by this function against the value in the cookie.
+hmacAlgorithm
+
+```ts
+string;
+```
+
+
+ Optional
+ Default: "sha256"
+
+
+The algorithm passed to the createHmac
call when generating a token.
+
ignoredMethods
```ts
diff --git a/package-lock.json b/package-lock.json
index 56749b9..7462798 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@types/cookie-parser": "^1.4.6",
"@types/cookie-signature": "^1.1.2",
"@types/express": "^4.17.21",
+ "@types/express-session": "^1.18.0",
"@types/http-errors": "^2.0.4",
"@types/mocha": "^10.0.6",
"@types/node": "^18.15.8",
@@ -1154,6 +1155,15 @@
"@types/range-parser": "*"
}
},
+ "node_modules/@types/express-session": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz",
+ "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==",
+ "dev": true,
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
"node_modules/@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@@ -6453,6 +6463,15 @@
"@types/range-parser": "*"
}
},
+ "@types/express-session": {
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz",
+ "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==",
+ "dev": true,
+ "requires": {
+ "@types/express": "*"
+ }
+ },
"@types/http-errors": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
diff --git a/package.json b/package.json
index b4af756..a98c2c2 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"@types/cookie-parser": "^1.4.6",
"@types/cookie-signature": "^1.1.2",
"@types/express": "^4.17.21",
+ "@types/express-session": "^1.18.0",
"@types/http-errors": "^2.0.4",
"@types/mocha": "^10.0.6",
"@types/node": "^18.15.8",
diff --git a/src/index.ts b/src/index.ts
index 8450fd3..a790643 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import type { Request, Response } from "express";
-import { createHash, randomBytes } from "crypto";
+import { createHmac, randomBytes } from "crypto";
import createHttpError from "http-errors";
import type {
@@ -18,6 +18,7 @@ import type {
export function doubleCsrf({
getSecret,
+ getSessionIdentifier = (req) => req.session.id,
cookieName = "__Host-psifi.x-csrf-token",
cookieOptions: {
sameSite = "lax",
@@ -26,7 +27,9 @@ export function doubleCsrf({
httpOnly = true,
...remainingCookieOptions
} = {},
+ delimiter = "|",
size = 64,
+ hmacAlgorithm = "sha256",
ignoredMethods = ["GET", "HEAD", "OPTIONS"],
getTokenFromRequest = (req) => req.headers["x-csrf-token"],
errorConfig: {
@@ -67,8 +70,14 @@ export function doubleCsrf({
// 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("|");
- if (validateTokenAndHashPair(csrfToken, csrfTokenHash, possibleSecrets)) {
+ const [csrfToken, csrfTokenHash] = csrfCookie.split(delimiter);
+ if (
+ validateTokenAndHashPair(req, {
+ incomingToken: csrfToken,
+ incomingHash: csrfTokenHash,
+ possibleSecrets,
+ })
+ ) {
// If the pair is valid, reuse it
return { csrfToken, csrfTokenHash };
} else if (validateOnReuse) {
@@ -81,8 +90,8 @@ export function doubleCsrf({
const csrfToken = randomBytes(size).toString("hex");
// the 'newest' or preferred secret is the first one in the array
const secret = possibleSecrets[0];
- const csrfTokenHash = createHash("sha256")
- .update(`${csrfToken}${secret}`)
+ const csrfTokenHash = createHmac(hmacAlgorithm, secret)
+ .update(`${getSessionIdentifier(req)}${csrfToken}`)
.digest("hex");
return { csrfToken, csrfTokenHash };
@@ -106,7 +115,7 @@ export function doubleCsrf({
overwrite,
validateOnReuse,
});
- const cookieContent = `${csrfToken}|${csrfTokenHash}`;
+ const cookieContent = `${csrfToken}${delimiter}${csrfTokenHash}`;
res.cookie(cookieName, cookieContent, {
...defaultCookieOptions,
...cookieOptions,
@@ -120,17 +129,17 @@ export function doubleCsrf({
// 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,
- possibleSecrets,
+ req,
+ { incomingHash, incomingToken, possibleSecrets },
) => {
- if (typeof token !== "string" || typeof hash !== "string") return false;
+ if (typeof incomingToken !== "string" || typeof incomingHash !== "string")
+ return false;
for (const secret of possibleSecrets) {
- const expectedHash = createHash("sha256")
- .update(`${token}${secret}`)
+ const expectedHash = createHmac(hmacAlgorithm, secret)
+ .update(`${getSessionIdentifier(req)}${incomingToken}`)
.digest("hex");
- if (hash === expectedHash) return true;
+ if (incomingHash === expectedHash) return true;
}
return false;
@@ -141,8 +150,8 @@ export function doubleCsrf({
const csrfCookie = getCsrfCookieFromRequest(req);
if (typeof csrfCookie !== "string") return false;
- // cookie has the form {token}|{hash}
- const [csrfToken, csrfTokenHash] = csrfCookie.split("|");
+ // cookie has the form {token}{delimiter}{hash}
+ const [csrfTokenFromCookie, csrfTokenHash] = csrfCookie.split(delimiter);
// csrf token from the request
const csrfTokenFromRequest = getTokenFromRequest(req) as string;
@@ -153,12 +162,12 @@ export function doubleCsrf({
: [getSecretResult];
return (
- csrfToken === csrfTokenFromRequest &&
- validateTokenAndHashPair(
- csrfTokenFromRequest,
- csrfTokenHash,
+ csrfTokenFromCookie === csrfTokenFromRequest &&
+ validateTokenAndHashPair(req, {
+ incomingToken: csrfTokenFromRequest,
+ incomingHash: csrfTokenHash,
possibleSecrets,
- )
+ })
);
};
diff --git a/src/tests/doublecsrf.test.ts b/src/tests/doublecsrf.test.ts
index a8c8efa..bf15bba 100644
--- a/src/tests/doublecsrf.test.ts
+++ b/src/tests/doublecsrf.test.ts
@@ -27,6 +27,8 @@ createTestSuite("csrf-csrf signed with custom options, single secret", {
getSecret: getSingleSecret,
cookieOptions: { signed: true, sameSite: "strict" },
size: 128,
+ delimiter: "~",
+ hmacAlgorithm: "sha512",
cookieName: "__Host.test-the-thing.token",
});
@@ -36,6 +38,8 @@ createTestSuite("csrf-csrf unsigned, multiple secrets", {
createTestSuite("csrf-csrf signed, multiple secrets", {
cookieOptions: { signed: true },
getSecret: getMultipleSecrets,
+ delimiter: "~",
+ hmacAlgorithm: "sha512",
});
createTestSuite("csrf-csrf signed with custom options, multiple secrets", {
getSecret: getMultipleSecrets,
diff --git a/src/tests/utils/mock.ts b/src/tests/utils/mock.ts
index 09c6c2a..8476aa7 100644
--- a/src/tests/utils/mock.ts
+++ b/src/tests/utils/mock.ts
@@ -16,6 +16,9 @@ export const generateMocks = () => {
cookies: {},
signedCookies: {},
secret: COOKIE_SECRET,
+ session: {
+ id: "f5d7e7d1-a0dd-cf55-c0bb-5aa5aabe441f",
+ },
} as unknown as Request;
// Internally mock the headers as a map.
diff --git a/src/types.ts b/src/types.ts
index f232c91..cf657d9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -40,9 +40,16 @@ export type RequestMethod =
export type CsrfIgnoredMethods = Array;
export type CsrfRequestValidator = (req: Request) => boolean;
export type CsrfTokenAndHashPairValidator = (
- token: string,
- hash: string,
- possibleSecrets: Array,
+ req: Request,
+ {
+ incomingHash,
+ incomingToken,
+ possibleSecrets,
+ }: {
+ incomingHash: string;
+ incomingToken: string;
+ possibleSecrets: Array;
+ },
) => boolean;
export type CsrfCookieSetter = (
res: Response,
@@ -88,12 +95,30 @@ export interface DoubleCsrfConfig {
*/
getSecret: CsrfSecretRetriever;
+ /**
+ * A function that should return the session identifier for the request.
+ * @param req The request object
+ * @returns the session identifier for the request
+ * @default (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"
*/
cookieName: string;
+ /**
+ * The options for HTTPOnly cookie that will be set on the response.
+ * @default { sameSite: "lax", path: "/", secure: true }
+ */
+ cookieOptions: CookieOptions;
+
+ /**
+ * Used to separate the plain token and the token hash in the cookie value.
+ */
+ delimiter: string;
/**
* The size in bytes of the generated token.
* @default 64
@@ -101,11 +126,10 @@ export interface DoubleCsrfConfig {
size: number;
/**
- * The options for HTTPOnly cookie that will be set on the response.
- * @default { sameSite: "lax", path: "/", secure: true }
+ * The hmac algorithm to use when calling createHmac.
+ * @default "sha256"
*/
- cookieOptions: CookieOptions;
-
+ hmacAlgorithm: string;
/**
* The methods that will be ignored by the middleware.
* @default ["GET", "HEAD", "OPTIONS"]