Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/allow multiple secrets #37

Merged
merged 16 commits into from
Nov 5, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 52 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ declare module "express-serve-static-core" {
}
}

export type CsrfSecretRetriever = (req?: Request) => string;
export type CsrfSecretRetriever = (req?: Request) => string | string[];
export type DoubleCsrfConfigOptions = Partial<DoubleCsrfConfig> & {
getSecret: CsrfSecretRetriever;
};
Expand All @@ -45,7 +45,7 @@ export type CsrfRequestValidator = (req: Request) => boolean;
export type CsrfTokenAndHashPairValidator = (
token: string,
hash: string,
secret: string
possibleSecrets: string[]
) => boolean;
export type CsrfCookieSetter = (
res: Response,
Expand All @@ -56,7 +56,8 @@ export type CsrfCookieSetter = (
export type CsrfTokenCreator = (
req: Request,
res: Response,
ovewrite?: boolean
ovewrite?: boolean,
validateOnGeneration?: boolean
) => string;

export interface DoubleCsrfConfig {
Expand Down Expand Up @@ -100,24 +101,37 @@ export function doubleCsrf({
code: "EBADCSRFTOKEN",
});

const generateTokenAndHash = (req: Request, overwrite = false) => {
const generateTokenAndHash = (
req: Request,
overwrite: boolean,
validateOnGeneration: boolean
) => {
const getSecretResult = getSecret(req);
const possibleSecrets = Array.isArray(getSecretResult)
? getSecretResult
: [getSecretResult];

const csrfCookie = getCsrfCookieFromRequest(req);
// if ovewrite is set, then even if there is already a csrf cookie, do not reuse it
// if csrfCookie is present, it means that there is already a session, so we extract
// the hash/token from it, validate it and reuse the token. This makes possible having
// multiple tabs open at the same time
// If ovewrite is set, then even if there is already a csrf cookie, do not reuse it
// If csrfCookie is present, it means that there is already a session, so we extract
// the hash/token from it, validate it and reuse the token as long as it is correct. This makes possible having
// multiple tabs open at the same time.
// If no cookie is present or the pair is invalid, generate a new token and hash from scratch
if (typeof csrfCookie === "string" && !overwrite) {
const [csrfToken, csrfTokenHash] = csrfCookie.split("|");
const csrfSecret = getSecret(req);
if (!validateTokenAndHashPair(csrfToken, csrfTokenHash, csrfSecret)) {
// if the pair is not valid, then the cookie has been modified by a third party
if (validateTokenAndHashPair(csrfToken, csrfTokenHash, possibleSecrets)) {
// If the pair is valid, reuse it
return { csrfToken, csrfTokenHash };
} else if (validateOnGeneration) {
// If the pair is invalid, but we want to validate on generation, throw an error
// Only if the option is set
throw invalidCsrfTokenError;
}
return { csrfToken, csrfTokenHash };
}
// else, generate the token and hash from scratch
const csrfToken = randomBytes(size).toString("hex");
const secret = getSecret(req);
// the 'newest' or preferred secret is the first one in the array
const secret = possibleSecrets[0];
const csrfTokenHash = createHash("sha256")
.update(`${csrfToken}${secret}`)
.digest("hex");
Expand All @@ -133,9 +147,14 @@ export function doubleCsrf({
const generateToken: CsrfTokenCreator = (
req: Request,
res: Response,
overwrite?: boolean
overwrite = false,
validateOnGeneration = true
) => {
const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, overwrite);
const { csrfToken, csrfTokenHash } = generateTokenAndHash(
req,
overwrite,
validateOnGeneration
);
const cookieContent = `${csrfToken}|${csrfTokenHash}`;
res.cookie(cookieName, cookieContent, { ...cookieOptions, httpOnly: true });
return csrfToken;
Expand All @@ -145,19 +164,22 @@ export function doubleCsrf({
? (req: Request) => req.signedCookies[cookieName] as string
: (req: Request) => req.cookies[cookieName] as string;

// validates if a token and its hash matches, given the secret that was originally included in the hash
// 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,
secret
possibleSecrets
) => {
if (typeof token !== "string" || typeof hash !== "string") return false;

const expectedHash = createHash("sha256")
.update(`${token}${secret}`)
.digest("hex");
for (const secret of possibleSecrets) {
const expectedHash = createHash("sha256")
.update(`${token}${secret}`)
.digest("hex");
if (hash === expectedHash) return true;
}

return expectedHash === hash;
return false;
};

const validateRequest: CsrfRequestValidator = (req) => {
Expand All @@ -171,11 +193,18 @@ export function doubleCsrf({
// csrf token from the request
const csrfTokenFromRequest = getTokenFromRequest(req) as string;

const csrfSecret = getSecret(req);
const getSecretResult = getSecret(req);
const possibleSecrets = Array.isArray(getSecretResult)
? getSecretResult
: [getSecretResult];

return (
csrfToken === csrfTokenFromRequest &&
validateTokenAndHashPair(csrfTokenFromRequest, csrfTokenHash, csrfSecret)
validateTokenAndHashPair(
csrfTokenFromRequest,
csrfTokenHash,
possibleSecrets
)
);
};

Expand Down
237 changes: 231 additions & 6 deletions src/tests/doublecsrf.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,239 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { assert } from "chai";
import { DoubleCsrfConfigOptions, doubleCsrf } from "../index.js";
import { createTestSuite } from "./testsuite.js";
import { getSecret } from "./utils/helpers.js";
import {
getSingleSecret,
getMultipleSecrets,
attachResponseValuesToRequest,
} from "./utils/helpers.js";
import { generateMocks, generateMocksWithToken } from "./utils/mock.js";
import { HEADER_KEY } from "./utils/constants.js";

createTestSuite("csrf-csrf unsigned", { getSecret });
createTestSuite("csrf-csrf signed", {
getSecret,
createTestSuite("csrf-csrf unsigned, single secret", {
getSecret: getSingleSecret,
});
createTestSuite("csrf-csrf signed, single secret", {
cookieOptions: { signed: true },
getSecret: getSingleSecret,
});
createTestSuite("csrf-csrf signed with custom options", {
getSecret,
createTestSuite("csrf-csrf signed with custom options, single secret", {
getSecret: getSingleSecret,
cookieOptions: { signed: true, sameSite: "strict" },
size: 128,
cookieName: "__Host.test-the-thing.token",
});

createTestSuite("csrf-csrf unsigned, multiple secrets", {
getSecret: getMultipleSecrets,
});
createTestSuite("csrf-csrf signed, multiple secrets", {
cookieOptions: { signed: true },
getSecret: getMultipleSecrets,
});
createTestSuite("csrf-csrf signed with custom options, multiple secrets", {
getSecret: getMultipleSecrets,
cookieOptions: { signed: true, sameSite: "strict" },
size: 128,
cookieName: "__Host.test-the-thing.token",
});

describe("csrf-csrf token-rotation", () => {
// Initialise the package with the passed in test suite settings and a mock secret
const doubleCsrfOptions: Omit<DoubleCsrfConfigOptions, "getSecret"> = {};

const {
cookieName = "__Host-psifi.x-csrf-token",
cookieOptions: { signed = false } = {},
} = doubleCsrfOptions;

const SECRET1 = "secret1";
const SECRET2 = "secret2";

const generateMocksWithMultipleSecrets = (secrets: string[] | string) => {
const { generateToken, validateRequest } = doubleCsrf({
...doubleCsrfOptions,
getSecret: () => secrets,
});

return {
...generateMocksWithToken({
cookieName,
signed,
generateToken,
validateRequest,
}),
validateRequest,
generateToken,
};
};

context("validating requests with combination of different secret/s", () => {
davidgonmar marked this conversation as resolved.
Show resolved Hide resolved
// Generate request --> CSRF token with secret1
// We will then match a request with token and secret1 with other combinations of secrets
const { mockRequest, validateRequest } =
generateMocksWithMultipleSecrets(SECRET1);
assert.isTrue(validateRequest(mockRequest));

it("should be valid with 1 matching secret", () => {
assert.isTrue(
generateMocksWithMultipleSecrets(SECRET1).validateRequest(mockRequest)
);
});

it("should be valid with 1/1 matching secret in array", () => {
assert.isTrue(
generateMocksWithMultipleSecrets([SECRET1]).validateRequest(mockRequest)
);
});

it("should be valid with 1/2 matching secrets in array, first secret matches", () => {
assert.isTrue(
generateMocksWithMultipleSecrets([SECRET1, SECRET2]).validateRequest(
mockRequest
)
);
});

it("should be valid with 1/2 matching secrets in array, second secret matches", () => {
assert.isTrue(
generateMocksWithMultipleSecrets([SECRET2, SECRET1]).validateRequest(
mockRequest
)
);
});

it("should be invalid with 0/1 matching secret in array", () => {
assert.isFalse(
generateMocksWithMultipleSecrets([SECRET2]).validateRequest(mockRequest)
);
});

it("should be invalid with 0/2 matching secrets in array", () => {
assert.isFalse(
generateMocksWithMultipleSecrets(SECRET2).validateRequest(mockRequest)
);
});

it("should be invalid with 0/3 matching secrets in array", () => {
assert.isFalse(
generateMocksWithMultipleSecrets([
"invalid0",
"invalid1",
"invalid2",
]).validateRequest(mockRequest)
);
});
});

context(
"should generate tokens correctly, simulating token rotations",
() => {
const getEmptyResponse = () => {
const { mockResponse, mockRequest } = generateMocks();

Check warning on line 133 in src/tests/doublecsrf.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

src/tests/doublecsrf.test.ts#L133

'mockRequest' is assigned a value but never used (@typescript-eslint/no-unused-vars)
return mockResponse;
};

const {
validateRequest: validateRequestWithSecret1,
generateToken: generateTokenWithSecret1,

Check warning on line 139 in src/tests/doublecsrf.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

src/tests/doublecsrf.test.ts#L139

'generateTokenWithSecret1' is assigned a value but never used (@typescript-eslint/no-unused-vars)
} = generateMocksWithMultipleSecrets(SECRET1);

const {
validateRequest: validateRequestWithSecret2,
generateToken: generateTokenWithSecret2,

Check warning on line 144 in src/tests/doublecsrf.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

src/tests/doublecsrf.test.ts#L144

'generateTokenWithSecret2' is assigned a value but never used (@typescript-eslint/no-unused-vars)
} = generateMocksWithMultipleSecrets(SECRET2);

const {
validateRequest: validateRequestWithSecret1And2,

Check warning on line 148 in src/tests/doublecsrf.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

src/tests/doublecsrf.test.ts#L148

'validateRequestWithSecret1And2' is assigned a value but never used (@typescript-eslint/no-unused-vars)
generateToken: generateTokenWithSecret1And2,
} = generateMocksWithMultipleSecrets([SECRET1, SECRET2]);

const {
validateRequest: validateRequestWithSecret2And1,

Check warning on line 153 in src/tests/doublecsrf.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

src/tests/doublecsrf.test.ts#L153

'validateRequestWithSecret2And1' is assigned a value but never used (@typescript-eslint/no-unused-vars)
generateToken: generateTokenWithSecret2And1,
} = generateMocksWithMultipleSecrets([SECRET2, SECRET1]);

it("should reuse existing token on request with SECRET1, while current is [SECRET1, SECRET2]", () => {
//
const { mockRequest } = generateMocksWithMultipleSecrets(SECRET1);
const mockResponse = getEmptyResponse();

const token = generateTokenWithSecret1And2(mockRequest, mockResponse);
attachResponseValuesToRequest({
request: mockRequest,
response: mockResponse,
headerKey: HEADER_KEY,
cookieName,
bodyResponseToken: token,
});

assert.isTrue(validateRequestWithSecret1(mockRequest));
assert.isFalse(validateRequestWithSecret2(mockRequest));
});

it("should reuse existing token on request with SECRET1, while current is [SECRET2, SECRET1]", () => {
const { mockRequest } = generateMocksWithMultipleSecrets(SECRET1);
const mockResponse = getEmptyResponse();

const token = generateTokenWithSecret2And1(mockRequest, mockResponse);
attachResponseValuesToRequest({
request: mockRequest,
response: mockResponse,
headerKey: HEADER_KEY,
cookieName,
bodyResponseToken: token,
});

assert.isTrue(validateRequestWithSecret1(mockRequest));
assert.isFalse(validateRequestWithSecret2(mockRequest));
});

it("should generate new token (with secret 1) on request with SECRET2, while current is [SECRET1, SECRET2], if overwrite is true", () => {
const { mockRequest } = generateMocksWithMultipleSecrets(SECRET2);

const mockResponse = getEmptyResponse();

const token = generateTokenWithSecret1And2(
mockRequest,
mockResponse,
true
);

attachResponseValuesToRequest({
request: mockRequest,
response: mockResponse,
headerKey: HEADER_KEY,
cookieName,
bodyResponseToken: token,
});

assert.isFalse(validateRequestWithSecret2(mockRequest));
assert.isTrue(validateRequestWithSecret1(mockRequest));
});

it("should generate new token (with secret 2) on request with SECRET2, while current is [SECRET2, SECRET1], if overwrite is true", () => {
const { mockRequest } = generateMocksWithMultipleSecrets(SECRET2);

const mockResponse = getEmptyResponse();

const token = generateTokenWithSecret2And1(
mockRequest,
mockResponse,
true
);

attachResponseValuesToRequest({
request: mockRequest,
response: mockResponse,
headerKey: HEADER_KEY,
cookieName,
bodyResponseToken: token,
});

assert.isTrue(validateRequestWithSecret2(mockRequest));
assert.isFalse(validateRequestWithSecret1(mockRequest));
});
}
);
});
Loading
Loading