Privilege Escalation is a vulnerability in which a user of the system could “escalate” themselves to a higher-privileged role, such as admin, through some loophole in the backend code. Taking Node.js example, let’s see how it could be done, and what are the best practices to avoid it.
To explore more possible attacks and their solutions, take a look at my post web security checklist.
How Privilege Escalation Can Happen
Assume that in the req.body, the user has maliciously included admin as true.
{
"username": "John",
"admin": true,
}
In NoSQL
Take the example of user schema in Mongoose.
const mongoose = require("mongoose");
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
},
email: {
type: String,
required: true,
unique: true,
},
password: {
type: String,
required: true,
},
admin: {
type: Boolean,
default: false,
},
// ...
});
const User = mongoose.model("User", userSchema);
The “admin” field when set to true means the user has the privilege of admin. (Alternatively, you could have a “role” field, that could take “user”, “admin”, and “super admin”. Either way, the vulnerability works.)
For any put or patch request, if the incoming parameters are not scrutinized and the proper checks are not in place, the malicious user can escalate/increase their privilege by themselves. Let’s take the example of the updateUser route, where only the provided fields get changed:
const updateUser = (req, res) => {
doc = await User.findOneAndUpdate({id: req.params.id}, req.body);
// Or with destructuring
doc = await User.findOneAndUpdate({id: req.params.id}, {updatedAt: new Date(), updatedBy: req.user._id, ...req.body});
}
Since “admin” is a valid field in user, you cannot stop it from saving.
In SQL
Privilege Escalation can happen in SQL where an ORM is involved that takes JSON and converts it into a query under the hood. Take this or a similar variation as an example:
const updateUser = (req, res) => {
await dataSource
.createQueryBuilder()
.update(User)
.set(req.body)
// Or with destructuring
// .set({updatedAt: new Date(), updatedBy: req.user._id, ...req.body})
.where("id = :id", { id: req.params.id }) request
.execute();
}
Again, this will result in escalating the user privilege because the query is valid according to the SQL schema and we’re responsible for letting it go to the database as is.
Solutions to Avoid Privilege Escalation for SQL and NoSQL
1. Be Explicit and Don’t Use Destructuring Statements or Pass the Whole Object
The first layer of defense: just pick and choose the parameters you’re interested in, instead of assuming the incoming data is accurate and destructuring or passing it as is. Being explicit is a very good and secure practice.
Tip: Use different names for parameters and database fields. Although not always a viable or desirable solution, it could act as a layer of defense, since you can’t possibly mix this up. Even if not for all the fields, at least for the critical ones. For example, keep the database field as “role”, but expect “userRole” in the request. This way, you have to explicitly assign it before persisting in the database.
2. Ensure No Extra Parameter Is Present
Although being explicit is a good practice and line of defense, the best way to keep yourself safe from privilege escalation is to not accept parameters that are not supposed to be there. NestJS provides a built-in DTO mechanism that is used along with the application-level configuration to disallow extra parameters in the request (you get an error if you do pass anything extra). You can do similar checks in Express middleware, or in the case of Lambda functions, a yup or JSON schema validation. This is by far the best way to secure yourself from privilege escalation.
3. Implement Role Based Access Control (RBAC)
For any critical part of your system, this should be a given. A user should not be allowed to change roles if they don’t have the right privileges to begin with. This can be taken care of at the middleware level by checking the user’s current role, or in the final method itself. For example:
if (req.user.role !== "admin" && req.body.role) {
return res.status(403).json({ error: "Unauthorized to modify role" });
}
// Or using middleware (in Express)
const protectRoleUpdate = (req, res, next) => {
if (req.body.role && req.user.role !== "admin") {
return res.status(403).json({ error: "Unauthorized to modify role" });
}
next();
};
app.put("/users/:id", protectRoleUpdate, async (req, res) => {
// Update logic here
});
Now even if you destructure the code or pass the req.body as is (which you still shouldn’t do as a good practice), you are secure from privilege escalation.
See also
- Web Security Checklist
- Tabnabbing Attack Explained
- Node JS Mongo Client for Atlas Data API
- SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
- Exactly Same Query Behaving Differently in Mongo Client and Mongoose
- AWS Layer: Generate nodejs Zip Layer File Based on the Lambda's Dependencies
- In Node JS HTML to PDF conversion, Populate Images From URLs