CSRF
Introduction to CSRF
What is CSRF?
Cross-Site Request Forgery (CSRF) is a type of attack where an attacker tricks a victim’s browser into sending unauthorized requests to a web application where the victim is authenticated. The attacker exploits the trust that the web application has in the victim’s browser, leveraging the fact that browsers automatically include credentials (e.g., cookies, session tokens) in requests to the target site.
For example, imagine a user is logged into their banking website, and they visit a malicious site in another tab. The malicious site could send a request to the banking site (e.g., to transfer money) without the user’s knowledge. Since the browser includes the user’s session cookie, the banking site processes the request as if it came from the legitimate user.
Why is CSRF Dangerous?
CSRF attacks are dangerous because they:
- Exploit authenticated sessions, allowing attackers to perform actions on behalf of users without their consent.
- Can lead to severe consequences, such as unauthorized fund transfers, account modifications, or data leaks.
- Are difficult for users to detect, as the attack occurs silently in the background.
- Target common web application features like form submissions, API endpoints, or state-changing requests.
Goals of This Tutorial
This tutorial aims to:
- Explain the mechanics of CSRF attacks in detail.
- Demonstrate how to implement CSRF protection in Node.js applications using Express.
- Provide step-by-step code examples with thorough analysis.
- Cover advanced topics, such as CSRF token management, edge cases, and performance considerations.
- Highlight best practices and common mistakes to avoid.
- Include practical exercises to reinforce learning.
Understanding CSRF Attacks
How CSRF Works
To understand CSRF, let’s break down the attack flow:
- User Authentication: A user logs into a legitimate web application (e.g.,
bank.com), and the server issues a session cookie that the browser stores. - Malicious Interaction: The user visits a malicious website (e.g.,
evil.com) or clicks a malicious link while still authenticated withbank.com. - Forged Request: The malicious site sends a request (e.g., a POST to
bank.com/transfer) to the target application. This could be triggered via:
- A hidden form that auto-submits using JavaScript.
- An
<img>tag with a craftedsrcattribute. - An AJAX request initiated by JavaScript.
- Browser Behavior: The browser, unaware of the malicious intent, includes the user’s session cookie in the request to
bank.com. - Server Processing: The server, trusting the session cookie, processes the request as if it came from the user, performing the action (e.g., transferring money).
Example of a CSRF Attack
Consider a banking application with a form to transfer money:
<!-- Form on bank.com -->
<form action="/transfer" method="POST">
<input type="text" name="amount" value="1000" />
<input type="text" name="toAccount" value="123456" />
<button type="submit">Transfer</button>
</form>The server processes this form via a POST endpoint:
app.post('/transfer', (req, res) => {
const { amount, toAccount } = req.body;
// Perform transfer logic
res.send('Transfer successful');
});An attacker creates a malicious page on evil.com:
<!-- Malicious page on evil.com -->
<form action="https://bank.com/transfer" method="POST" id="maliciousForm">
<input type="hidden" name="amount" value="10000" />
<input type="hidden" name="toAccount" value="attackerAccount" />
</form>
<script>
document.getElementById('maliciousForm').submit();
</script>If the user is logged into bank.com and visits evil.com, the form auto-submits, sending a POST request to bank.com/transfer with the attacker’s parameters. The browser includes the user’s session cookie, and the server processes the transfer, unaware it was unauthorized.
Conditions for a Successful CSRF Attack
A CSRF attack requires:
- The target application relies on cookies or other automatically included credentials for authentication.
- The application has state-changing endpoints (e.g., POST, PUT, DELETE) that don’t validate request origins.
- The user is authenticated with the target application during the attack.
- The attacker can predict or craft the request parameters (e.g., form fields).
CSRF vs. XSS
CSRF is often confused with Cross-Site Scripting (XSS), but they are distinct:
- CSRF: Exploits trust in the user’s authenticated session to send unauthorized requests. No code execution in the target application is required.
- XSS: Injects malicious scripts into the target application, running code in the user’s browser. XSS can be used to facilitate CSRF by dynamically crafting requests.
CSRF Protection Mechanisms
To prevent CSRF attacks, applications must ensure that requests are intentionally sent by the authenticated user. Common protection mechanisms include:
- CSRF Tokens: Include a unique, unpredictable token in each state-changing request, validated by the server.
- SameSite Cookies: Configure cookies to restrict cross-site requests.
- Origin and Referer Headers: Verify the request’s origin to ensure it comes from a trusted source.
- Custom Headers: Require headers (e.g.,
X-Requested-With) that attackers cannot set in cross-site requests. - User Interaction: Require user confirmation (e.g., re-authentication) for sensitive actions.
This tutorial focuses primarily on CSRF tokens, as they are the most widely used and robust solution, but we’ll also cover SameSite cookies and other techniques.
Setting Up a Node.js Application
Let’s create a Node.js application using Express to demonstrate CSRF protection. We’ll build a simple banking application with a transfer feature and progressively add CSRF security.
Prerequisites
Ensure you have:
- Node.js (version 14 or later) installed.
- A code editor (e.g., VS Code).
- Basic knowledge of Express and HTTP.
Initialize the Project
Create a new directory and initialize a Node.js project:
mkdir node-csrf-demo
cd node-csrf-demo
npm init -yInstall dependencies:
npm install express express-session csurf cookie-parser body-parser- express: Web framework for Node.js.
- express-session: Manages user sessions.
- csurf: CSRF protection middleware.
- cookie-parser: Parses cookies.
- body-parser: Parses incoming request bodies (e.g., form data).
Basic Application Setup
Create app.js with a simple Express server:
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const path = require('path');
const app = express();
// Middleware
app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(
session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: { secure: false }, // Set to true in production with HTTPS
})
);
// Set view engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Mock user authentication (for demo purposes)
app.use((req, res, next) => {
req.session.user = { id: 1, username: 'testuser' }; // Simulate logged-in user
next();
});
// Routes
app.get('/', (req, res) => {
res.render('index', { user: req.session.user });
});
app.get('/transfer', (req, res) => {
res.render('transfer');
});
app.post('/transfer', (req, res) => {
const { amount, toAccount } = req.body;
// Mock transfer logic
console.log(`Transferring ${amount} to account ${toAccount}`);
res.send('Transfer successful');
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});Create a views directory with two EJS templates:
views/index.ejs:
<!DOCTYPE html>
<html>
<head>
<title>Banking App</title>
</head>
<body>
<h1>Welcome, <%= user.username %></h1>
<a href="/transfer">Make a Transfer</a>
</body>
</html>views/transfer.ejs:
<!DOCTYPE html>
<html>
<head>
<title>Transfer Money</title>
</head>
<body>
<h1>Transfer Money</h1>
<form action="/transfer" method="POST">
<label>Amount:</label>
<input type="text" name="amount" value="1000" />
<label>To Account:</label>
<input type="text" name="toAccount" value="123456" />
<button type="submit">Transfer</button>
</form>
</body>
</html>Run the application:
node app.jsVisit http://localhost:3000 to see the homepage, and click the link to access the transfer form. Submitting the form sends a POST request to /transfer.
Current Vulnerability
This application is vulnerable to CSRF because:
- It uses session cookies for authentication.
- The
/transferendpoint accepts POST requests without validating their origin. - An attacker could craft a malicious form to trigger unauthorized transfers.
Let’s simulate a CSRF attack to understand the risk.
Simulating a CSRF Attack
Create a malicious HTML file (evil.html) to mimic an attacker’s website:
<!DOCTYPE html>
<html>
<head>
<title>Evil Site</title>
</head>
<body>
<h1>You’ve been hacked!</h1>
<form action="http://localhost:3000/transfer" method="POST" id="maliciousForm">
<input type="hidden" name="amount" value="10000" />
<input type="hidden" name="toAccount" value="attackerAccount" />
</form>
<script>
document.getElementById('maliciousForm').submit();
</script>
</body>
</html>Host this file using a simple server (e.g., Python’s http.server):
python3 -m http.server 8000Open http://localhost:8000/evil.html in a browser where you’re logged into the banking app. The form auto-submits, and the server logs:
Transferring 10000 to account attackerAccountThe transfer succeeds because the browser included the session cookie, and the server trusted the request. This confirms the CSRF vulnerability.
Implementing CSRF Protection with Tokens
CSRF Token Overview
CSRF tokens are unique, unpredictable values generated by the server and included in state-changing requests (e.g., forms, AJAX calls). The server validates the token to ensure the request originates from a legitimate source. Tokens are typically:
- Stored in the user’s session.
- Embedded in forms as hidden fields or sent in headers for AJAX requests.
- Validated on the server before processing the request.
We’ll use the csurf middleware to add CSRF protection to our application.
Step 1: Install and Configure csurf
The csurf middleware is already installed. Let’s integrate it into app.js.
Modify app.js to include csurf and generate tokens for the transfer form:
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const csrf = require('csurf'); // Import csurf
const path = require('path');
const app = express();
// Middleware
app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(
session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: { secure: false },
})
);
app.use(csrf()); // Add CSRF middleware
// Set view engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Mock user authentication
app.use((req, res, next) => {
req.session.user = { id: 1, username: 'testuser' };
next();
});
// Routes
app.get('/', (req, res) => {
res.render('index', { user: req.session.user });
});
app.get('/transfer', (req, res) => {
res.render('transfer', { csrfToken: req.csrfToken() }); // Pass CSRF token to form
});
app.post('/transfer', (req, res) => {
const { amount, toAccount } = req.body;
console.log(`Transferring ${amount} to account ${toAccount}`);
res.send('Transfer successful');
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});Update views/transfer.ejs to include the CSRF token:
<!DOCTYPE html>
<html>
<head>
<title>Transfer Money</title>
</head>
<body>
<h1>Transfer Money</h1>
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="<%= csrfToken %>" /> <!-- Add CSRF token -->
<label>Amount:</label>
<input type="text" name="amount" value="1000" />
<label>To Account:</label>
<input type="text" name="toAccount" value="123456" />
<button type="submit">Transfer</button>
</form>
</body>
</html>Step 2: Test the Protection
Restart the server and visit http://localhost:3000/transfer. Submit the form to confirm it works. The server validates the _csrf token automatically.
Now, revisit http://localhost:8000/evil.html. The malicious form fails with a 403 Forbidden error because it lacks a valid CSRF token. The server logs:
ForbiddenError: invalid csrf tokenHow csurf Works
- Token Generation: The
csurfmiddleware generates a unique token for each user session, stored inreq.session._csrf. - Token Embedding: The
req.csrfToken()method retrieves the token, which we include in the form as a hidden field (_csrf). - Token Validation: On POST requests,
csurfchecks the_csrffield (orX-CSRF-Tokenheader for AJAX) against the session token. If invalid or missing, it throws aForbiddenError.
Step 3: Handling CSRF Errors
To provide a better user experience, let’s handle CSRF errors gracefully:
// Add error handling middleware
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
res.status(403).send('Invalid CSRF token. Please try again.');
} else {
next(err);
}
});Place this middleware at the end of app.js. Now, invalid CSRF tokens return a user-friendly message instead of a generic error.
Step 4: Supporting AJAX Requests
For AJAX-based forms, include the CSRF token in a custom header. Update transfer.ejs to use JavaScript:
<!DOCTYPE html>
<html>
<head>
<title>Transfer Money</title>
</head>
<body>
<h1>Transfer Money</h1>
<form id="transferForm">
<label>Amount:</label>
<input type="text" name="amount" value="1000" />
<label>To Account:</label>
<input type="text" name="toAccount" value="123456" />
<button type="submit">Transfer</button>
</form>
<script>
const form = document.getElementById('transferForm');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const data = {
amount: formData.get('amount'),
toAccount: formData.get('toAccount'),
};
const response = await fetch('/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '<%= csrfToken %>', // Include CSRF token
},
body: JSON.stringify(data),
});
const result = await response.text();
alert(result);
});
</script>
</body>
</html>Update the /transfer endpoint to parse JSON:
app.use(bodyParser.json()); // Add JSON parser
app.post('/transfer', (req, res) => {
const { amount, toAccount } = req.body;
console.log(`Transferring ${amount} to account ${toAccount}`);
res.send('Transfer successful');
});Test the form again. The AJAX request includes the CSRF token in the X-CSRF-Token header, and csurf validates it.



