构建全栈 Web 应用程序时,客户端和服务器之间的通信面临不同漏洞的风险,例如 XSS(跨站脚本)、 CSRF(跨站请求)伪造)和令牌劫持。作为 Web 开发人员,了解此类漏洞以及如何预防它们非常重要。
由于我也在尝试学习和预防 API 中的此漏洞,因此这些指南也是我创建本文的参考,这些都值得一读:
首先,我们定义一下前面提到的三个漏洞。
根据 OWASP.org
跨站脚本(XSS)攻击是一种注入,其中恶意脚本被注入到其他良性且受信任的网站中。当攻击者使用 Web 应用程序向不同的最终用户发送恶意代码(通常以浏览器端脚本的形式)时,就会发生 XSS 攻击。导致这些攻击成功的缺陷相当普遍,并且发生在 Web 应用程序在其生成的输出中使用用户输入而未对其进行验证或编码的任何地方。
根据 OWASP.org
跨站请求伪造 (CSRF) 是一种攻击,它迫使最终用户在当前经过身份验证的 Web 应用程序上执行不需要的操作。在社会工程的帮助下(例如通过电子邮件或聊天发送链接),攻击者可能会诱骗 Web 应用程序的用户执行攻击者选择的操作。如果受害者是普通用户,成功的 CSRF 攻击可以迫使用户执行状态更改请求,例如转移资金、更改电子邮件地址等。如果受害者是管理帐户,CSRF 可以危害整个 Web 应用程序。
根据 JWT Cheatsheet
当攻击者拦截/窃取令牌并使用目标用户身份访问系统时,就会发生这种攻击。
当我开始使用 Angular 和 Laravel 创建全栈应用程序时。我使用 JSON Web Tokens (JWT) 进行身份验证,它很容易使用,但如果实施不当也很容易被利用。我常犯的错误是:
本地存储是一种常见的选择,因为它可以轻松地从 JavaScript 检索和访问,它也是持久的,这意味着每当选项卡或浏览器关闭时它都不会删除,这使得它非常容易受到 跨站点的攻击脚本 (XSS) 攻击。
如果 XSS 攻击将以下内容注入您的网站:
console.log(localStorage.getItem('jwt_token'));
JWT 有一个 TTL,如果在 Laravel 中没有正确配置,默认情况下,令牌会设置为 3600 秒(1 小时),为黑客提供了公开且广泛的机会来窃取令牌并使用它充当受害者直到令牌过期。
刷新令牌允许用户获取新的访问令牌而无需重新进行身份验证。 TTL 在 token 中起着至关重要的作用,前面提到过,较长的 TTL 存在安全风险,但较短的 TTL 会带来糟糕的用户体验,迫使他们重新登录。
我们将创建一个基本的React Express 应用程序来应用和缓解这些漏洞。为了更好地理解我们将要执行的应用程序的输出,请参阅下图。
身份验证时,用户将发送用户名和密码并将其 POST 到 /login API 进行验证。登录后,服务器将:
验证数据库中的凭据
JSON 格式的用户凭证将在数据库中检查以进行身份验证。
生成用户指纹
为已验证的用户生成随机字节指纹并将其存储在变量中。
哈希指纹
生成的指纹将被散列并存储在不同的变量中。
为生成的指纹(原始指纹)创建 Cookie
未散列的指纹将设置在名为 __Secure_Fgp 的硬化 cookie 中,其标志为:httpOnly、secure、sameSite=Strict 和 maxAge 为 15 分钟。
Creating a token for the User credentials with the Hashed Fingerprint
Generating a JWT token for the verified user with its hashed fingerprint.
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.
When an authenticated user accessed the protected route. A middleware will verify the cookies of the user.
Fetching cookies
The middleware of the server will fetch the 2 cookies from the client upon request.
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.)
Hash the __Secure_Fgp cookie and compare it to the fingerprint from the payload JWT token.
Now for the implementation
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
npm init -y //Initate a node project // Installing dependencies npm install express nodemon jsonwebtoken csurf crypto cookie-parser cors
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" },
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.
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 }); });
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.
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…
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.
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 });
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);
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 . */
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.
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
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!
以上是使用 Express 中的 JWT 和指纹 Cookie 防止 CSRF 和 XSS 攻击的详细内容。更多信息请关注PHP中文网其他相关文章!