ホームページ  >  記事  >  ウェブフロントエンド  >  Express の JWT およびフィンガープリント Cookie を使用した CSRF および XSS 攻撃の防止

Express の JWT およびフィンガープリント Cookie を使用した CSRF および XSS 攻撃の防止

Linda Hamilton
Linda Hamiltonオリジナル
2024-10-01 22:22:02282ブラウズ

フルスタック Web アプリケーションを構築する場合、クライアントとサーバー間の通信は、XSS (クロスサイト スクリプティング)、 CSRF (クロスサイト リクエスト) などのさまざまな脆弱性によって危険にさらされます。 Web 開発者として、このような脆弱性とその防止方法を知ることは非常に重要です。

私も API のこの脆弱性を学習して防止しようとしているので、これらのガイドはこの記事を作成する際の参考にもなり、すべて読む価値があります。

  • フロントエンドで JWT を処理するための究極のガイド
  • Java 用の JSON Web トークンのチートシート
  • OWASP Web トークンのサイドジャッキング

まず、前述した 3 つの脆弱性を定義しましょう。

XSS (クロスサイト スクリプティング)

OWASP.org によると

クロスサイト スクリプティング (XSS) 攻撃は、インジェクションの一種であり、悪意のあるスクリプトが、それ以外は無害で信頼できる Web サイトに挿入されます。 XSS 攻撃は、攻撃者が Web アプリケーションを使用して、通常はブラウザ側スクリプトの形式で悪意のあるコードを別のエンド ユーザーに送信するときに発生します。これらの攻撃を成功させる欠陥は非常に広範囲に広がっており、Web アプリケーションが検証やエンコードを行わずに生成する出力内でユーザーからの入力を使用するあらゆる場所で発生します。

CSRF (クロスサイトリクエストフォージェリ)

OWASP.org によると

クロスサイト リクエスト フォージェリ (CSRF) は、エンド ユーザーが現在認証されている Web アプリケーション上で望ましくないアクションの実行を強制する攻撃です。攻撃者は、ソーシャル エンジニアリング (電子メールやチャット経由でリンクを送信するなど) を少し利用することで、Web アプリケーションのユーザーをだまして、攻撃者が選択したアクションを実行させる可能性があります。被害者が通常のユーザーの場合、CSRF 攻撃が成功すると、ユーザーは資金の送金や電子メール アドレスの変更などの状態変更リクエストの実行を強制される可能性があります。被害者が管理者アカウントの場合、CSRF は Web アプリケーション全体を侵害する可能性があります。

トークンのサイドジャッキング

JWT チートシートによると

この攻撃は、トークンが攻撃者によって傍受/盗まれ、攻撃者がそれを使用して、ターゲットのユーザー ID を使用してシステムにアクセスするときに発生します。


私が Angular と Laravel を使用してフルスタック アプリケーションを作成し始めたとき。認証には JSON Web Token (JWT) を使用しました。これは使いやすいですが、適切に実装されていないと悪用されやすくなります。私が犯しがちな間違いは次のとおりです:

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 に POST して検証します。ログインすると、サーバーは次のことを行います:

  1. データベース内の認証情報を確認します

    認証のために、JSON 形式のユーザー資格情報がデータベースでチェックされます。

  2. ユーザー指紋の生成

    検証されたユーザーのランダムなバイト フィンガープリントを生成し、それを変数に保存します。

  3. 指紋をハッシュ化します

    生成されたフィンガープリントはハッシュされ、別の変数に保存されます。

  4. 生成されたフィンガープリント (オリジナルのフィンガープリント) の Cookie の作成

    ハッシュ化されていないフィンガープリントは、フラグ httpOnly、secure、sameSite=Strict および 15 分の maxAge を持つ __Secure_Fgp という名前の強化された Cookie に設定されます。

  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 およびフィンガープリント Cookie を使用した CSRF および XSS 攻撃の防止の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。