Skip to content

Commit

Permalink
feat: 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 May 1, 2024
1 parent 5e5ba9b commit dad33c6
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 27 deletions.
46 changes: 46 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) => 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
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
(request: Request) => string;
```

<p>
<b>Optional</b><br />
<b>Default:</b>
</p>

```
(req) => req.session.id
```

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

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

<h3>cookieName</h3>

```ts
Expand Down Expand Up @@ -268,6 +288,19 @@ string;

<p>For development you will need to set <code>secure</code> to false unless you're running HTTPS locally. Ensure secure is true in your live environment by using environment variables.</b></p>

<h3>delimiter</h3>

```ts
string;
```

<p>
<b>Optional<br />
Default: <code>"|"</code></b>
</p>

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

<h3>getTokenFromRequest</h3>

```ts
Expand All @@ -285,6 +318,19 @@ string;

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

<h3>hmacAlgorithm</h3>

```ts
string;
```

<p>
<b>Optional<br />
Default: <code>"sha256"</code></b>
</p>

<p>The algorithm passed to the <code>createHmac</code> call when generating a token.</p>

<h3>ignoredMethods</h3>

```ts
Expand Down
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
49 changes: 29 additions & 20 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,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 };
Expand All @@ -106,7 +115,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 +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;
Expand All @@ -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;
Expand All @@ -153,12 +162,12 @@ export function doubleCsrf({
: [getSecretResult];

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

Expand Down
4 changes: 4 additions & 0 deletions src/tests/doublecsrf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});

Expand All @@ -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,
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
38 changes: 31 additions & 7 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,41 @@ 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
*/
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"]
Expand Down

0 comments on commit dad33c6

Please sign in to comment.