kubetail-org / edge-csrf

CSRF protection library for JavaScript that runs on the edge runtime (with Next.js, SvelteKit, Express, Node-HTTP integrations)
MIT License
139 stars 7 forks source link

CSRF in NextJS with next-http-proxy-middleware conflict #57

Open mkoumila opened 1 month ago

mkoumila commented 1 month ago

Hello @amorey , I am currently facing an issue using @edge-csrf/node-http with next-http-proxy-middleware in my Next.js 14 project. My project is connected to a Drupal CMS backend. In brief, I have a form in my Next.js project that sends a POST request to Drupal.

Everything was working fine when I was using the CSRF protection from next-auth/react. However, after implementing @edge-csrf/node-http in my custom Node.js server within my Next.js project, I receive the following response instead of the actual data (with a 400 error) when sending a POST request:

array:1 [ "{"type":"Buffer","data":_100,111,99,117,109,101,110,116,95,99,118,61,38,99,115,114,102,95,116,111,107,101,110,61,65,65,104,83,116,67,51,8" => "" ]

Is there something I might have missed adding to my proxy code, such as an @edge-csrf functionality or configuration?

All my requests passes by this proxy:

// api/proxy
import httpProxyMiddleware from "next-http-proxy-middleware"
import { redisOffline } from "@custompackage/core/server"
import * as crypto from "crypto"
import { getServerSidePropsFlags } from "@custompackage/console/server"

if (process.env.DRUPAL_BASE_URL === undefined) {
    throw Error("DRUPAL BASE URL environment variable not specified!")
}

const handleProxyInit = (proxy) => {
    /**
     * Check the list of bindable events in the `http-proxy` specification.
     * @see https://www.npmjs.com/package/http-proxy#listening-for-proxy-events
     */
    proxy.on("proxyReq", () => {})
    proxy.on("proxyRes", async (proxyRes, req) => {
        if (req.method !== "GET") {
            return
        }

        if (
            proxyRes.headers["content-type"].includes("json") &&
            proxyRes.statusCode === 200
        ) {
            const uniqueReqId = crypto.createHash("sha512").update(req.url).digest("hex")
            const cacheKey = `apiproxy:${uniqueReqId}`

            var body = []
            proxyRes.on("data", function (chunk) {
                body.push(chunk)
            })

            proxyRes.on("end", async function () {
                body = Buffer.concat(body).toString()
                try {
                    // make sure to validate the JSON before setting it in the cache
                    JSON.parse(body)
                    await redisOffline.set(cacheKey, body)
                } catch (e) {
                    console.error(`Cannot parse JSON for ${req.url}`, e)
                }
            })
        }
    })
}

export default async function handler(req, res) {
    try {
        if (
            req.method === "GET" &&
            getServerSidePropsFlags().serverFlags.get("enableOffline")
        ) {
            const uniqueReqId = crypto
                .createHash("sha512")
                .update(req.url.replace(new RegExp(`^/api/proxy`, "i"), ""))
                .digest("hex")
            const cacheKey = `apiproxy:${uniqueReqId}`

            const cached = await redisOffline.get(cacheKey)

            if (cached) {
                return res.end(cached)
            }
        }

        // API resolved without sending a response for ..., this may result in stalled requests.
        return await httpProxyMiddleware(req, res, {
            onProxyInit: handleProxyInit,
            target: process.env.DRUPAL_BASE_URL,
            secure: false, // Don't verify the SSL Certs
            pathRewrite: [{ patternStr: `^/api/proxy`, replaceStr: "" }],
            followRedirects: true,
            headers: {
                cookie: "", // Must override the browser sent authorization code otherwise ingress gives a 400 status
            },
        })
    } catch (error) {
        console.error(error)
        res.status(500).json(error)
    }
}

export const config = {
    api: {
        bodyParser: false,
        externalResolver: true, // Prevents noise created by proxy
    },
}

Thank you for your assist

mkoumila commented 1 month ago

Here is a request/response data regarding sending a POST request with Proxy ( failed ) and Without it ( successful ):

PS: Some data has been changed to "bla bla bla" or fake tokens for security 🙏🏻

// REQUEST - No Proxy:

POST /fr/_webform HTTP/1.1
Accept: application/vnd.api+json
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9
Connection: keep-alive
Content-Length: 141
Content-Type: application/x-www-form-urlencoded
Host: localhost:8080
Origin: http://localhost:3000
Referer: http://localhost:3000/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
User-Agent: "Bla Bla Bla"
sec-ch-ua: "Bla Bla Bla"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

// REQUEST - with Proxy:

POST /api/proxy/fr/_webform HTTP/1.1
Accept: application/vnd.api+json
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9
Connection: keep-alive
Content-Length: 137
Content-Type: application/x-www-form-urlencoded
Cookie: _csrfSecret=pYsh6lqK0X54R4Blo1BAA8KP;
Host: localhost:3000
Origin: http://localhost:3000
Referer: http://localhost:3000/contact
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: "Bla Bla Bla"
sec-ch-ua: "Bla Bla Bla"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"

// RESPONSE - No Proxy:

HTTP/1.1 200 OK
Server: nginx/1.blabla.1
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
Vary: Accept-Encoding
Cache-Control: must-revalidate, no-cache, private
Date: Sat, 03 Aug 2024 23:36:22 GMT
Content-language: fr
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Expires: Sun, 19 Nov 1978 05:00:00 GMT
X-Consumer-ID: default_consumer
Vary: X-Consumer-ID
Access-Control-Allow-Origin: *
Content-Encoding: gzip

//
RESPONSE - with Proxy:

HTTP/1.1 400 Bad Request
X-CSRF-Token: AAiXzolJChmvYbrK7fAOYQvspCsr8yYU+S4pQ5PA
Content-Security-Policy: default-src 'none'; script-src 'self' 'unsafe-eval' 'unsafe-inline' style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; frame-src 'self'; connect-src 'self'; media-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';
X-DNS-Prefetch-Control: on
Strict-Transport-Security: "bla bla bla"
X-XSS-Protection: "bla bla bla"
x-frame-options: SAMEORIGIN
Permissions-Policy: camera=(), microphone=(), geolocation=()
x-content-type-options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
server: nginx/1.blabla.1
content-type: application/json
content-length: 75
connection: close
cache-control: must-revalidate, no-cache, private
date: Sat, 03 Aug 2024 23:37:54 GMT
content-language: fr
expires: Sun, 19 Nov 1978 05:00:00 GMT
x-consumer-id: default_consumer
vary: X-Consumer-ID
access-control-allow-origin: *
amorey commented 1 month ago

return await httpProxyMiddleware(req, res, { onProxyInit: handleProxyInit, target: process.env.DRUPAL_BASE_URL, secure: false, // Don't verify the SSL Certs pathRewrite: [{ patternStr: ^/api/proxy, replaceStr: "" }], followRedirects: true, headers: { cookie: "", // Must override the browser sent authorization code otherwise ingress gives a 400 status }, })

It looks like the request to httpProxyMiddleware() isn't forwarding cookies from the client so all requests will fail validation. Can you try forwarding the csrf cookie and see if that fixes the problem?

mkoumila commented 1 month ago

Hi @amorey , i remove this line yet i still get the same error:

headers: {
      cookie: ""
},

The problem to be specific is that my form payload on POST request is:

csrf_token: AAgSqrQ/8R2JonL8SIczZmmACaxjNM2R5ebIjdPT
name: myname
webform_id: form_test

and the error is get is that webform_id is null even thou it's present in the payload ! but when i remove CSRF it works just fine.

It might be a conflict between CSRF and Http Proxy i guess since the data i receive in the backoffice is a Buffer!

To fix this issues, Would you please @amorey give us the detailed steps on how CSRF get validated from the creating of it ? This would be super helpful !

Thanks

amorey commented 1 month ago

Here's the code block that gets the token from the request (getTokenString()) and passes it to the verification function (verifyToken()): https://github.com/kubetail-org/edge-csrf/blob/main/shared/src/protect.ts#L136-L142

Can you share a minimal example that demonstrates the error?

mkoumila commented 1 month ago

Thanks @amorey,

I wish i could, but it's a company project ( for a client ) and it's huge, making a minimal example would take a lot because it has multiple functionalities and packages 🙏🏻 Somehow i understand what is the problem i'll debug more to fix it.

I appreciate your help buddy 😄

mkoumila commented 2 weeks ago

FIX: https://github.com/kubetail-org/edge-csrf/pull/59