Skip to content

Commit

Permalink
use createHmac to sign generated csrf tokens
Browse files Browse the repository at this point in the history
BREAKING CHANGE: This change replaces the createHash method with
createHmac for hashing tokens. It introduces the getSessionIdentifier
configuration option which by default will return req.session.id. The
purpose of this function is to return the id of the session associated
with the incoming request. The session id will be included in the hmac
signature, forcefully tying the generated csrf token with that session.
This means by default generated tokens can only be used by the session
which they were originally generated for.

If you have any kind of session rotation (to mitigate session hijacking),
which you should be doing during privilege escaltions and de-escalations
(e.g. sign in, sign out) then you will need to generate a new csrf token
at the same time.

Additionally this change exposes the delimiter and hmacAlgorithm options.
  • Loading branch information
psibean committed Apr 6, 2024
1 parent e07dcfe commit 76efeff
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 27 deletions.
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 28 additions & 18 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -18,6 +18,7 @@ import type {

export function doubleCsrf({
getSecret,
getSessionIdentifier = (req) => req.session.id,
cookieName = "__Host-psifi.x-csrf-token",
cookieOptions: {
sameSite = "lax",
Expand All @@ -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: {
Expand Down Expand Up @@ -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) {
Expand All @@ -81,8 +90,9 @@ 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 };
Expand All @@ -106,7 +116,7 @@ export function doubleCsrf({
overwrite,
validateOnReuse,
});
const cookieContent = `${csrfToken}|${csrfTokenHash}`;
const cookieContent = `${csrfToken}${delimiter}${csrfTokenHash}`;
res.cookie(cookieName, cookieContent, {
...defaultCookieOptions,
...cookieOptions,
Expand All @@ -120,17 +130,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("sha256", secret)
.update(`${getSessionIdentifier(req)}${incomingToken}`)
.digest("hex");
if (hash === expectedHash) return true;
if (incomingHash === expectedHash) return true;
}

return false;
Expand All @@ -141,7 +151,7 @@ export function doubleCsrf({
const csrfCookie = getCsrfCookieFromRequest(req);
if (typeof csrfCookie !== "string") return false;

// cookie has the form {token}|{hash}
// cookie has the form {token}{delimiter}{hash}
const [csrfToken, csrfTokenHash] = csrfCookie.split("|");

// csrf token from the request
Expand All @@ -154,11 +164,11 @@ export function doubleCsrf({

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

Expand Down
3 changes: 3 additions & 0 deletions src/tests/utils/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 22 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,16 @@ export type RequestMethod =
export type CsrfIgnoredMethods = Array<RequestMethod>;
export type CsrfRequestValidator = (req: Request) => boolean;
export type CsrfTokenAndHashPairValidator = (
token: string,
hash: string,
possibleSecrets: Array<string>,
req: Request,
{
incomingHash,
incomingToken,
possibleSecrets,
}: {
incomingHash: string;
incomingToken: string;
possibleSecrets: Array<string>;
},
) => boolean;
export type CsrfCookieSetter = (
res: Response,
Expand Down Expand Up @@ -88,24 +95,30 @@ export interface DoubleCsrfConfig {
*/
getSecret: CsrfSecretRetriever;

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 size in bytes of the generated token.
* @default 64
*/
size: number;

/**
* 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
*/
size: number;

hmacAlgorithm: string;
/**
* The methods that will be ignored by the middleware.
* @default ["GET", "HEAD", "OPTIONS"]
Expand Down

0 comments on commit 76efeff

Please sign in to comment.