Psifi-Solutions / csrf-csrf

A utility package to help implement stateless CSRF protection using the Double Submit Cookie Pattern in express.
Other
120 stars 18 forks source link

Token hash problem #53

Closed hoshixlily closed 8 months ago

hoshixlily commented 8 months ago

Description

I am getting "ForbiddenError: invalid csrf token".

I tried to debug the code and arrived at a part where the csrf cookie is split by the pipe character ("|") and the second part is supposed to be the hash value. However, the token I have does not seem to have this character, so the hash is always undefined, which causes validation to fail. How should one proceed so that the hashing does not fail?

const validateTokenAndHashPair = (token, hash, possibleSecrets) => {
    if (typeof token !== "string" || typeof hash !== "string") // <-- hash is undefined, so validation fails.
        return false;
    for (const secret of possibleSecrets) {
        const expectedHash = (0, crypto_1.createHash)("sha256")
            .update(`${token}${secret}`)
            .digest("hex");
        if (hash === expectedHash)
            return true;
    }
    return false;
};
const validateRequest = (req) => {
    const csrfCookie = getCsrfCookieFromRequest(req);
    if (typeof csrfCookie !== "string")
        return false;
    const [csrfToken, csrfTokenHash] = csrfCookie.split("|"); // <-- No pipe symbol in cookie string, therefore csrfTokeHash is undefined.
    const csrfTokenFromRequest = getTokenFromRequest(req);
    const getSecretResult = getSecret(req);
    const possibleSecrets = Array.isArray(getSecretResult)
        ? getSecretResult
        : [getSecretResult];
    return (csrfToken === csrfTokenFromRequest &&
        validateTokenAndHashPair(csrfTokenFromRequest, csrfTokenHash, possibleSecrets));
};

The csrfCookie is a string of 128 characters.

csrf-csrf: v3.0.3
NodeJS: 18.18.0
psibean commented 8 months ago

I am getting "ForbiddenError: invalid csrf token".

This means that the token you're providing in the header of the request you're making doesn't match the token hash in the cookie value when it's hashed on the backend. Are you providing the token in your frontend request? If you aren't then it will come back as undefined.

The cookie value consists of:

The original token (this is so stateless sessions can re-use the same value) a "|" (pipe) character, and then the hashed token.

The token is just... the token, which your frontend needs to include in the header (or in a way that your getTokenFromRequest retrieves it (but not from a cookie).

Then csrc-csrf will take the token value, hash it, and then compare that to the hash inside the cookie value.

I tried to debug the code and arrived at a part where the csrf cookie is split by the pipe character ("|") and the second part is supposed to be the hash value. However, the token I have does not seem to have this character, so the hash is always undefined,

The token shouldn't have the pipe character ("}"). The cookie value does, but the cookie value and the csrf token are not the same thing.

The code you've provided here is from the library itself, and this code is 100% working as intended.

It's not possible to help you without your own code samples and what you're doing.

  1. Please show your code where you're generating a token (via generateToken or req.csrfToken) and where you're providing that token to the frontend
  2. Please show the frontend code where you're receiving the generated token and including it in your request header
  3. Please share your specific doubleCsrfProtection initialization config options (hiding any secret values, of course)
hoshixlily commented 8 months ago

Hello, thank you for the detailed response. I have no doubt that the library works, I am just not very good with security related things.

Here is the initialization of doubeCsrf:

const csrf = doubleCsrf({
    getSecret: () => process.env.XSRF_SECRET,
    cookieName: process.env.NODE_ENV === "production" ? "__Host-XSRF-TOKEN" : "X-XSRF-TOKEN",
    cookieOptions: {
        sameSite: true,
        secure: process.env.NODE_ENV === "production",
        path: "/",
    },
    getTokenFromRequest: req => {
        const cookieName = process.env.NODE_ENV === "production" ? "__Host-XSRF-TOKEN" : "X-XSRF-TOKEN";
        const token = req.cookies[cookieName] as string;
        if (!token) {
            throw new Error("No XSRF token found");
        }
        return token;
    }
});
app.use(csrf.doubleCsrfProtection); // NestJS

Here is the part where I send the cookie to front end:

@Get("xsrf-token")
public async getXsrfToken(@Res() response: e.Response, @Req() request: e.Request): Promise<void> {
    const secure = this.configService.get("NODE_ENV") === "production";
    const xsrfCookieName = secure ? "__Host-XSRF-TOKEN" : "X-XSRF-TOKEN";
    response.cookie(xsrfCookieName, request.csrfToken(), {
        httpOnly: true,
        sameSite: true,
        secure,
        path: "/"
    });
    response.status(HttpStatus.OK).send();
}

And this is the front-end part (Angular interceptor):

export const xsrfInterceptor: HttpInterceptorFn = (req, next) => {
    const tokenExtractor = inject(HttpXsrfTokenExtractor);
    const headerName = environment.production ? "__Host-XSRF-TOKEN" : "X-XSRF-TOKEN";
    const token = tokenExtractor.getToken();
    if (token !== null && !req.headers.has(headerName)) {
        req = req.clone({ headers: req.headers.set(headerName, token) });
    }
    return next(req);
};
psibean commented 8 months ago

So this part is incorrect:

    getTokenFromRequest: req => {
        const cookieName = process.env.NODE_ENV === "production" ? "__Host-XSRF-TOKEN" : "X-XSRF-TOKEN";
        const token = req.cookies[cookieName] as string;
        if (!token) {
            throw new Error("No XSRF token found");
        }
        return token;
    }

First of all, as mentioned, the cookie contains a value like this: ${originalToken}|${hashedToken}, but getTokenFromRequest is supposed to return only the token value.

Secondly, if you return the token value from the cookie within getTokenFromRequest, then this is the same as having no CSRF protection.

As mentioned in the documentation, the links, and implicit in the name of the pattern: the double submit pattern works by having the value submitted to the backend via two different locations. The cookie is one of them.

You need to include the token value inside of a header or body payload, and getTokenFromRequest should return the token value from one of those places (depending on which you're using).

It looks like you're using Angular, and my understanding is, Angular by default assumes you're using the synchronised token pattern (via csrf-sync) and are transmitting the token value by cookie with that pattern (which is also not recommended).

To get this working with Angular, you'll need to make sure that you're sending the token (the return value of generateToken() or req.csrfToken() to your frontend, and then you'll need to write a custom interceptor which uses that value as the token.

This part here:

const token = tokenExtractor.getToken();

Is likely not working because the cookie is httpOnly - it cannot be accessed by the frontend.

You could also send the token in another cookie (which you then use in your existing interceptor config), which is not httpOnly, and that's fine too - however it can in some rare cases have it's own security drawbacks, but it is also a convenient way of doing it. Regarding the drawbacks, you can see the discussion here

This part isn't quite right either:

@Get("xsrf-token")
public async getXsrfToken(@Res() response: e.Response, @Req() request: e.Request): Promise<void> {
    const secure = this.configService.get("NODE_ENV") === "production";
    const xsrfCookieName = secure ? "__Host-XSRF-TOKEN" : "X-XSRF-TOKEN";
    response.cookie(xsrfCookieName, request.csrfToken(), {
        httpOnly: true,
        sameSite: true,
        secure,
        path: "/"
    });
    response.status(HttpStatus.OK).send();
}

All you need to do is call generateToken(req) or req.csrfToken() and csrf-csrf will generate a token and set the cookie for you.

So currently when you call request.csrfToken() this is setting up a cookie with the pipe and hashed value, then you're overriding that cookie with just the token value - which you shouldn't be doing. The reason your cookie value doesn't have the pipe is because you're overriding it and setting it yourself to an incorrect value. The documentation clearly states the token value should be sent to the cfrontend via a response payload (either inside of a template HTML response, or a json response, in the case of json, you should have your own logic that ensures the token is only usable by the session that requested it).

Additionally you aren't sending back the return value of request.csrfToken() which you'll need to do so you can include that value in the header in your interceptor.

99% of users are using the wrong kind of pattern, you're likely better off using csrf-sync if you have server side session state (which you will by default if using express-session).

hoshixlily commented 8 months ago

Thank you very much for your detailed answer. I was able to pinpoint my mistakes and it's now working without any problem.

I was under the impression that request.csrfToken() is everything I need and should be stored in a cookie, but of course the correct answer was in the library name itself. After preventing the cookie value being overridden, keeping the request.csrfToken() in memory and adding it to header, everything works properly now.

Thank you for your time once again.