Skip to content

Commit 0e2467a

Browse files
committed
fix: validateOnReuse incorrectly throws
Logic for validateOnReuse parameter is still incorrect. If validateOnReuse is false, overwrite is true, but the token from the cookie is invalid, this should silently fail and return a new valid token instead.
1 parent 813df9c commit 0e2467a

File tree

4 files changed

+75
-20
lines changed

4 files changed

+75
-20
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,8 @@ Used to customise the error response <code>statusCode</code>, the contained erro
410410

411411
<p>By default if a <code>csrf-csrf</code> cookie already exists on an incoming request, <code>generateCsrfToken</code> will not overwrite it, it will return the existing token so long as the token is valid. If you wish to force a token generation, you can use the <code>overwrite</code> option of the third parameter:</p>
412412

413+
<p>The <code>validateOnReuse</code> parameter is a bit misleading, and is also deprecated (will be removed with the next major release). A better name for it would be <code>throwOnReuseInvalid</code>.</p>
414+
413415
```ts
414416
generateCsrfToken(req, res, { overwrite: true }); // This will force a new token to be generated, and a new cookie to be set, even if one already exists
415417
```

src/index.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,19 @@ export function doubleCsrf({
7171
const possibleSecrets = getPossibleSecrets(req);
7272
// If cookie is not present, this is a new user (no existing csrfToken)
7373
// If ovewrite is true, always generate a new token.
74-
// If overwrite is false and validateOnReuse is true and there is an existing token, validate it first
75-
// If overwrite is false and validateOnReuse is false, just return the cookie value
74+
// If the existing token is valid, return it
75+
// If the existing token is not valid and validateOnReuse is true, throw an error
76+
// If the existing token is not valid and validateOnReuse is false, silently ignore, return new valid token
7677
if (cookieName in req.cookies && !overwrite) {
77-
if (!validateOnReuse || (validateOnReuse && validateCsrfToken(req, possibleSecrets))) {
78-
// If validateOnReuse is false, or if the token is valid, reuse it
78+
// If the current token is valid, reuse it
79+
if (validateCsrfTokenCookie(req, possibleSecrets)) {
7980
return getCsrfTokenFromCookie(req);
8081
}
81-
// This only happens if overwrite is false and validateOnReuse is true
82-
throw invalidCsrfTokenError;
82+
83+
// If the current token is invalid and validateOnReuse is true, throw here
84+
if (validateOnReuse) {
85+
throw invalidCsrfTokenError;
86+
}
8387
}
8488
// otherwise, generate a completely new token
8589
// the 'newest' or preferred secret is the first one in the array
@@ -112,7 +116,38 @@ export function doubleCsrf({
112116
return csrfToken;
113117
};
114118

115-
const getCsrfTokenFromCookie = (req: Request) => req.cookies[cookieName] as string;
119+
const getCsrfTokenFromCookie = (req: Request) => req.cookies[cookieName] ?? "";
120+
121+
const validateHmac = ({
122+
expectedHmac,
123+
req,
124+
randomValue,
125+
possibleSecrets,
126+
}: { expectedHmac: string; possibleSecrets: Array<string>; randomValue: string; req: Request }) => {
127+
const message = constructMessage(req, randomValue);
128+
for (const secret of possibleSecrets) {
129+
const hmacForSecret = generateHmac(secret, message);
130+
if (expectedHmac === hmacForSecret) return true;
131+
}
132+
133+
return false;
134+
};
135+
136+
const validateCsrfTokenCookie = (req: Request, possibleSecrets: Array<string>) => {
137+
const csrfTokenFromCookie = getCsrfTokenFromCookie(req);
138+
const [expectedHmac, randomValue] = csrfTokenFromCookie.split(csrfTokenDelimiter);
139+
140+
if (
141+
typeof expectedHmac !== "string" ||
142+
expectedHmac === "" ||
143+
typeof randomValue !== "string" ||
144+
randomValue === ""
145+
) {
146+
return false;
147+
}
148+
149+
return validateHmac({ expectedHmac, possibleSecrets, randomValue, req });
150+
};
116151

117152
// given an array of secrets, checks whether at least one of the secrets constructs a matching hmac
118153
const validateCsrfToken: CsrfTokenValidator = (req, possibleSecrets) => {
@@ -131,13 +166,7 @@ export function doubleCsrf({
131166
// The reason it's safe for us to only validate the hmac and random value from the cookie here
132167
// is because we've already checked above whether the token in the cookie and the token provided
133168
// by the request are the same.
134-
const message = constructMessage(req, randomValue);
135-
for (const secret of possibleSecrets) {
136-
const hmacForSecret = generateHmac(secret, message);
137-
if (receivedHmac === hmacForSecret) return true;
138-
}
139-
140-
return false;
169+
return validateHmac({ expectedHmac: receivedHmac, req, possibleSecrets, randomValue });
141170
};
142171

143172
const validateRequest: CsrfRequestValidator = (req) => {

src/tests/testsuite.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
getCookieValueFromResponse,
1111
switchSecret,
1212
} from "./utils/helpers.js";
13-
import { generateMocks, generateMocksWithToken, next } from "./utils/mock.js";
13+
import { generateMocks, generateMocksWithToken, next, setCsrfCookieOnRequest } from "./utils/mock.js";
1414

1515
type CreateTestsuite = (
1616
name: string,
@@ -171,14 +171,29 @@ export const createTestSuite: CreateTestsuite = (name, doubleCsrfOptions) => {
171171
expect(generatedToken).not.toBe(csrfToken);
172172
});
173173

174-
it("should return existing CSRF token for a GET request that does not include the CSRF token", () => {
174+
it("should return existing CSRF token for a GET request that does not send the CSRF token", () => {
175175
const { mockRequest, mockResponse, csrfToken } = generateMocksWithTokenInternal();
176176
mockRequest.method = "GET";
177177
mockRequest.headers[HEADER_KEY] = undefined;
178178
const reusedToken = generateCsrfToken(mockRequest, mockResponse);
179179

180180
expect(reusedToken).toBe(csrfToken);
181181
});
182+
183+
it("should return a new and valid CSRF token for a GET request that does not send the CSRF token or have the CSRF cookie", () => {
184+
const { mockRequest, mockResponse, csrfToken } = generateMocksWithTokenInternal();
185+
mockRequest.method = "GET";
186+
mockRequest.headers[HEADER_KEY] = undefined;
187+
mockRequest.headers.cookie = `${cookieName}=`;
188+
mockRequest.cookies[cookieName] = undefined;
189+
190+
const newToken = generateCsrfToken(mockRequest, mockResponse);
191+
setCsrfCookieOnRequest(mockRequest, mockResponse, cookieName);
192+
expect(newToken).not.toBe(csrfToken);
193+
expect(validateRequest(mockRequest)).toBe(false);
194+
mockRequest.headers[HEADER_KEY] = newToken;
195+
expect(validateRequest(mockRequest)).toBe(true);
196+
});
182197
});
183198

184199
describe("validateRequest", () => {

src/tests/utils/mock.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ export type GenerateMocksWithTokenOptions = {
7878
sessionIdentifier?: string;
7979
};
8080

81+
export const setCsrfCookieOnRequest = (req: Request, response: Response, cookieName: string) => {
82+
const { setCookie, cookieValue } = getCookieValueFromResponse(response);
83+
req.headers.cookie = `${cookieName}=${cookieValue};`;
84+
85+
(req as any).cookies = undefined;
86+
cookieParserMiddleware(req, response, next);
87+
return {
88+
setCookie,
89+
cookieValue,
90+
};
91+
};
92+
8193
// Generate the request and response mocks.
8294
// Set them up as if they have been pre-processed in a valid state.
8395
export const generateMocksWithToken = ({
@@ -89,15 +101,12 @@ export const generateMocksWithToken = ({
89101
const { mockRequest, mockResponse, mockResponseHeaders } = generateMocks(sessionIdentifier);
90102

91103
const csrfToken = generateCsrfToken(mockRequest, mockResponse);
92-
const { setCookie, cookieValue } = getCookieValueFromResponse(mockResponse);
93-
mockRequest.headers.cookie = `${cookieName}=${cookieValue};`;
104+
const { setCookie, cookieValue } = setCsrfCookieOnRequest(mockRequest, mockResponse, cookieName);
94105
const decodedCookieValue = decodeURIComponent(cookieValue);
95106
// Have to delete the cookies object otherwise cookieParser will skip it's parsing.
96107
// After removing @types/cookie-parser and relying on cookie-parser provided types
97108
// the types prevent cookies from being undefined despite it being valid for cases
98109
// before the middleware runs.
99-
(mockRequest as any).cookies = undefined;
100-
cookieParserMiddleware(mockRequest, mockResponse, next);
101110
expect(getCookieFromRequest(cookieName, mockRequest)).toBe(decodedCookieValue);
102111

103112
mockRequest.headers[HEADER_KEY] = csrfToken;

0 commit comments

Comments
 (0)