From 2e0647d756a180af88284f52fde7ffa3dd3b7cd4 Mon Sep 17 00:00:00 2001 From: psibean Date: Sun, 4 May 2025 12:21:47 +0930 Subject: [PATCH] feat: add an optional logger callback option A number of log events have been added to help understand what the csrf-csrf code is doing. The new logger callback option can be used to consume these events. --- README.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ src/constants.ts | 8 +++++ src/index.ts | 78 ++++++++++++++++++++++++++++++++++++------- src/types.ts | 43 ++++++++++++++++++++++++ 4 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 src/constants.ts diff --git a/README.md b/README.md index 5ee7d94..47068d7 100644 --- a/README.md +++ b/README.md @@ -382,6 +382,92 @@ Used to customise the error response statusCode, the contained erro

* It is primarily provided to avoid the need of wrapping the doubleCsrfProtection middleware in your own middleware, allowing you to apply a global logic as to whether or not CSRF protection should be executed based on the incoming request. You should only skip CSRF protection for cases you are 100% certain it is safe to do so, for example, requests you have identified as coming from a native app. You should ensure you are not introducing any vulnerabilities that would allow your web based app to circumvent the protection via CSRF attacks. This option is NOT a solution for CSRF errors.

+ +

logger

+ +```ts +(logArgs) => void +``` + +

+ Optional
+ Default:
undefined +

+ +

This function can be used to receive and handle internal logging from csrf-csrf, csrf-csrf will invoke this callback at different points throughout CSRF token generation and CSRF token validation to help you capture what is happening. The below documentation covers the log types and their respective arguments.

+ +

All of the logs will contain the { request: Request } so this will be omitted from the below.

+ +

CSRF_TOKEN_CONTENT_INVALID

+ +```ts +{ + logType: "CSRF_TOKEN_CONTENT_INVALID"; + isReceivedHmacAString: boolean; + isRandomValueAString: boolean; + isRandomValueEmpty: boolean; +} +``` +

If this log event is called it means that getCsrfTokenFromRequest and getCsrfTokenFromCookie are returning non-empty strings and the values are equal. However a hmac and randomValue could not be extracted from the CSRF token, this indicates that the format of the CSRF token is incorrect. You can use the provided arguments to infer what is wrong.

+ +

CSRF_TOKEN_GENERATED

+ +```ts +{ + logType: "CSRF_TOKEN_GENERATED"; + cookieOptions: CsrfTokenCookieOptions; + generatedNewToken: boolean; + overwrite: boolean; + validateOnReuse: boolean; +} +``` +

This log event is called whenever the generateCsrfToken is invoked, either directly or via req.csrfToken, and successfully returns a CSRF token.

+ +

CSRF_TOKEN_INVALID

+ +```ts +{ + logType: "CSRF_TOKEN_INVALID" +} +``` + +

This log event is called when the values from getCsrfTokenFromRequest and getCsrfTokenFromCookie are valid and a hmac and randomValue could be successfully extracted, however CSRF token validation is unable to successfully verify the received CSRF token with any of the secrets returned via getSecret. + +

CSRF_TOKEN_INVALID_REUSE

+ +```ts +{ + logType: "CSRF_TOKEN_INVALID_REUSE"; + overwrite: boolean; + validateOnReuse: boolean; +} +``` + +

This log event is called when generateCsrfToken is called with { overwrite: false, validateOnReuse: true } and the existing CSRF token is not considered valid. You would usually expect one of the other logs to be fired before this one to indicate why the validation may have failed.

+ +

CSRF_TOKEN_MISSING

+ +```ts +{ + logType: "CSRF_TOKEN_MISSING"; + isCsrfTokenFromCookieAString: boolean; + isCsrfTokenFromRequestAString: boolean; +} +``` + +

This log event is called when either getCsrfTokenFromRequest and/or getCsrfTokenFromCookie returns null, or undefined.

+ +

REQUEST_CONTENT_INVALID

+ +```ts + logType: "REQUEST_CONTENT_INVALID"; + isCsrfTokenFromCookieEmpty: boolean; + isCsrfTokenFromRequestEmpty: boolean; + isCsrfTokenEqual: boolean; +``` + +

This log event is called when either getCsrfTokenFromRequest and/or getCsrfTokenFromCookie return an empty string, or when their return values are not equal.

+

Utilities

Below is the documentation for what doubleCsrf returns.

diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..fba98e5 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,8 @@ +export const CSRF_LOG_EVENTS = { + CSRF_TOKEN_CONTENT_INVALID: "CSRF_TOKEN_CONTENT_INVALID", + CSRF_TOKEN_GENERATED: "CSRF_TOKEN_GENERATED", + CSRF_TOKEN_INVALID: "CSRF_TOKEN_INVALID", + CSRF_TOKEN_INVALID_REUSE: "CSRF_TOKEN_INVALID_REUSE", + CSRF_TOKEN_MISSING: "CSRF_TOKEN_MISSING", + REQUEST_CONTENT_INVALID: "REQUEST_CONTENT_INVALID", +} as const; diff --git a/src/index.ts b/src/index.ts index 8c7cc9b..b8c857d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import type { Request, Response } from "express"; import createHttpError from "http-errors"; import type { + CsrfLoggerArgs, CsrfRequestMethod, CsrfRequestValidator, CsrfTokenGenerator, @@ -13,8 +14,8 @@ import type { GenerateCsrfTokenConfig, GenerateCsrfTokenOptions, } from "./types"; - -export * from "./types"; +export { CSRF_LOG_EVENTS } from "./constants"; +import { CSRF_LOG_EVENTS } from "./constants"; export function doubleCsrf({ getSecret, @@ -29,6 +30,7 @@ export function doubleCsrf({ getCsrfTokenFromRequest = (req) => req.headers["x-csrf-token"], errorConfig: { statusCode = 403, message = "invalid csrf token", code = "EBADCSRFTOKEN" } = {}, skipCsrfProtection, + logger, }: DoubleCsrfConfigOptions): DoubleCsrfUtilities { const ignoredMethodsSet = new Set(ignoredMethods); const defaultCookieOptions = { @@ -49,6 +51,12 @@ export function doubleCsrf({ code: code, }); + const internalLogger = (logArgs: CsrfLoggerArgs) => { + if (logger) { + logger(logArgs); + } + }; + const constructMessage = (req: Request, randomValue: string) => { const uniqueIdentifier = getSessionIdentifier(req); const messageValues = [uniqueIdentifier.length, uniqueIdentifier, randomValue.length, randomValue]; @@ -78,10 +86,11 @@ export function doubleCsrf({ if (cookieName in req.cookies && !overwrite) { if (validateCsrfToken(req, possibleSecrets)) { // If the token is valid, reuse it - return getCsrfTokenFromCookie(req); + return { csrfToken: getCsrfTokenFromCookie(req), generatedNewToken: false }; } if (validateOnReuse) { + internalLogger({ logType: CSRF_LOG_EVENTS.CSRF_TOKEN_INVALID_REUSE, request: req, overwrite, validateOnReuse }); // If the pair is invalid, but we want to validate on generation, throw an error // only if the option is set throw invalidCsrfTokenError; @@ -95,7 +104,7 @@ export function doubleCsrf({ const hmac = generateHmac(secret, message); const csrfToken = `${hmac}${csrfTokenDelimiter}${randomValue}`; - return csrfToken; + return { csrfToken, generatedNewToken: true }; }; // Generates a token, sets the cookie on the response and returns the token. @@ -107,13 +116,22 @@ export function doubleCsrf({ res: Response, { cookieOptions = defaultCookieOptions, overwrite = false, validateOnReuse = false } = {}, ) => { - const csrfToken = generateCsrfTokenInternal(req, { + const parsedCookieOptions = { + ...defaultCookieOptions, + ...cookieOptions, + }; + const { csrfToken, generatedNewToken } = generateCsrfTokenInternal(req, { overwrite, validateOnReuse, }); - res.cookie(cookieName, csrfToken, { - ...defaultCookieOptions, - ...cookieOptions, + res.cookie(cookieName, csrfToken, parsedCookieOptions); + internalLogger({ + logType: CSRF_LOG_EVENTS.CSRF_TOKEN_GENERATED, + cookieOptions, + generatedNewToken, + request: req, + overwrite, + validateOnReuse, }); return csrfToken; }; @@ -124,15 +142,50 @@ export function doubleCsrf({ const validateCsrfToken: CsrfTokenValidator = (req, possibleSecrets) => { const csrfTokenFromCookie = getCsrfTokenFromCookie(req); const csrfTokenFromRequest = getCsrfTokenFromRequest(req); + const isCsrfTokenFromCookieAString = typeof csrfTokenFromCookie === "string"; + const isCsrfTokenFromRequestAString = typeof csrfTokenFromRequest === "string"; + + if (!(isCsrfTokenFromCookieAString && isCsrfTokenFromRequestAString)) { + internalLogger({ + logType: CSRF_LOG_EVENTS.CSRF_TOKEN_MISSING, + request: req, + isCsrfTokenFromCookieAString, + isCsrfTokenFromRequestAString, + }); + return false; + } - if (typeof csrfTokenFromCookie !== "string" || typeof csrfTokenFromRequest !== "string") return false; - - if (csrfTokenFromCookie === "" || csrfTokenFromRequest === "" || csrfTokenFromCookie !== csrfTokenFromRequest) + const isCsrfTokenFromCookieEmpty = csrfTokenFromCookie === ""; + const isCsrfTokenFromRequestEmpty = csrfTokenFromRequest === ""; + const isCsrfTokenEqual = csrfTokenFromCookie === csrfTokenFromRequest; + + if (isCsrfTokenFromCookieEmpty || isCsrfTokenFromRequestEmpty || !isCsrfTokenEqual) { + internalLogger({ + logType: CSRF_LOG_EVENTS.REQUEST_CONTENT_INVALID, + request: req, + isCsrfTokenFromCookieEmpty, + isCsrfTokenFromRequestEmpty, + isCsrfTokenEqual, + }); return false; + } const [receivedHmac, randomValue] = csrfTokenFromCookie.split(csrfTokenDelimiter); - if (typeof receivedHmac !== "string" || typeof randomValue !== "string" || randomValue === "") return false; + const isReceivedHmacAString = typeof receivedHmac === "string"; + const isRandomValueAString = typeof randomValue === "string"; + const isRandomValueEmpty = randomValue === ""; + + if (!(isReceivedHmacAString && isRandomValueAString) || isRandomValueEmpty) { + internalLogger({ + logType: CSRF_LOG_EVENTS.CSRF_TOKEN_CONTENT_INVALID, + request: req, + isReceivedHmacAString, + isRandomValueAString, + isRandomValueEmpty, + }); + return false; + } // The reason it's safe for us to only validate the hmac and random value from the cookie here // is because we've already checked above whether the token in the cookie and the token provided @@ -143,6 +196,7 @@ export function doubleCsrf({ if (receivedHmac === hmacForSecret) return true; } + internalLogger({ logType: CSRF_LOG_EVENTS.CSRF_TOKEN_INVALID, request: req }); return false; }; diff --git a/src/types.ts b/src/types.ts index 23e0231..dd87904 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import type { CookieOptions, NextFunction, Request, Response } from "express"; import type { HttpError } from "http-errors"; +import type { CSRF_LOG_EVENTS } from "./constants"; export type SameSiteType = boolean | "lax" | "strict" | "none"; export type TokenRetriever = (req: Request) => string | null | undefined; @@ -41,6 +42,46 @@ export type GenerateCsrfTokenConfig = { cookieOptions: CsrfTokenCookieOptions; }; export type GenerateCsrfTokenOptions = Partial; +export type CsrfTokenGeneratedLogArgs = { + logType: typeof CSRF_LOG_EVENTS.CSRF_TOKEN_GENERATED; + cookieOptions: CsrfTokenCookieOptions; + generatedNewToken: boolean; + overwrite: boolean; + validateOnReuse: boolean; +}; +export type CsrfTokenInvalidLogArgs = { logType: typeof CSRF_LOG_EVENTS.CSRF_TOKEN_INVALID }; +export type CsrfRequestContentInvalidLogArgs = { + logType: typeof CSRF_LOG_EVENTS.REQUEST_CONTENT_INVALID; + isCsrfTokenFromCookieEmpty: boolean; + isCsrfTokenFromRequestEmpty: boolean; + isCsrfTokenEqual: boolean; +}; +export type CsrfTokenContentInvaldLogArgs = { + logType: typeof CSRF_LOG_EVENTS.CSRF_TOKEN_CONTENT_INVALID; + isReceivedHmacAString: boolean; + isRandomValueAString: boolean; + isRandomValueEmpty: boolean; +}; +export type CsrfTokenInvalidReuseLogArgs = { + logType: typeof CSRF_LOG_EVENTS.CSRF_TOKEN_INVALID_REUSE; + overwrite: boolean; + validateOnReuse: boolean; +}; +export type CsrfTokenMissingLogArgs = { + logType: typeof CSRF_LOG_EVENTS.CSRF_TOKEN_MISSING; + isCsrfTokenFromCookieAString: boolean; + isCsrfTokenFromRequestAString: boolean; +}; +export type CsrfLoggerArgs = { request: Request } & ( + | CsrfTokenGeneratedLogArgs + | CsrfTokenInvalidLogArgs + | CsrfRequestContentInvalidLogArgs + | CsrfTokenContentInvaldLogArgs + | CsrfTokenInvalidReuseLogArgs + | CsrfTokenMissingLogArgs +); +export type CsrfLogger = (logArgs: CsrfLoggerArgs) => void; + export interface DoubleCsrfConfig { /** * A function that returns a secret or an array of secrets. @@ -160,6 +201,8 @@ export interface DoubleCsrfConfig { * ``` */ skipCsrfProtection: (req: Request) => boolean; + + logger?: CsrfLogger; } export interface DoubleCsrfUtilities {