Lab 15: Web Application Security¶
Aligns with Lecture 30 (Application Security Basics) and Lecture 31 (Common Web Attacks and Defences). Applies directly to the Express/MongoDB API from Labs 11–12 and the React front end from Labs 13–14.
Objective:¶
To apply core, practical web-application security measures: secure password storage, protection against NoSQL injection and XSS, sensible CORS configuration, common security response headers, and basic rate limiting.
Activity Outcomes:¶
- Explain the CIA triad and why plaintext password storage is unacceptable.
- Hash and verify passwords using bcrypt.
- Add security headers using helmet and understand what each mitigates.
- Configure CORS explicitly rather than allowing all origins.
- Recognise and fix a NoSQL injection vulnerability in a query.
- Recognise and fix a reflected XSS vulnerability in server-rendered HTML.
- Apply basic rate limiting to a sensitive route.
1) Useful Concepts¶
| Threat | Typical cause | Primary defence used in this lab |
|---|---|---|
| Plaintext password exposure | Storing req.body.password directly | bcrypt.hash() before saving; bcrypt.compare() on login |
| NoSQL injection | Passing untrusted req.body/req.query straight into a Mongo query | Validate/whitelist input types; strip $ and . operators (e.g. express-mongo-sanitize) |
| Cross-Site Scripting (XSS) | Inserting untrusted input into HTML without escaping | Escape output server-side; never use dangerouslySetInnerHTML with untrusted data in React |
| Cross-Origin misuse | cors() with no options (allow-all) | Explicit origin allow-list in the CORS config |
| Brute-force / abuse | No limit on repeated requests to a route | express-rate-limit on sensitive routes such as /login |
Security middleware baseline:
const helmet = require('helmet');
const cors = require('cors');
const mongoSanitize = require('express-mongo-sanitize');
app.use(helmet());
app.use(cors({ origin: ['http://localhost:5173'], methods: ['GET','POST','PUT','DELETE'] }));
app.use(mongoSanitize()); // strips keys starting with $ or containing '.'
2) Solved Lab Activities¶
| Sr. No. | Allocated Time | Level of Complexity | CLO Mapping |
|---|---|---|---|
| Activity 1 | 25 minutes | Medium | CLO-4 |
| Activity 2 | 20 minutes | Medium | CLO-4 |
| Activity 3 | 30 minutes | High | CLO-5 |
Activity 1: Hash passwords with bcrypt¶
Update the registration route from Lab 12 to hash the password before saving, and confirm login still works by comparing hashes.
Solution:
const bcrypt = require('bcryptjs');
app.post('/api/auth/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password || password.length < 8) {
return res.status(400).json({ success: false, error: 'Username and an 8+ character password are required' });
}
const hashed = await bcrypt.hash(password, 10);
const user = await User.create({ username, password: hashed });
res.status(201).json({ success: true, data: { id: user._id, username: user.username } });
});
// login (unchanged in shape from Lab 12, using bcrypt.compare)
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
const user = await User.findOne({ username });
const valid = user && await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ success: false, error: 'Invalid credentials' });
res.json({ success: true, data: { username } });
});
Output / Expected behaviour:
Inspecting the users collection shows only bcrypt hashes (e.g. '$2a$10$...'), never the plaintext password.
Activity 2: Headers, CORS and rate limiting¶
Add helmet, an explicit CORS allow-list, and rate limiting on the login route specifically.
Solution:
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
app.use(helmet());
app.use(cors({ origin: 'http://localhost:5173' }));
const loginLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 5, // 5 attempts per window per IP
message: { success: false, error: 'Too many login attempts, try again later.' }
});
app.post('/api/auth/login', loginLimiter, async (req, res) => { /* as in Activity 1 */ });
Output / Expected behaviour:
A request from an origin other than http://localhost:5173 is blocked by CORS.
The 6th login attempt within 10 minutes from the same IP receives 429 with the rate-limit message.
Activity 3: Demonstrate and fix NoSQL injection and XSS¶
Show a vulnerable query and a vulnerable HTML response, then fix both.
Solution:
// VULNERABLE: query built directly from user input
app.post('/api/auth/login-unsafe', async (req, res) => {
// if req.body.password is the object { "$ne": null }, this matches ANY user
const user = await User.findOne({ username: req.body.username, password: req.body.password });
res.json({ success: !!user });
});
// FIXED: validate types before querying, and never compare passwords with $-operators
app.post('/api/auth/login-safe', async (req, res) => {
const { username, password } = req.body;
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ success: false, error: 'Invalid input' });
}
const user = await User.findOne({ username });
const valid = user && await bcrypt.compare(password, user.password);
res.json({ success: !!valid });
});
// VULNERABLE: reflecting untrusted input straight into HTML
app.get('/search-unsafe', (req, res) => {
res.send(`<h2>Results for: ${req.query.term}</h2>`); // e.g. ?term=<script>...</script>
});
// FIXED: escape before inserting into HTML
function escapeHtml(str) {
return str.replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch]));
}
app.get('/search-safe', (req, res) => {
res.send(`<h2>Results for: ${escapeHtml(req.query.term || '')}</h2>`);
});
// React note: never do this with untrusted data —
// <div dangerouslySetInnerHTML={{ __html: userInput }} />
// React already escapes {expression} text content by default; prefer that.
Output / Expected behaviour:
POST /api/auth/login-unsafe with { "username": "admin", "password": { "$ne": null } } logs in without a real password -demonstrating the vulnerability.
The same payload against /api/auth/login-safe is rejected with 400 because password is not a string.
GET /search-unsafe?term=<script>alert(1)</script> renders the tag in the page; /search-safe?term=... shows it as harmless escaped text.
3) Graded Lab Tasks¶
Note: The instructor may adjust these tasks to the level of difficulty and complexity of the solved activities. Tasks should be evaluated in the same lab session.
Lab Task 1: Token expiry and re-login
Reduce the JWT expiresIn from Lab 12 to '2m'. Demonstrate that a request made after expiry is rejected by requireAuth with 401, and that logging in again issues a fresh, working token.
Lab Task 2: Security header/report checklist
Enable helmet on the Lab 12 API and use browser developer tools (Network tab) to list which response headers helmet added (e.g. X-Content-Type-Options, X-Frame-Options). For each header, write one sentence on what it protects against.
Lab Task 3: OWASP Top 10 mapping
Produce a short table mapping each vulnerability class covered in this lab (weak password storage, NoSQL injection, XSS, CORS misconfiguration, missing rate limiting) to its corresponding OWASP Top 10 category, and note one additional Top 10 category not covered in this lab that the students should research on their own.