Skip to content

Commit

Permalink
feat: support multiple secrets (backwards compatible)
Browse files Browse the repository at this point in the history
* feat: allow returning multiple secrets in getSecret

* fix: picking a secret in generateTokenAndHash

* feat: add validateOnGeneration option to generateToken

* fix: typing in CsrfTokenCreator

* test: some basic tests for new functionality - wip

* test: revert tests to what they were before, but test both passing a single and multiple secrets

* test: add additional tests for secret rotation scenarios

* test: wip - improve tests for token rotation

* test: refactor tests to be tidier, and add test cases

* test: add validateOnGeneration tests

* refactor: move overwrite/validateOnGeneration defaults to generateToken

* test: change some wording

* fix: accept validateOnGeneration param in req.csrfToken

* docs: update README with latest information and fix minor details

* chore(dev): rename validateOnGeneration > validateOnReuse

Some comment and readme adjustments

* chore(dev): add CHANGELOG.md to .prettierignore

---------

Co-authored-by: David González Martínez <[email protected]>
  • Loading branch information
psibean and davidgonmar authored Nov 5, 2023
1 parent fe5170a commit 51da818
Show file tree
Hide file tree
Showing 7 changed files with 422 additions and 54 deletions.
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
.*
lib/
package-lock.json
package-lock.json
CHANGELOG.md
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@ All notable changes to this project will be documented in this file. See [standa

### [3.0.1](https://github.com/Psifi-Solutions/csrf-csrf/compare/v3.0.0...v3.0.1) (2023-09-15)


### Bug Fixes

* types for TypeScript moduleResolution ([#32](https://github.com/Psifi-Solutions/csrf-csrf/issues/32)) ([6a5cd2c](https://github.com/Psifi-Solutions/csrf-csrf/commit/6a5cd2c43e4940577856cc08a565da79c4e1348b))
- types for TypeScript moduleResolution ([#32](https://github.com/Psifi-Solutions/csrf-csrf/issues/32)) ([6a5cd2c](https://github.com/Psifi-Solutions/csrf-csrf/commit/6a5cd2c43e4940577856cc08a565da79c4e1348b))

## 3.0.0 (2023-08-18)

Expand Down
34 changes: 26 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ const {
</p>

```js
const myRoute = (request, response) => {
const csrfToken = generateToken(request, response);
const myRoute = (req, res) => {
const csrfToken = generateToken(req, res);
// You could also pass the token into the context of a HTML response.
res.json({ csrfToken });
};
Expand Down Expand Up @@ -193,7 +193,7 @@ const doubleCsrfUtilities = doubleCsrf({
sameSite = "lax", // Recommend you make this strict if posible
path = "/",
secure = true,
...remainingCOokieOptions // See cookieOptions below
...remainingCookieOptions // See cookieOptions below
},
size: 64, // The size of the generated tokens in bits
ignoredMethods: ["GET", "HEAD", "OPTIONS"], // A list of request methods that will not be protected.
Expand All @@ -204,12 +204,14 @@ const doubleCsrfUtilities = doubleCsrf({
<h3>getSecret</h3>

```ts
(request: Request) => string;
(request?: Request) => string | string[]
```

<p><b>Required</b></p>

<p>This should return a secret key to be used for hashing the CSRF tokens.</p>
<p>This should return a secret key or an array of secret keys to be used for hashing the CSRF tokens.</p>
<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>cookieName</h3>

Expand Down Expand Up @@ -324,27 +326,43 @@ number;
<h3>generateToken</h3>

```ts
(request: Request, response: Response, overwrite?: boolean) => string;
(
request: Request,
response: Response,
overwrite?: boolean, // Set to true to force a new token to be generated
validateOnReuse?: boolean // Set to false to generate a new token if token re-use is invalid
) => string;
```

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

```ts
generateToken(req, res, true); // This will force a new token to be generated, and a new cookie to be set, even if one already exists
```

<p>If the 'overwrite' parameter is set to false (default), the existing token will be re-used and returned. However, the cookie value will also be validated. If the validation fails an error will be thrown. If you don't want an error to be thrown, you can set the 'validateOnReuse' (by default, true) to false. In this case instead of throwing an error, a new token will be generated and returned.
</p>

```ts
generateToken(req, res, true); // As overwrite is true, an error will never be thrown.
generateToken(req, res, false); // As validateOnReuse is true (default), an error will be thrown if the cookie is invalid.
generateToken(req, res, false, false); // As validateOnReuse is false, an error will never be thrown, even if the cookie is invalid. Instead, a new cookie will be generated if it is found to be invalid.
```

<p>Instead of importing and using generateToken, you can also use req.csrfToken any time after the doubleCsrfProtection middleware has executed on your incoming request.</p>

```ts
req.csrfToken(); // same as generateToken(req, res) and generateToken(req, res, false);
req.csrfToken(true); // same as generateToken(req, res, true);
req.csrfToken(false, false); // same as generateToken(req, res, false, false);
```

<p>The <code>generateToken</code> function serves the purpose of establishing a CSRF (Cross-Site Request Forgery) protection mechanism by generating a token and an associated cookie. This function also provides the option to utilize a third parameter called <code>overwrite</code>. By default, this parameter is set to <em>false</em>.</p>
<p>The <code>generateToken</code> function serves the purpose of establishing a CSRF (Cross-Site Request Forgery) protection mechanism by generating a token and an associated cookie. This function also provides the option to utilize a third parameter called <code>overwrite</code>, and a fourth parameter called <code>validateOnReuse</code>. By default, <code>overwrite</code> is set to <em>false</em>, and <code>validateOnReuse</code> is set to <em>true</em>.</p>
<p>It returns a CSRF token and attaches a cookie to the response object. The cookie content is <code>`${token}|${tokenHash}`</code>.</p>
<p>You should only transmit your token to the frontend as part of a response payload, do not include the token in response headers or in a cookie, and <b>do not</b> transmit the token hash by any other means.</p>
<p>When <code>overwrite</code> is set to <em>false</em>, the function behaves in a way that preserves the existing CSRF cookie and its corresponding token and hash. In other words, if a valid CSRF cookie is already present in the incoming request, the function will reuse this cookie along with its associated token.</p>
<p>On the other hand, if <code>overwrite</code> is set to <em>true</em>, the function will generate a new token and cookie each time it is invoked. This behavior can potentially lead to certain complications, particularly when multiple tabs are being used to interact with your web application. In such scenarios, the creation of new cookies with every call to the function can disrupt the proper functioning of your web app across different tabs, as the changes might not be synchronized effectively (you would need to write your own synchronization logic).</p>
<p>If overwrite is set to <em>false</em>, the function will also validate the existing cookie information. If the information is found to be invalid (for instance, if the secret has been changed from the time the cookie was generated), an error will be thrown. If you don't want an error to be thrown, you can set the <code>validateOnReuse</code> (by default, <em>true</em>) to <em>false</em>. If it is <em>false</em>, instead of throwing an error, a new cookie will be generated.</p>

<h3>invalidCsrfTokenError</h3>

Expand Down
85 changes: 58 additions & 27 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 | Array<string>;
export type DoubleCsrfConfigOptions = Partial<DoubleCsrfConfig> & {
getSecret: CsrfSecretRetriever;
};
Expand All @@ -40,12 +40,12 @@ export type RequestMethod =
| "OPTIONS"
| "TRACE"
| "PATCH";
export type CsrfIgnoredMethods = RequestMethod[];
export type CsrfIgnoredMethods = Array<RequestMethod>;
export type CsrfRequestValidator = (req: Request) => boolean;
export type CsrfTokenAndHashPairValidator = (
token: string,
hash: string,
secret: string
possibleSecrets: Array<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,
validateOnReuse?: 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,
validateOnReuse: 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 true, always generate a new token.
// If overwrite is false and there is no existing token, generate a new token.
// If overwrite is false and there is an existin token then validate the token and hash pair
// 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("|");
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 (validateOnReuse) {
// 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
// otherwise, generate a completely new token
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 @@ -129,13 +143,18 @@ export function doubleCsrf({
// This should be used in routes or middleware to provide users with a token.
// The value returned from this should ONLY be sent to the client via a response payload.
// Do NOT send the csrfToken as a cookie, embed it in your HTML response, or as JSON.

// TODO: next major update, breaking change, combine extra params as a singl eobject parameter
const generateToken: CsrfTokenCreator = (
req: Request,
res: Response,
overwrite?: boolean
overwrite = false,
validateOnReuse = true
) => {
const { csrfToken, csrfTokenHash } = generateTokenAndHash(req, overwrite);
const { csrfToken, csrfTokenHash } = generateTokenAndHash(
req,
overwrite,
validateOnReuse
);
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,16 +193,25 @@ 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
)
);
};

const doubleCsrfProtection: doubleCsrfProtection = (req, res, next) => {
req.csrfToken = (overwrite?: boolean) => generateToken(req, res, overwrite);
// TODO: next major update, breaking change, make a single object parameter
req.csrfToken = (overwrite?: boolean, validateOnReuse?: boolean) =>
generateToken(req, res, overwrite, validateOnReuse);
if (ignoredMethodsSet.has(req.method as RequestMethod)) {
next();
} else if (validateRequest(req)) {
Expand Down
Loading

0 comments on commit 51da818

Please sign in to comment.