Press enter or click to view image in full size
This is the first part of a series where I’m gonna teach you how to make your own lab. I’ll walk you through every step of the way. From setting up a Github repo, making the lab, testing it and pushing it onto your repo so that you have something to show and talk about in your next encounter with someone who’s equally passionate about appsec as you are.
Mass assignment is what happens when a developer lets user input flow directly into an object or database without checking what’s actually in it. Most modern frameworks make it super easy to take everything from a request and map it straight to a model. That’s convenient, but it means a user can sneak in fields they’re not supposed to touch, like a role or isAdmin property.
This isn’t some theoretical edge case either. Back in 2012, a security researcher named Egor Homakov used exactly this trick on GitHub itself. He added his public key to the Rails organization by exploiting a mass assignment flaw in the way GitHub handled user input. One extra parameter in a form submission, and he had push access to any public repo on the platform. GitHub patched it, Rails added stronger defaults, and developers everywhere kept making the same mistake anyway. It’s 2026 and it’s still happening.
Create a new repo called mediumLabs and set it to private. Then run these commands to get it set up locally:
mkdir mediumLabs
cd mediumLabs
echo "# mediumLabs" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin [email protected]:[your_profile]/mediumLabs.git
git push -u origin mainWe’ll also need a .gitignore at the repo root to keep node_modules out of the repo:
echo node_modules/ > .gitignoreNow that we have our repo, it’s time to create the folder structure for our Mass Assignment lab. In the end it will look something like this:
/mediumLabs
├── .gitignore
└── /massAssignment
├── /node_modules
├── server.js
├── register.html
├── login.html
├── user.html
├── admin.html
├── package.json
└── package-lock.jsonThe lab we’re going to make is a Node/Express app, so make sure you have npm and node installed. If not, go to nodejs and you'll see something like this.
Press enter or click to view image in full size
Just copy and paste it into your terminal and you’re good to go!
To start we’re going to make our folder ready for npm with the command npm init. This will create a package.json. After that we'll install some dependencies we're going to need for our application: express, nodemon, and better-sqlite3. You'll also see a package-lock.json appear after installing. Don't worry about that, that's normal.
mkdir massAssignment
cd massAssignment
npm init
npm i express
npm i nodemon
npm i better-sqlite3After that, we’re gonna make some final adjustments to package.json before we go and start coding. We need to define what gets started when we give the command and where it's located. For this to work we need to make sure the properties main and scripts are well defined. Use nano or vim to update the contents of package.json. You can leave the defaults for all the rest of the properties.
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},To finish up our structure, all we need to do is create the files that will make up our website. We’ll need server.js, user.html, admin.html, login.html, and register.html.
touch server.js user.html admin.html register.html login.htmlSo now we have our lab skeleton and we’re almost ready to go and make a vulnerable app!
Before we start implementing the vulnerable code, I’m gonna provide you with some boilerplate. Just copy and paste it in the correct files and then we’re gonna test if the application starts up. Once that works, we’ll dive into the fun part.
server.js
const express = require('express');
const path = require('path');
const Database = require('better-sqlite3');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
const db = new Database(':memory:');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
email TEXT UNIQUE NOT NULL,
address TEXT UNIQUE NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
phone TEXT UNIQUE NOT NULL
)
`);
db.prepare("INSERT INTO users (username, password, role, email, address, city, country, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run('testAdmin', 'password', 'admin', '[email protected]', '123 Admin St', 'AdminCity', 'Belgium', '0000000000');
db.prepare("INSERT INTO users (username, password, role, email, address, city, country, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run('testUser', 'password', 'user', '[email protected]', '456 User Ave', 'UserCity', 'Belgium', '1111111111');
const parseCookies = (req) => {
const cookies = {};
const header = req.headers.cookie;
if (header) {
header.split(';').forEach(cookie => {
const [key, value] = cookie.trim().split('=');
cookies[key] = decodeURIComponent(value);
});
}
return cookies;
};
const getCurrentUser = (req) => {
const { username } = parseCookies(req);
if (!username) return null;
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) || null;
};
app.get('/', (req, res) => {
res.redirect('/login');
});
app.get('/register', (req, res) => {
res.sendFile(path.join(__dirname, 'register.html'));
});
app.get('/login', (req, res) => {
res.sendFile(path.join(__dirname, 'login.html'));
});
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE username = ? AND password = ?').get(username, password);
if (!user) return res.status(401).send('Invalid credentials');
res.cookie('username', username, { httpOnly: true });
res.redirect(user.role === 'admin' ? '/admin' : '/user');
});
app.get('/user', (req, res) => {
const user = getCurrentUser(req);
if (!user) return res.redirect('/login');
res.sendFile(path.join(__dirname, 'user.html'));
});
app.get('/admin', (req, res) => {
const user = getCurrentUser(req);
if (!user) return res.redirect('/login');
if (user.role !== 'admin') return res.status(403).send('Forbidden');
res.sendFile(path.join(__dirname, 'admin.html'));
});
app.get('/api/users', (req, res) => {
const users = db.prepare('SELECT username, role FROM users').all();
res.json(users);
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));user.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<h1>Welcome User</h1>
</body>
</html>admin.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<h1>Welcome Admin</h1>
</body>
</html>register.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Register</title>
</head>
<body>
<h1>Register</h1>
<form action="/register" method="POST">
<label>Username<br>
<input type="text" name="username" required>
</label><br><br>
<label>Password<br>
<input type="password" name="password" required>
</label><br><br>
<label>Email<br>
<input type="email" name="email" required>
</label><br><br>
<label>Address<br>
<input type="text" name="address" required>
</label><br><br>
<label>City<br>
<input type="text" name="city" required>
</label><br><br>
<label>Country<br>
<input type="text" name="country" required>
</label><br><br>
<label>Phone Number<br>
<input type="tel" name="phone" required>
</label><br><br>
<button type="submit">Register</button>
</form>
</body>
</html>login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Login</h1>
<form action="/login" method="POST">
<label>Username<br>
<input type="text" name="username" required>
</label><br><br>
<label>Password<br>
<input type="password" name="password" required>
</label><br><br>
<button type="submit">Login</button>
</form>
</body>
</html>So now that we have all the right files in place, it’s time to start the server.
cd mediumLabs/massAssignment
npm run devYou should see the output Server running on http://localhost:3000. And if you open your browser on the link you'll see the login form.
Try logging in with the credentials testAdmin:password and testUser:password and verify that the correct page is rendered.
Join Medium for free to get updates from this writer.
If that works, stop the server with CTRL-C.
The HTML files are self explanatory, so I’m not going to walk through those. If you do have questions about them, don’t hesitate to reach out on LinkedIn. For server.js, I'm going to focus on two things that directly set up the vulnerability.
1. The role default
Look at the table definition:
role TEXT NOT NULL DEFAULT 'user',This means that if you insert a user without specifying a role, they automatically get user. Many developers think this alone is enough to prevent privilege escalation. It's not, but it does give a false sense of security. Keep this in mind.
2. The access control on /admin
app.get('/admin', (req, res) => {
const user = getCurrentUser(req);
if (!user) return res.redirect('/login');
if (user.role !== 'admin') return res.status(403).send('Forbidden');
res.sendFile(path.join(__dirname, 'admin.html'));
});This is actually correct. A normal user could log in and be redirected to /user, but nothing stops them from manually browsing to /admin. This check makes sure they get a 403 Forbidden if they try. The access control itself isn't the problem. The problem is what happens before a user ever reaches this point.
If you looked through the boilerplate, you might have noticed that there isn’t a POST route for /register. You can load the registration form, but submitting it goes nowhere. Of course that's intentional, because that's where the vulnerability lives.
When registering a new user, our form needs a lot of fields. Username, password, email, address, city, country, phone. That’s a lot of properties to destructure one by one. And if you’re a developer who’s done this a hundred times, you know there’s a shortcut.
Enter the spread operator.
app.post('/register', (req, res) => {
const newUser = { role: 'user', ...req.body };
db.prepare('INSERT INTO users (username, password, role, email, address, city, country, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(newUser.username, newUser.password, newUser.role, newUser.email, newUser.address, newUser.city, newUser.country, newUser.phone);
res.redirect('/login');
});By using ...req.body, we can grab every field from the form without typing out each one individually. And we're being smart about it too. We hardcoded role: 'user' right there in the object. Between this and the DEFAULT 'user' in the database, we've now defined the default role in two separate places. Seems watertight.
Add this route to server.js, restart the server, and register a new user. Navigate to /api/users and you'll see the new user has role: user. Perfect.
Press enter or click to view image in full size
But what happens when we intercept that registration request and, just for fun, add a role parameter?
Press enter or click to view image in full size
Or skip the form entirely and send a JSON request straight from an API client like Burp or Postman:
{
"username": "registerUser2",
"password": "password",
"email": "[email protected]",
"address": "address2",
"city": "city2",
"country": "country2",
"phone": "phone2",
"role": "admin"
}I just added this because most modern apps expose a JSON API, not an HTML form. The vulnerability is the same either way.
Whatever you send, turns out, those two safety measures aren’t so safe after all.
We can verify this by logging in with the credentials registerUser2:password. Instead of the user page, we see the admin page. We just pulled off a mass assignment attack.
The key is in the spread order. When JavaScript sees { role: 'user', ...req.body }, it first sets role to 'user', and then spreads every property from req.body on top of it. If req.body contains its own role property, it overwrites the one we hardcoded. The last value wins.
The database default doesn’t help either, because we’re explicitly passing a role value in the INSERT statement. The DEFAULT only kicks in when you omit the column entirely.
So both safety nets fail for the same reason: the attacker-controlled value arrives last and takes priority.
A few things went wrong here, but luckily the fixes are simple.
1. Don’t use the spread operator on user input. You don’t know what a user might throw at your backend. In this lab it led to mass assignment, but in other contexts it can introduce unexpected fields, break your logic, or cause crashes you didn’t plan for. Explicitly destructure only the fields you expect.
2. Don’t include sensitive properties in user-facing endpoints. You don’t need to include role in the INSERT statement at all. Let the database default handle it. The only place you should set a role explicitly is in admin-only functionality that regular users can't reach.
Implementing both of these gives us:
app.post('/register', (req, res) => {
const { username, password, email, address, city, country, phone } = req.body;
db.prepare('INSERT INTO users (username, password, email, address, city, country, phone) VALUES (?, ?, ?, ?, ?, ?, ?)').run(username, password, email, address, city, country, phone);
res.redirect('/login');
});Explicitly tell what fields you need and only put those fields in your database. Sensitive properties like role should only appear in queries that aren't publicly accessible.
Note: The code in this lab is deliberately stripped down to focus on the vulnerability. There’s no error handling, no input validation, and passwords are stored in plaintext. Don’t copy the “fixed” version into production and call it a day. It’s secure against mass assignment, but it’s still a lab.
That wraps up Part 1. You’ve got a working lab with a real vulnerability you can exploit and explain. In the next part, we’ll build a different vulnerability. If you have questions or want to connect, find me on LinkedIn.
I break web apps for fun, make vulnerable labs to learn, and write about it so you can too. More writeups, vulnerable labs, and pentesting war stories on blog.forgesec.be. Come say hi on LinkedIn, no exploits required!