In web development, writing secure code isn’t just a “nice-to-have” – it’s absolutely critical. One tiny vulnerability can blow up in your face, exposing sensitive user data, trampling on privacy, and, let’s be real, absolutely trashing your reputation. I want to walk you through some of the most common web application vulnerabilities I see out there, share how we tackle them, and give you some pointers on what I zero in on during a good old-fashioned code review.
1. Broken Access Control
Broken Access Control – man, this one’s a classic. It’s when users can somehow get their hands on stuff or do things they absolutely shouldn’t. Most of the time, it’s just because the permission structures weren’t fully baked in. Sure, application frameworks have gotten crazy sophisticated lately, but here’s the thing: they don’t magically handle permissions for you. You, the developer, still have to implement that stuff, because every single app has its own weird, custom requirements.
What I Look for in Code Review:
When I’m digging through code for broken access control, my eyes are peeled for:
- Endpoints that are just chilling there, handling sensitive data or actions, with no checks for who’s allowed to do what.
- Direct object references in URLs or parameters that aren’t being validated against the current user’s authorization. I’ve seen too many times where someone could just change an ID in the URL and suddenly see another user’s data.
- Inconsistent access control – like one function needs a specific role, but a similar function doesn’t bother checking. That’s a red flag. 🚩
How We Fix It:
The main way we bat down broken access control is by putting in some seriously solid permission structures.
Identity-Based Access Control
For operations where a user should only ever see their own data, we make damn sure the user’s ID matches the requested resource’s ID.
Vulnerable Code Example (GET Endpoint for Grades): This code fetches a student’s grade using only the ID from the URL query. An attacker could simply change ?studentid=123 to ?studentid=456 to view another student’s grades.
app.get('/grades', (req, res) => {
res.setHeader('Content-Type', 'application/json')
// VULNERABILITY: No check to see who is logged in!
var lookup = {}
lookup.studentID = req.query.studentid
lookup.subjectID = req.query.subjectid
grade = getGrade(lookup)
json = JSON.stringify(grade)
res.send(json)
})
Secure Code Example (GET Endpoint for Grades): This version checks if the requested studentID matches the ID of the currently logged-in user. If they don’t match, access is denied.
app.get('/grades', (req, res) => {
res.setHeader('Content-Type', 'application/json')
// Access Control
if (getCurrentUser().studentID != req.query.studentid) {
var response = {}
response.message = "Access Denied"
res.json(response)
return false
}
var lookup = {}
lookup.studentID = req.query.studentid
lookup.subjectID = req.query.subjectid
grade = getGrade(lookup)
json = JSON.stringify(grade)
res.send(json)
})
Role-Based Access Control
For actions that should only be for certain roles (like only teachers updating grades, not students), we check the user’s role.
Vulnerable Code Example (PATCH Endpoint for Grades): This endpoint allows any authenticated user to update a grade, as there’s no role check. A student could use this to change their own grade.
app.patch('/grades', (req, res) => {
res.setHeader('Content-Type', 'application/json')
// VULNERABILITY: No role check. A student could call this!
var grade = {}
grade.studentID = req.body.studentID
grade.subjectID = req.body.subjectID
grade.grade = req.body.grade
response = updateGrade(grade)
json = JSON.stringify(response)
res.send(json)
})
Secure Code Example (PATCH Endpoint for Grades): Here, the code first checks if the user has the “teacher” role. If not, the request is denied.
app.patch('/grades', (req, res) => {
res.setHeader('Content-Type', 'application/json')
// Access Control
if (getCurrentUser().role != "teacher") {
var response = {}
response.message = "Access Denied"
res.send(JSON.stringify(response))
return false
}
var grade = {}
grade.studentID = req.body.studentID
grade.subjectID = req.body.subjectID
grade.grade = req.body.grade
response = updateGrade(grade)
json = JSON.stringify(response)
res.send(json)
})
2. Cross-Site Scripting (XSS)
Cross-Site Scripting, or XSS – this one’s a real pain because it’s so popular. It happens when your app takes unvalidated user input and just shoves it right into a web page, letting attackers run their own nasty scripts in someone else’s browser. 👨💻
What I Look for in Code Review:
- Finding every single place where user input gets injected into the response.
- Checking for any display of data that came from a database.
- The big one: is there any output encoding around user-controlled data?
How We Fix It:
Dealing with XSS is all about having multiple layers of defense.
1. Find the Injection Points
First things first, you gotta meticulously find all places in your code where user-supplied data is injected into responses.
2. Escape the Output
This is the most important step for XSS mitigation. You need to HTML-encode all dangerous characters in user-controlled data before you stick it into your HTML output.
Vulnerable Code Example (Displaying a Message): The code takes messageContent straight from the database and inserts it into the HTML. If an attacker saved a message like <script>alert('XSS');</script>, it would execute in the browser of anyone who views it.
function generateMessageHTML(messageId) {
let messageContent = database.loadContent(messageId);
// VULNERABILITY: Unescaped content is inserted directly into HTML.
return `<p class="messageContent">${messageContent}</p>`;
}
Secure Code Example (Escaping Message Content): Using a library like lodash.escape, we sanitize the content before rendering it. The browser will now display the script as plain text instead of executing it.
import escape from 'lodash.escape';
function generateMessageHTML(messageId) {
let messageContent = database.loadContent(messageId);
let escapedContent = escape(messageContent);
return `<p class="messageContent">${escapedContent}</p>`;
}
3. Perform Input Validation
Validate that user-controlled data is in the exact format you expect before saving it.
Vulnerable Code Example (Saving a Message): This function blindly saves whatever it receives. An attacker could provide a malformed messageId or senderEmail.
// VULNERABILITY: No validation on input parameters.
function handleMessageSend(messageId, senderEmail, messageContent) {
database.save(messageId, senderEmail, messageContent);
}
Secure Code Example (Validating Message ID and Email): Using a library like validator.js, we ensure the messageId is a UUID and senderEmail is a valid email before saving.
import isEmail from 'validator/lib/isEmail.js';
import isUUId from 'validator/lib/isUUID.js';
function handleMessageSend(messageId, senderEmail, messageContent) {
if (!isUUId(messageId)) {
throw new Error("validation of messageId parameter failed");
}
if (!isEmail(senderEmail)) {
throw new Error("validation of email parameter failed");
}
database.save(messageId, senderEmail, messageContent);
}
4. Don’t Put User Input in Dangerous Places
Look, the mitigations we just talked about work great when user input is just going to be the content of an HTML element (like <div>user_input</div>). But there are some spots where you just never put user-controlled input. These are no-go zones:
- Inside a
<script>tag - Inside CSS (like in a
<style>tag) - Inside an HTML attribute (like
<div attr=user_input>)
5. Content Security Policy (CSP)
CSP is a security feature in web browsers that tells them which content sources are safe to load and run. By listing your allowed domains, you can prevent malicious scripts from unauthorized sources from running.
Example CSP Policy:
Content-Security-Policy: default-src 'self' https://trusted-site.example;
In this snippet, default-src means content (like scripts) can come from your own origin ('self') and from https://trusted-site.example, but anything else? Blocked.
3. Code Injection
Code injection is pretty nasty – it lets attackers run whatever code they want on your server or their client by injecting malicious code through user input.
How We Fix It:
1. Avoid Dangerous Functions
In JavaScript, just don’t use eval(), setTimeout(), setInterval(), and the Function constructor with user input.
Vulnerable Code Example (Using eval()): This code uses eval() to dynamically call a function on a user-provided string. An attacker could inject malicious code by manipulating the name variable.
// VULNERABILITY: The 'name' variable comes from user input and is passed to eval().
// An input like `"; require('child_process').exec('rm -rf /');"` could be catastrophic.
uppercaseName = eval('"' + name + '"' + '.toUpperCase()');
Secure Code Example (Removing eval()): The same result can be achieved safely and simply without eval().
uppercaseName = name.toUpperCase();
2. Reconsider Dynamic Code Execution
Honestly, just ask yourself, “Do I really need to evaluate dynamically generated server-side code here?”. Most of the time, this is a sign of bad software design.
3. Lock Down the Interpreter
If possible, configure your server-side interpreter to disable functions that can execute code dynamically. For example, in PHP, you can use the disable_functions directive in your php.ini file.
4. Utilize a Static Analysis Tool
Adding a static application security testing (SAST) tool like Snyk Code to your pipeline is an excellent way to catch these vulnerabilities early.
4. Cross-Site Request Forgery (CSRF)
CSRF is sneaky. It’s when an attacker tricks your browser into sending a forged request to a vulnerable web application where you’re already logged in.
How We Fix It:
1. CSRF Tokens
One of the best ways to fight CSRF is by using a random, unique token for each request. The server validates this token to ensure the request is legitimate.
Vulnerable Code Example (State-Changing Form): This form transfers money. An attacker could host a webpage with a hidden, auto-submitting version of this form. If a logged-in user visits the attacker’s page, their browser will submit the form and transfer funds without their knowledge.
<form action="https://saturnbank.com/transfer" method="POST">
<input type="hidden" bsb="421314" accountNo="1736123125" amount="100" />
<p>Click here for a free vacation!</p>
<input type="submit" value="Claim Now!">
</form>
app.post('/transfer', (req, res) => {
// VULNERABILITY: No token validation. Processes any request.
// ... transfer logic ...
});
Secure Code Example (Using CSRF Tokens): The form now includes a hidden field with a unique csrfToken. The server-side endpoint uses csrfProtection middleware to validate this token before processing the request.
<form action="https://saturnbank.com/transfer" method="POST">
<input type="hidden" bsb="421314" accountNo="1736123125" amount="100" />
<input type="hidden" name="_csrf" value="<%= csrfToken %>" />
</form>
// FIX: Add CSRF protection middleware to the route.
app.post('/transfer', csrfProtection, (req, res) => {
// This code only runs if the CSRF token is valid.
// …
});
2. SameSite Cookies
Another solid defense is adding the SameSite attribute to your cookies. This tells the browser whether to send the cookie along with cross-site requests.
SameSite=Strict: The browser will never include the cookie in requests that came from another site.SameSite=Lax: A bit more permissive, it allows the cookie on top-level GET requests.
Example:
Set-Cookie: SessionId=sYMnfCUrAlmqVVZn9dqevxyFpKZt30NN; SameSite=Strict;
5. Prototype Pollution
Prototype pollution attacks are devious. They exploit how JavaScript handles object prototypes to modify an object’s prototype, which can lead to application-wide vulnerabilities.
How We Fix It:
1. Use Safe Open-Source Libraries for Recursive Property Setting
Don’t write your own recursive merge functions. Use a trusted, secure library like lodash.merge.
Vulnerable Code Example (Unsafe Merge): This custom merge function recursively assigns properties. An attacker can provide a malicious JSON payload like {"__proto__": {"isAdmin": true}}. This will add an isAdmin property to every object in the application, potentially granting admin rights.
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// VULNERABILITY: Unsafe merge can be exploited.
async function updateUser(userId, requestBody) {
const userData = await db.loadUserData(userId);
merge(userData, requestBody); // Malicious requestBody pollutes Object.prototype
await db.saveUserData(userId, userData);
return userData;
}
Secure Code Example (Using lodash.merge): The lodash.merge library is designed to prevent prototype pollution.
import safeMerge from 'lodash.merge';
async function updateUser(userId, requestBody) {
const userData = await db.loadUserData(userId);
// FIX: Use a library that safely merges objects.
safeMerge(userData, requestBody);
await db.saveUserData(userId, userData);
return userData;
}
2. Create Objects Without Prototypes: Object.create(null)
If you create an object using Object.create(null), it won’t have a prototype, making it immune to pollution.
3. Prevent Any Changes to the Prototype: Object.freeze()
You can freeze the default object prototype to prevent any modifications to it. A simple way is to use the nopp npm package or call Object.freeze(Object.prototype) once when your application starts.
6. NoSQL Injection
NoSQL injection lets attackers mess with your NoSQL database queries through malicious user input, which can even lead to remote code execution.
How We Fix It:
1. Sanitize User Input
The core of NoSQL injection mitigation is to strictly validate and sanitize any user-supplied input.
Vulnerable Code Example (Login Page): The query uses req.body.username and req.body.password directly. An attacker could submit a JSON object instead of a string for the password, like {"$ne": null}, which would change the query logic to password != null, logging them in as the first user in the database.
app.post('/login', function (req, res){
// VULNERABILITY: User input is used directly in the query.
let query = {
username: req.body.username,
password: req.body.password
}
db.collection('user').findOne(query, function (err, user) {
// ...
});
});
Secure Code Example (Login Page Mitigation): By casting the user input to a string, we prevent an attacker from sending a malicious query object. The database will now literally search for a password string like “[object Object]”, which will fail.
app.post('/login', function (req, res){
// FIX: Cast all user input to the expected type (String).
let user = String(req.body.username);
let pass = String(req.body.password);
let query = {
username: user,
password: pass
}
db.collection('user').findOne(query, function (err, user) {
// ...
});
});
2. Avoid Dangerous Operators
Avoid using dangerous operators such as $where, mapReduce, and $group with user-provided data.
3. Disable JavaScript Execution (if applicable)
To stop JavaScript from executing in certain NoSQL databases like MongoDB, set javascriptEnabled to false in the mongod.conf file.
7. XXE (XML External Entity)
XXE vulnerabilities occur when an XML parser improperly processes external entity references, allowing an attacker to read local files or perform other malicious actions.
How We Fix It:
The safest way to mitigate XXE is by configuring your XML parser to disable DTDs (Document Type Definitions) and external entities.
Vulnerable Code Example (libxmljs): The noent:true option tells the parser to replace entities, including external ones. An attacker can submit XML with a malicious entity that points to a local file, like /etc/passwd.
const libxml = require("libxmljs");
app.post("/profile/favorites", (req, res) => {
// VULNERABILITY: The noent:true option enables external entity processing.
// An attacker could submit: <?xml version="1.0" ?><!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><favorite>&xxe;</favorite>
favorite = libxml.parseXml(req.body, { noent: true });
addToFavorites(favorite);
});
Secure Code Example (libxmljs Mitigation): Most modern XML parsers, including libxmljs, disable external entities by default. The fix is simply to remove the option that enables them.
const libxml = require("libxmljs");
app.post("/profile/favorites", (req, res) => {
// FIX: Removed the dangerous noent:true option.
favorite = libxml.parseXml(req.body);
addToFavorites(favorite);
});
8. SQL Injection
SQL injection is still very much a thing. It’s when an attacker manipulates user-supplied input to alter the logic of your database queries.
How We Fix It:
The best way to prevent SQL injection is to use parameterized queries (also known as prepared statements). This separates the query logic from the data.
Vulnerable Code Example (User Login): This code builds a SQL query by concatenating strings with user input. An attacker can enter ' OR 1=1 -- as the password. The resulting query becomes SELECT email FROM credentials WHERE email= 'user@example.com' AND password= '' OR 1=1 -- ', which is always true, bypassing the login.
public static boolean checkUser(
HttpServletRequest req, Connection con) throws SQLException {
// VULNERABILITY: Query is built by concatenating user input.
String sqlQuery = "SELECT email FROM credentials " +
"WHERE email='" + req.getParameter("email") + "' " +
"AND password='" + req.getParameter("password") + "'";
Statement statement = con.createStatement();
ResultSet rs = statement.executeQuery(sqlQuery);
return rs.next();
}
Secure Code Example (Using Prepared Statements): This version uses a PreparedStatement with placeholders (?). The database treats the user input as literal data, not executable SQL code, neutralizing the attack.
public static boolean checkUser(
HttpServletRequest req, Connection con) throws SQLException {
// FIX: Use a PreparedStatement with placeholders.
String sqlQuery = "SELECT email FROM credentials " +
"WHERE email= ? " +
"AND password= ? ";
PreparedStatement statement = con.prepareStatement(sqlQuery);
statement.setString(1, req.getParameter("email"));
statement.setString(2, req.getParameter("password"));
ResultSet rs = statement.executeQuery(); // Note: No argument here
return rs.next();
}
9. XPath Injection
Similar to SQL injection, XPath injection occurs when user input is used to construct an XPath query, potentially allowing unauthorized access to XML data.
How We Fix It:
1. Use an Allowlist
Restrict user input to a set of known-safe characters or patterns.
Vulnerable Code Example (Team Lookup): The teamName is taken directly from the user and put into the XPath query. An attacker could input Bears'] | /* | /teams/team[name='Bears to see all data in the XML document.
app.get('/showteam', async function (req, res) {
const teamName = req.query.team;
// VULNERABILITY: teamName is used directly in the XPath query.
const query = "/teams/team[name='" + teamName + "']/members/member/name/text()";
const nodes = xpath.select(query, doc);
// ...
});
Secure Code Example (Using an Allowlist): This code uses a regular expression to ensure teamName only contains letters. If it contains anything else, the request is rejected.
app.get('/showteam', async function (req, res) {
const teamName = req.query.team;
const re = /^[A-Za-z]+$/g;
// FIX: Validate input against an allowlist.
if ( ! re.test(teamName) ) {
res.send("invalid team name");
return;
}
const query = "/teams/team[name='" + teamName + "']/members/member/name/text()";
const nodes = xpath.select(query, doc);
// ...
});
2. Encode User Input
Encode special characters in the user input so they are treated as literal text.
import {encode} from 'html-entities';
//...
// FIX: Encode the input to neutralize special characters like '
const nodes = xpath.select("/teams/team[name='" + encode(teamName) + "']/members/member/name/text()", doc);
3. Parameterized XPath Queries
If your library supports it, use parameterized XPath queries. This is the most robust solution, similar to prepared statements for SQL.
10. Server-Side Request Forgery (SSRF)
SSRF vulnerabilities occur when a web application fetches a remote resource without validating the user-supplied URL, allowing an attacker to make the server send requests to unintended internal systems.
How We Fix It:
Utilize an Allowlist
This is the most robust approach. If your application needs to fetch resources from other URLs, maintain a strict allowlist of approved domains or IP addresses.
Vulnerable Code Example (Image Fetcher): This endpoint fetches an image from any URL provided by the user. An attacker could provide an internal IP address like http://169.254.169.254/latest/meta-data/ (the AWS metadata service) to steal cloud credentials.
const axios = require('axios');
app.get('/fetch-image', async (req, res) => {
const { url } = req.query;
try {
// VULNERABILITY: The app makes a request to any URL the user provides.
const response = await axios.get(url, { responseType: 'stream' });
response.data.pipe(res);
} catch (error) {
res.status(500).send('Error fetching image');
}
});
Secure Code Example (Using an Allowlist): This version checks if the hostname from the user-provided URL is on a pre-approved list. If not, the request is blocked.
const axios = require('axios');
const { URL } = require('url');
const ALLOWED_HOSTS = ['images.example.com', 'media.example.org'];
app.get('/fetch-image', async (req, res) => {
const { url } = req.query;
try {
const requestedUrl = new URL(url);
// FIX: Check if the requested hostname is in the allowlist.
if (!ALLOWED_HOSTS.includes(requestedUrl.hostname)) {
return res.status(400).send('Invalid host.');
}
const response = await axios.get(requestedUrl.href, { responseType: 'stream' });
response.data.pipe(res);
} catch (error) {
res.status(500).send('Error fetching image or invalid URL.');
}
});
Be careful with DNS rebinding attacks and redirects. An allowlist is your strongest defense.
Conclusion
Look, baking security into your code and doing thorough code reviews aren’t just checkboxes – they’re absolutely essential for building robust web applications. By really understanding these common vulnerabilities and by actually putting these mitigation strategies into practice, you can seriously boost your app’s security posture. Remember to lean on trusted libraries, be super strict with validating and escaping user input, and use static analysis tools constantly to find and fix those security holes before they become a real headache. 🛡️