>  기사  >  웹 프론트엔드  >  Express에서 JWT 및 지문 쿠키를 사용하여 CSRF 및 XSS 공격 방지

Express에서 JWT 및 지문 쿠키를 사용하여 CSRF 및 XSS 공격 방지

Linda Hamilton
Linda Hamilton원래의
2024-10-01 22:22:02282검색

풀 스택 웹 애플리케이션을 구축할 때 클라이언트와 서버 간의 통신은 XSS(Cross-Site Scripting), CSRF(Cross-Site Request)와 같은 다양한 취약점으로 인해 위험에 처해 있습니다. 위조) 및 토큰 사이드재킹. 웹 개발자로서 이러한 취약점과 이를 방지하는 방법을 아는 것은 매우 중요합니다.

저는 API의 취약점을 배우고 예방하려고 노력하고 있으므로 이 가이드는 이 기사를 작성하는 데 참고 자료이기도 하며 모두 읽어 볼 가치가 있습니다.

  • 프런트엔드에서 JWT를 처리하기 위한 최종 가이드
  • Java용 JSON 웹 토큰 치트 시트
  • OWASP 웹 토큰 사이드재킹

먼저 앞서 언급한 세 가지 취약점을 정의해 보겠습니다.

XSS(교차 사이트 스크립팅)

OWASP.org에 따르면

교차 사이트 스크립팅(XSS) 공격은 일종의 주입으로, 악성 스크립트를 무해하고 신뢰할 수 있는 웹사이트에 삽입합니다. XSS 공격은 공격자가 웹 애플리케이션을 사용하여 일반적으로 브라우저 측 스크립트 형태의 악성 코드를 다른 최종 사용자에게 보낼 때 발생합니다. 이러한 공격을 성공시키는 결함은 상당히 널리 퍼져 있으며 웹 애플리케이션이 생성한 출력 내에서 유효성 검사나 인코딩 없이 사용자의 입력을 사용하는 모든 곳에서 발생합니다.

CSRF(Cross-Site Request Forgery)

OWASP.org에 따르면

교차 사이트 요청 위조(CSRF)는 최종 사용자가 현재 인증된 웹 애플리케이션에서 원치 않는 작업을 실행하도록 강제하는 공격입니다. 공격자는 약간의 사회 공학적 도움(예: 이메일이나 채팅을 통해 링크 전송)을 사용하여 웹 애플리케이션 사용자를 속여 공격자가 선택한 작업을 실행하도록 할 수 있습니다. 피해자가 일반 사용자인 경우 CSRF 공격이 성공하면 사용자는 자금 이체, 이메일 주소 변경 등과 같은 상태 변경 요청을 수행해야 할 수 있습니다. 피해자가 관리 계정인 경우 CSRF는 전체 웹 애플리케이션을 손상시킬 수 있습니다.

토큰 사이드재킹

JWT 치트시트에 따르면

이 공격은 공격자가 토큰을 가로채거나 도난당하고 이를 사용하여 대상 사용자 ID를 사용하여 시스템에 액세스할 때 발생합니다.


Angular와 Laravel을 사용하여 Full-Stack 애플리케이션 만들기를 시작했을 때입니다. 인증을 위해 JWT(JSON Web Tokens)를 사용했는데, 사용하기 쉽지만 제대로 구현되지 않으면 악용되기 쉽습니다. 제가 저지른 일반적인 실수는 다음과 같습니다.

1. 로컬 스토리지에 토큰 저장

로컬 저장소는 JavaScript에서 쉽게 검색하고 액세스할 수 있기 때문에 일반적으로 선택됩니다. 또한 영구적이므로 탭이나 브라우저를 닫을 때마다 삭제되지 않으며 교차 사이트에 매우 취약합니다. 스크립팅(XSS) 공격.

예:

XSS 공격으로 인해 사이트에 다음이 삽입되는 경우:

console.log(localStorage.getItem('jwt_token'));

2. 토큰의 긴 TTL(Time to Live)

JWT에는 TTL이 있으며 Laravel에서 제대로 구성되지 않은 경우 기본적으로 토큰은 3600초(1시간)로 설정되어 해커가 토큰을 훔치고 피해자로 행동하는 데 사용할 수 있는 공개적이고 광범위한 기회를 제공합니다. 토큰이 만료될 때까지.

3. 새로고침 토큰 없음

새로 고침 토큰을 사용하면 사용자가 재인증할 필요 없이 새 액세스 토큰을 얻을 수 있습니다. TTL은 토큰에서 중요한 역할을 하며, 앞서 언급한 것처럼 TTL이 길면 보안 위험이 있지만, TTL이 짧을수록 사용자 경험이 나빠져서 다시 로그인해야 합니다.


이러한 취약점을 적용하고 완화하기 위해 기본 React Express 애플리케이션을 만들겠습니다. 우리가 수행할 애플리케이션의 출력을 더 잘 이해하려면 아래 다이어그램을 참조하십시오.

입증

인증 시 사용자는 사용자 이름과 비밀번호를 전송하고 이를 /login API에 게시하여 확인합니다. 로그인하면 서버는 다음을 수행합니다.

  1. 데이터베이스에서 자격 증명 확인

    인증을 위해 JSON 형식의 사용자 자격 증명을 데이터베이스에서 확인합니다.

  2. 사용자 지문 생성

    인증된 사용자에 대해 랜덤 바이트 지문을 생성하여 변수에 저장합니다.

  3. 지문 해시

    생성된 지문은 해시되어 다른 변수에 저장됩니다.

  4. 생성된 지문(원본 지문)에 대한 쿠키 생성

    해시되지 않은 지문은 15분의 httpOnly, secure, sameSite=StrictmaxAge 플래그와 함께 __Secure_Fgp라는 이름의 강화된 쿠키에 설정됩니다.

  5. Creating a token for the User credentials with the Hashed Fingerprint

    Generating a JWT token for the verified user with its hashed fingerprint.

  6. Creating a cookie for the token

    After generating JWT token, the token will be sent as a cookie.

After the process, there will be 2 cookies will be sent, the original fingerprint of the user, and the generated token containing the data with the hashed fingerprint of the user.
Preventing CSRF and XSS Attacks with JWT and Fingerprint Cookies in Express

Accessing the protected route

When an authenticated user accessed the protected route. A middleware will verify the cookies of the user.

  1. Fetching cookies

    The middleware of the server will fetch the 2 cookies from the client upon request.

  2. Verify the JWT

    Using JWT token, it will verify the token from the fetched cookie. Extract the data inside the JWT (e.g. User details, fingerprint etc.)

  3. Hash the __Secure_Fgp cookie and compare it to the fingerprint from the payload JWT token.

Preventing CSRF and XSS Attacks with JWT and Fingerprint Cookies in Express

Now for the implementation


Server-Side (Express JS)

Here are all the libraries that we need:

  • jsonwebtoken

    For generating, signing and verifying JWT Tokens

  • crypto

    To generate random bytes and hashing fingerprints

  • cookie-parser

    For parsing Cookie header and creating cookies

  • cors

    Configuring CORS policy

  • csurf

    Generating CSRF Tokens

Set up

npm init -y //Initate a node project
// Installing dependencies
npm install express nodemon jsonwebtoken csurf crypto cookie-parser cors

Create a server.js file

Create a server.js file and edit the package.json, write "start": "nodemon server.js" under the scripts object.

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon server.js"
  },

Set up the server

Since we are using JWT, we’re gonna need a HMAC key

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const cookieParser = require('cookie-parser');
const cors = require('cors')
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

const app = express();

// MIDDLEWARES ======================================================
// Middleware to parse JSON bodies and Cookies
app.use(express.json());
app.use(cookieParser());

// Middleware to parse URL-encoded bodies (as sent by HTML forms)
app.use(express.urlencoded({ extended: true }));

// Middleware to apply CORS
const corsOptions = {
    origin: 'http://localhost:5173',  // Your React app's URL
    credentials: true  // Allow credentials (cookies, authorization headers)
};
app.use(cors(corsOptions));

const keyHMAC = crypto.randomBytes(64);  // HMAC key for JWT signing

// API ======================================================

// we'll add our routes here

// Start the Express server
app.listen(3000, () => {
    console.log('Server running on https://localhost:3000');
});

After setting up the Express server, we can start by creating our /login API.

Create a login API

I did not used database for this project, but feel free to modify the code.

app.post('/login', csrfProtection, (req, res) => {
        // Fetch the username and password from the JSON
    const { username, password } = req.body;

    // Mock data from the database
    // Assuming the user is registered in the database
    const userId = crypto.randomUUID();
    const user = {
        'id': userId,
        'username': username,
        'password': password,
    }

    res.status(200).json({
        message: 'Logged in successfully!',
        user: user
    });
});

Generate a user fingerprint

Assuming that the user is registered in the database, First, we’re gonna need two functions, one for generating a random fingerprint and hashing the fingerprint.

/* 
.
. ... other configurations
.
*/
const keyHMAC = crypto.randomBytes(64);  // HMAC key for JWT signing

// Utility to generate a secure random string
const generateRandomFingerprint = () => {
    return crypto.randomBytes(50).toString('hex');
};

// Utility to hash the fingerprint using SHA-256
const hashFingerprint = (fingerprint) => {
    return crypto.createHash('sha256').update(fingerprint, 'utf-8').digest('hex');
};

As discussed earlier, we are going to generate a fingerprint for the user, hash that fingerprint and set it in a cookie with the name __Secure_Fgp..

Then generate a token with the user’s details (e.g. id, username and password) together with the original fingerprint, not the hashed one since we are going to use that for verification of the token later.

    const userId = crypto.randomUUID();
    const user = {
        'id': userId,
        'username': username,
        'password': password,
    }

    const userFingerprint = generateRandomFingerprint();  // Generate random fingerprint
    const userFingerprintHash = hashFingerprint(userFingerprint);  // Hash fingerprint

    // Set the fingerprint in a hardened cookie
    res.cookie('__Secure_Fgp', userFingerprint, {
        httpOnly: true,
        secure: true,  // Send only over HTTPS
        sameSite: 'Strict',  // Prevent cross-site request
        maxAge: 15 * 60 * 1000  // Cookie expiration (15 minutes)
    });

    const token = jwt.sign(
        {
            sub: userId,  // User info (e.g., ID)
            username: username,
            password: password,
            userFingerprint: userFingerprintHash,  // Store the hashed fingerprint in the JWT
            exp: Math.floor(Date.now() / 1000) + 60 * 15  // Token expiration time (15 minutes)
        },
        keyHMAC // Signed jwt key
    );

    // Send JWT as a cookie
    res.cookie('token', token, {
        httpOnly: true,
        secure: true,
        sameSite: 'Strict',
        maxAge: 15 * 60 * 1000
    });

    res.status(200).json({
        message: 'Logged in successfully!',
        user: user
    });
});

After log in, it will pass two cookies, token and __Secure_Fgp which is the original fingerprint, into the front end.

Create middleware for authenticating of token

To validate that, we are going to create a middleware for our protected route. This middleware will fetch the two cookies first and validate, if there are no cookies sent, then it will be unauthorized.

If the token that was fetched from the cookie is not verified, malformed or expired, it will be forbidden for the user to access the route.

and lastly, it will hash the fingerprint from the fetched cookie and verify it with the hashed one.

// Middleware to verify JWT and fingerprint match
const authenticateToken = (req, res, next) => {
    const token = req.cookies.token;
    const fingerprintCookie = req.cookies.__Secure_Fgp;

    if (!token || !fingerprintCookie) {
        return res.status(401).json({
            status: 401,
            message: "Error: Unauthorized",
            desc: "Token expired"
        });  // Unauthorized
    }

    jwt.verify(token, keyHMAC, (err, payload) => {
        if (err) return res.status(403).json({
            status: 403,
            message: "Error: Forbidden",
            desc: "Token malformed or modified"
        });  // Forbidden

        const fingerprintHash = hashFingerprint(fingerprintCookie);

        // Compare the hashed fingerprint in the JWT with the hash of the cookie value
        if (payload.userFingerprint !== fingerprintHash) {
            return res.status(403).json({
                status: 403,
                message: "Forbidden",
                desc: "Fingerprint mismatch"
            });  // Forbidden - fingerprint mismatch
        }

        // Return the user info
        req.user = payload;
        next();
    });
};

To use this middleware we are going to create a protected route. This route will return the user that we fetched from the verified token in our middleware.

/* 
.
. ... login api
.
*/

// Protected route
app.get('/protected', authenticateToken, (req, res) => {
    res.json({ message: 'This is a protected route', user: req.user });
});

// Start the Express server
app.listen(3000, () => {
    console.log('Server running on https://localhost:3000');
});

With all of that set, we can now try it on our front end…


Client-Side integration (React + TS)

For this, I used some dependencies for styling. It does not matter what you used, the important thing is that we need to create a form that will allow the user to login.

I will not create a step by step in building a form, instead, I will just give the gist of the implementation for the client side.

In my React app, I used shadcn.ui for styling.

// App.tsx
<section className="h-svh flex justify-center items-center">
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 p-7 rounded-lg w-96 border border-white">
            <h1 className="text-center font-bold text-xl">Welcome</h1>
            <FormField
              control={form.control}
              name="username"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Username</FormLabel>
                  <FormControl>
                    <Input placeholder="Username" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input type="password" placeholder="Password" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit" className="mr-4">Login</Button>
            <Link to={"/page"} className='py-2 px-4 rounded-lg bg-white font-medium text-black'>Go to page</Link>
          </form>
        </Form>
      </section>

This is a simple login form with a button that will navigate the user to the other page that will fetch the protected route.

When the user click submit, it will POST request to the /login API in our server. If the response is success, it will navigate to the page.

 // App.tsx
  const onSubmit = async (values: z.infer<typeof formSchema>) => {
    console.log(values)
    try {
      const res = await fetch("http://localhost:3000/login", {
        method: 'POST', // Specify the HTTP method
        headers: {
          'Content-Type': 'application/json', // Set content type
        },
        credentials: 'include', // Include cookies in the request
        body: JSON.stringify(values), // Send the form data as JSON
      });
      if (!res.ok) {
        throw new Error(`Response status: ${res.status}`)
      }
      const result = await res.json();
      navigate("/page") // navigate to the page
      console.log(result);
    } catch (error) {
      console.error(error);
    }
  }

In the other page, it will fetch the /protected API to simulate an authenticated session of the user.

const fetchApi = async () => {
        try {
            const res = await fetch("http://localhost:3000/protected", {
                method: 'GET', // Specify the HTTP method
                headers: {
                    'Content-Type': 'application/json', // Set content type
                },
                credentials: 'include', // Include cookies in the request
            });

            if (!res.ok) {
                // Throw error
                throw res
            }
            // Fetch the response
            const result = await res.json();
            setUser(result.user);
            console.log(result)
        } catch (error: any) {
            setError(true)
            setStatus(error.status)
        }
    }

Make sure to put credentials: ‘include’ in the headers to include cookies upon request.

To test, run the app and look into the Application tab of the browser.

// React
npm run dev

// Express
npm start

Under Application tab, go to cookies and you can see the two cookies that the server generated.
Preventing CSRF and XSS Attacks with JWT and Fingerprint Cookies in Express

Token is good for 15 mins, and after that the user will need to reauthenticate.

With this, you have the potential prevention of XSS (Cross-Site Scripting) and Token Sidejacking into your application. This might not guarantee a full protection but it reduces the risks by setting the cookie based on the OWASP Cheat sheet.

res.cookie('__Secure_Fgp', userFingerprint, {
    httpOnly: true,
    secure: true,  // Send only over HTTPS
    sameSite: 'Strict',  // Prevent cross-site request
    maxAge: 15 * 60 * 1000
});

How about Cross-Site Request Forgery (CSRF) prevention?

For the CSRF, we are going to tweak a few things on our server side using this:

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });

then we’ll add it to the middleware

// MIDDLEWARES ======================================================
// Middleware to parse JSON bodies and Cookies
app.use(express.json());
app.use(cookieParser());
// Middleware to parse URL-encoded bodies (as sent by HTML forms)
app.use(express.urlencoded({ extended: true }));

// Middleware to apply CORS
const corsOptions = {
    origin: 'http://localhost:5173',  // Your React app's URL
    credentials: true  // Allow credentials (cookies, authorization headers)
};
app.use(cors(corsOptions));

// Middleware to apply csrf protection
app.use(csrfProtection);

Create a CSRF Token API

For this we’ll need an API that will generate a CSRF Token and passed it as a cookie to the front end.

app.get('/csrf-token', (req, res) => { // Generate a CSRF token
    res.cookie('XSRF-TOKEN', req.csrfToken(), { // Sends token as a cookie
        httpOnly: false,
        secure: true,
        sameSite: 'Strict'
    });
    res.json({ csrfToken: req.csrfToken() });
});

Take note that this csrfProtection will only apply to the POST, PUT, DELETE requests, anything that will allow user to manipulate sensitive data. So for this, we’ll just secure our login endpoint with CSRF.

// Login route to generate JWT and set fingerprint
app.post('/login', csrfProtection, (req, res) => {
    const { username, password } = req.body;

    // Mock data from the database
    const userId = crypto.randomUUID();
    const user = {
        'id': userId,
        'username': username,
        'password': password,
    }

    /*
    .
    . other code
    .
    */

Generate the CSRF Token in the front end

We need to make a GET request to the /csrf-token API and save the token in our local storage.

// App.tsx
  useEffect(() => {
    const fetchCSRFToken = async () => {
      const res = await fetch('http://localhost:3000/csrf-token', {
        method: 'GET',
        credentials: 'include'  // Send cookies with the request
      });
      const data = await res.json();
      localStorage.setItem('csrfToken', data.csrfToken);
      setCsrfToken(data.csrfToken)
    };

    fetchCSRFToken();
  }, [])

I know, I know… we just talked about the security risk of putting tokens in a local storage. Since there are many ways to mitigate such attacks, common solution would be to refresh this token or just store it in the state variable. For now, we are going to store it in the local storage.

This will run when the component loads. everytime the user visits the App.tsx, it will generate a new CSRF Token.

Now since our /login API is protected with CSRF, we must include the CSRF-Token in the headers upon logging in.

  const onSubmit = async (values: z.infer<typeof formSchema>) => {
    console.log(values)
    try {
      const res = await fetch("http://localhost:3000/login", {
        method: 'POST', // Specify the HTTP method
        headers: {
          'Content-Type': 'application/json', // Set content type
          'CSRF-Token': csrfToken // adding the csrf token
        },
        credentials: 'include', // Include cookies in the request
        body: JSON.stringify(values), // Send the form data as JSON
      });
      if (!res.ok) {
        throw new Error(`Response status: ${res.status}`)
      }
      const result = await res.json();
      navigate("/page") // navigate to the page
      console.log(result);
    } catch (error) {
      console.error(error);
    }
  }

Now, when the App.tsx load, we can now see the Cookies in our browser.

Preventing CSRF and XSS Attacks with JWT and Fingerprint Cookies in Express

The XSRF-TOKEN is our generated token from the server, while the _csrf is the token generated by the csrfProtection = csrf({ cookie: true });

Here is the full code of the application.
https://github.com/Kurt-Chan/session-auth-practice

Conclusion

This might not give a full protection to your app but it reduce the risks of XSS and CSRF attacks in your website. To be honest, I am new to this integrations and still learning more and more about this.

If you have questions, feel free to ask!

THANK YOU FOR READING!

위 내용은 Express에서 JWT 및 지문 쿠키를 사용하여 CSRF 및 XSS 공격 방지의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.