Skip to content

Commit 72b9cd9

Browse files
Merge pull request #264 from NYPL/main
csrf bugfix
2 parents 53d1998 + 5b55161 commit 72b9cd9

10 files changed

+256
-181
lines changed

.eslintrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"react/display-name": 0,
3737
"@typescript-eslint/no-use-before-define": 0,
3838
"@typescript-eslint/camelcase": "off",
39-
"react/react-in-jsx-scope": 0
39+
"react/react-in-jsx-scope": 0,
40+
"@typescript-eslint/explicit-module-boundary-types": 0
4041
},
4142
"settings": {
4243
"react": {

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## CHANGE LOG
22

3+
### v1.1.1 Fix CSRF regression
4+
- Ensure that CSRF headers are not overwritten by nyplUserHasRegistered headers
5+
- Refactor CSRF utils
6+
37
### v1.1.0 Duplicate patron bug fixes
48
- Add cookie-based redirect back to congrats page from any page after success
59
- Remove hasUsernameBeenValidated flag and hidden input field

pages/new/index.tsx

+23-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { useEffect } from "react";
88
import { Heading } from "@nypl/design-system-react-components";
99
import RoutingLinks from "../../src/components/RoutingLinks.tsx";
1010
import useFormDataContext from "../../src/context/FormDataContext";
11-
import { getCsrfToken } from "../../src/utils/utils";
11+
import {
12+
generateNewCookieTokenAndHash,
13+
generateNewToken,
14+
parseTokenFromPostRequestCookies,
15+
} from "../../src/utils/csrfUtils";
16+
import * as cookie from "../../src/utils/CookieUtils";
1217

1318
import { GetServerSideProps } from "next";
1419
import { useTranslation } from "next-i18next";
@@ -61,12 +66,24 @@ function HomePage({ policyType, csrfToken, lang }: HomePageProps) {
6166
}
6267

6368
export const getServerSideProps: GetServerSideProps = async (context) => {
64-
const { csrfToken } = getCsrfToken(context.req, context.res);
69+
const tokenFromRequestCookie = parseTokenFromPostRequestCookies(context.req);
6570
const { query } = context;
66-
context.res.setHeader(
67-
"Set-Cookie",
68-
`nyplUserRegistered=false; Max-Age=-1; path=/; domain=${cookieDomain};`
69-
);
71+
let newTokenCookie;
72+
let csrfToken = tokenFromRequestCookie.value;
73+
74+
if (!csrfToken) {
75+
csrfToken = generateNewToken();
76+
const newTokenCookieString = generateNewCookieTokenAndHash(csrfToken);
77+
newTokenCookie = cookie.buildCookieHeader(newTokenCookieString);
78+
}
79+
80+
const headers = [
81+
// reset cookie that would otherwise bump users out of the flow
82+
// to succcess page
83+
`nyplUserRegistered=false; Max-Age=-1; path=/; domain=${cookieDomain};`,
84+
newTokenCookie,
85+
];
86+
context.res.setHeader("set-cookie", headers);
7087

7188
return {
7289
props: {

src/utils/CookieUtils.ts

+36-6
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,44 @@ import { serialize } from "cookie";
55
* Function to set cookies on the server side based on with minor updates:
66
* https://github.com/vercel/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
77
*/
8-
const set = (res, name, value, options) => {
8+
const set = (res, value) => {
9+
const cookie = buildCookieHeader(value);
10+
return res.setHeader("Set-Cookie", cookie);
11+
};
12+
13+
const buildCookieHeader = (value) => {
14+
const { name, options } = metadata().csrfToken;
915
const stringValue =
1016
typeof value === "object" ? "j:" + JSON.stringify(value) : String(value);
17+
return serialize(name, String(stringValue), options);
18+
};
19+
20+
// Use secure cookies if the site uses HTTPS
21+
// This being conditional allows cookies to work non-HTTPS development URLs
22+
// Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
23+
// prefix, but enable them by default if the site URL is HTTPS; but not for
24+
// non-HTTPS URLs like http://localhost which are used in development).
25+
// For more on prefixes see:
26+
// https://googlechrome.github.io/samples/cookie-prefixes/
1127

12-
return res.setHeader(
13-
"Set-Cookie",
14-
serialize(name, String(stringValue), options)
15-
);
28+
const metadata = () => {
29+
const useSecureCookies = process.env.NODE_ENV === "production";
30+
const name = `${useSecureCookies ? "__Host-" : ""}nypl.csrf-token`;
31+
return {
32+
// default cookie options
33+
csrfToken: {
34+
// Default to __Host- for CSRF token for additional protection if using
35+
// useSecureCookies.
36+
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
37+
name,
38+
options: {
39+
httpOnly: true,
40+
sameSite: "lax",
41+
path: "/",
42+
secure: useSecureCookies,
43+
},
44+
},
45+
};
1646
};
1747

18-
export default { set };
48+
export { set, metadata, buildCookieHeader };

src/utils/__tests__/api.test.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@ import {
88
validateUsername,
99
callPatronAPI,
1010
} from "../api";
11-
import utils from "../utils";
1211
const axios = require("axios");
1312
import moment from "moment";
1413

1514
jest.mock("axios");
16-
jest.mock("../utils");
15+
jest.mock("../csrfUtils", () => {
16+
return {
17+
...jest.requireActual("../csrfUtils"),
18+
parseCsrfToken: jest.fn(() => "12345"),
19+
validateCsrfToken: jest.fn(() => ({
20+
csrfToken: "csrfToken",
21+
csrfTokenValid: true,
22+
})),
23+
};
24+
});
1725
let mockedReturnedJson = {};
1826
class MockRes {
1927
status() {
@@ -25,10 +33,6 @@ class MockRes {
2533
}
2634
}
2735
const mockRes = new MockRes();
28-
utils.getCsrfToken = jest.fn(() => ({
29-
csrfToken: "csrfToken",
30-
csrfTokenValid: true,
31-
}));
3236

3337
describe("constructApiHeaders", () => {
3438
test("it returns authorization headers object", () => {
@@ -266,7 +270,7 @@ describe("validateAddress", () => {
266270
isWorkAddress: false,
267271
},
268272
cookies: {
269-
"next-auth.csrf-token": "csrfToken",
273+
"nypl.csrf-token": "csrfToken",
270274
},
271275
};
272276
axios.post.mockResolvedValueOnce({
@@ -356,7 +360,7 @@ describe("validateUsername", () => {
356360
username: "tomnook42",
357361
},
358362
cookies: {
359-
"next-auth.csrf-token": "csrfToken",
363+
"nypl.csrf-token": "csrfToken",
360364
},
361365
};
362366
axios.post.mockResolvedValue({
@@ -400,7 +404,7 @@ describe("validateUsername", () => {
400404
username: "tomnook42",
401405
},
402406
cookies: {
403-
"next-auth.csrf-token": "csrfToken",
407+
"nypl.csrf-token": "csrfToken",
404408
},
405409
};
406410
axios.post.mockRejectedValue({

src/utils/__tests__/csrfUtils.test.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as csrfUtils from "../csrfUtils";
2+
3+
jest.mock("../CookieUtils", () => {
4+
return {
5+
...jest.requireActual("../CookieUtils.ts"),
6+
set: jest.fn(),
7+
};
8+
});
9+
jest.mock("crypto", () => {
10+
return {
11+
createHash: jest.fn().mockReturnValue({
12+
update: () => ({
13+
digest: () => "666",
14+
}),
15+
}),
16+
};
17+
});
18+
19+
describe("validateCsrfToken", () => {
20+
test("it returns invalid when no token is set", () => {
21+
const csrfTokenValid = csrfUtils.validateCsrfToken({
22+
cookies: {},
23+
});
24+
// We don't really care what it is, just that it's there.
25+
expect(csrfTokenValid).toEqual(false);
26+
});
27+
28+
test("it returns token valid when request token matches cookie token", () => {
29+
const isValid = csrfUtils.validateCsrfToken({
30+
method: "POST",
31+
body: { csrfToken: "12345" },
32+
cookies: {
33+
"nypl.csrf-token": "12345|666",
34+
},
35+
});
36+
37+
expect(isValid).toEqual(true);
38+
});
39+
40+
test("it returns token invalid when request token does not match cookie token", () => {
41+
const isValid = csrfUtils.validateCsrfToken({
42+
method: "POST",
43+
body: { csrfToken: "12345" },
44+
cookies: {
45+
"nypl.csrf-token": "12345|789",
46+
},
47+
});
48+
49+
expect(isValid).toEqual(false);
50+
});
51+
});

src/utils/__tests__/utils.test.ts

+8-50
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,8 @@
1-
import {
2-
getPageTitles,
3-
createQueryParams,
4-
createNestedQueryParams,
5-
getCsrfToken,
6-
} from "../utils";
7-
import cookieUtils from "../CookieUtils";
8-
9-
jest.mock("../CookieUtils");
1+
import * as utils from "../utils";
102

113
describe("getPageTiles", () => {
124
test("it returns text saying there are 5 steps", () => {
13-
expect(getPageTitles()).toEqual({
5+
expect(utils.getPageTitles()).toEqual({
146
personal: "Step 1 of 5: Personal Information",
157
address: "Step 2 of 5: Address",
168
workAddress: "Alternate Address",
@@ -23,7 +15,7 @@ describe("getPageTiles", () => {
2315

2416
describe("createQueryParams", () => {
2517
test("it should return an empty string with an empty object", () => {
26-
expect(createQueryParams({})).toEqual("");
18+
expect(utils.createQueryParams({})).toEqual("");
2719
});
2820

2921
test("it should return a url query string", () => {
@@ -32,16 +24,16 @@ describe("createQueryParams", () => {
3224
key2: "value2",
3325
key3: "value3",
3426
};
35-
expect(createQueryParams(data)).toEqual(
27+
expect(utils.createQueryParams(data)).toEqual(
3628
"&key1=value1&key2=value2&key3=value3"
3729
);
3830
});
3931
});
4032

4133
describe("createNestedQueryParams", () => {
4234
test("it should return an empty string with an empty string or type argument", () => {
43-
expect(createNestedQueryParams({}, "key")).toEqual("");
44-
expect(createNestedQueryParams({ key: "somevalue" }, "")).toEqual("");
35+
expect(utils.createNestedQueryParams({}, "key")).toEqual("");
36+
expect(utils.createNestedQueryParams({ key: "somevalue" }, "")).toEqual("");
4537
});
4638

4739
test("it should return a nested url query string", () => {
@@ -50,46 +42,12 @@ describe("createNestedQueryParams", () => {
5042
key2: "value2",
5143
key3: "value3",
5244
};
53-
expect(createNestedQueryParams(data, "results")).toEqual(
45+
expect(utils.createNestedQueryParams(data, "results")).toEqual(
5446
`&results=${JSON.stringify(data)}`
5547
);
5648

57-
expect(createNestedQueryParams(data, "errors")).toEqual(
49+
expect(utils.createNestedQueryParams(data, "errors")).toEqual(
5850
`&errors=${JSON.stringify(data)}`
5951
);
6052
});
6153
});
62-
63-
describe("getCsrfToken", () => {
64-
beforeAll(() => {
65-
// We don't actually want to set any cookies so mock this.
66-
cookieUtils.set = jest.fn(() => "ok");
67-
});
68-
69-
test("it returns a new token which is not valid since it's new and not compared to an existing token", () => {
70-
const { csrfToken, csrfTokenValid } = getCsrfToken({ cookies: {} }, {});
71-
// We don't really care what it is, just that it's there.
72-
expect(csrfToken).toBeDefined();
73-
expect(csrfTokenValid).toEqual(false);
74-
});
75-
76-
// TODO: it's hard to test when the true case happens because the secret is
77-
// private in the function, by design and security.
78-
test("it returns a false token validation", () => {
79-
const firstCall = getCsrfToken({ cookies: {} }, {});
80-
81-
expect(firstCall.csrfToken).toBeDefined();
82-
expect(firstCall.csrfTokenValid).toEqual(false);
83-
84-
const second = getCsrfToken(
85-
{
86-
cookies: { "next-auth.csrf-token": "wrong-token!" },
87-
},
88-
{}
89-
);
90-
91-
// The first token should not be reused.
92-
expect(second.csrfToken === firstCall.csrfToken).toEqual(false);
93-
expect(second.csrfTokenValid).toEqual(false);
94-
});
95-
});

src/utils/api.ts

+14-4
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import {
1313
FormAPISubmission,
1414
AddressResponse,
1515
} from "../interfaces";
16-
import utils from "./utils";
16+
import {
17+
generateNewToken,
18+
setCsrfTokenCookie,
19+
validateCsrfToken,
20+
parseTokenFromPostRequestCookies,
21+
} from "./csrfUtils";
1722

1823
// Initializing the cors middleware
1924
export const cors = Cors({
@@ -238,7 +243,12 @@ function invalidCsrfResponse(res) {
238243
*/
239244
export async function validateAddress(req, res, appObj = app) {
240245
const tokenObject = appObj["tokenObject"];
241-
const { csrfTokenValid } = utils.getCsrfToken(req, res);
246+
const parsedTokenFromRequestCookies = parseTokenFromPostRequestCookies(req);
247+
const csrfTokenValid = validateCsrfToken(req);
248+
if (!parsedTokenFromRequestCookies) {
249+
const newToken = generateNewToken();
250+
setCsrfTokenCookie(res, newToken);
251+
}
242252
if (!csrfTokenValid) {
243253
return invalidCsrfResponse(res);
244254
}
@@ -293,7 +303,7 @@ export async function validateUsername(
293303
appObj = app
294304
) {
295305
const tokenObject = appObj["tokenObject"];
296-
const { csrfTokenValid } = utils.getCsrfToken(req, res);
306+
const csrfTokenValid = validateCsrfToken(req);
297307
if (!csrfTokenValid) {
298308
return invalidCsrfResponse(res);
299309
}
@@ -438,7 +448,7 @@ export async function createPatron(
438448
appObj = app
439449
) {
440450
const data = req.body;
441-
const { csrfTokenValid } = utils.getCsrfToken(req, res);
451+
const csrfTokenValid = validateCsrfToken(req);
442452
if (!csrfTokenValid) {
443453
return invalidCsrfResponse(res);
444454
}

0 commit comments

Comments
 (0)