What You'll Build
In this lab you will take a small, deliberately vulnerable Node.js application containing one SQL injection flaw and one stored XSS flaw, confirm both as an attacker would, and then patch them the right way. You will finish with a hardened version and automated tests that prove each vulnerability is closed and stays closed.
The point is to complete the full loop from Module 2: find, exploit safely, fix at the root, and verify. Rather than memorising payloads, you will practise the reasoning, spotting where data crosses into a code context, and applying parameterisation and output encoding as the structural cures. That reasoning transfers to any language or framework you meet later.
Prerequisites
- Node.js 18+ and npm installed, with a terminal you are comfortable running commands in.
- Completion of the Module 2 readings on SQL injection, parameterised queries, and XSS, whose concepts you will apply directly.
- Basic familiarity with reading JavaScript and running a local web server on a port such as 3000.
- An understanding that you must only run these techniques against this local lab or apps you are explicitly authorised to test.
Setup & Project Structure
Create a minimal Express application with a SQLite database and two endpoints: a login that is vulnerable to SQL injection, and a comments feature that is vulnerable to stored XSS. Keeping the app tiny makes the flaws easy to see and the fixes easy to verify. Run it locally and keep your browser's developer tools open so you can watch requests and responses throughout.
# Scaffold the lab locally
mkdir sqli-xss-lab && cd sqli-xss-lab
npm init -y
npm install express better-sqlite3
# Create app.js with two intentionally vulnerable endpoints
# (login via string-concatenated SQL, comments via unescaped HTML),
# then run it and open the developer tools Network tab:
node app.js # serves on http://localhost:3000Step 1 — Foundation: The Vulnerable App
Start from the vulnerable baseline so you can see each flaw before fixing it. The login handler concatenates the username and password straight into a SQL string, and the comments handler stores and renders comment text as raw HTML. Read both handlers carefully and predict, from Module 2, exactly how each could be exploited before you try. Naming the flaw first sharpens the fix.
// app.js — VULNERABLE baseline (study before exploiting)
// SQLi: username/password concatenated into the query
app.post('/login', (req, res) => {
const { user, pass } = req.body;
const row = db.prepare(
`SELECT * FROM users WHERE user = '${user}' AND pass = '${pass}'`
).get(); // input becomes SQL
res.send(row ? 'Welcome ' + row.user : 'Denied');
});
// Stored XSS: comment text rendered as raw HTML
app.post('/comment', (req, res) => {
db.prepare('INSERT INTO comments(text) VALUES (?)').run(req.body.text);
const all = db.prepare('SELECT text FROM comments').all();
res.send(all.map(c => `<p>${c.text}</p>`).join('')); // unescaped
});Step 2 — Core Logic: Confirm the Flaws
Now confirm each flaw safely against your local app. For the login, submit a classic tautology in the password field so the WHERE clause becomes always true and you are logged in without valid credentials. For the comments, submit text containing a script or an image error handler and reload the page to see it execute, confirming stored XSS. Record exactly what you sent and what happened.
# Confirm SQLi: a tautology makes the WHERE clause always true
curl -s http://localhost:3000/login \
-d "user=admin" --data-urlencode "pass=' OR '1'='1"
# Expected: "Welcome admin" — logged in with no valid password.
# Confirm stored XSS: submit a payload, then reload to see it run
curl -s http://localhost:3000/comment \
--data-urlencode "text=<img src=x onerror=alert(1)>"
# Reload the page in the browser: the onerror handler fires.Step 3 — Integration & Enhancement: Patch at the Root
Fix each flaw at its structural root, not with a filter. Replace the concatenated login query with a parameterised statement so the credentials are bound as data and a tautology becomes a harmless literal that matches no user. Replace the raw HTML rendering with contextual output encoding, so comment text is displayed as text and any tags are shown rather than executed. Re-run your Step 2 payloads to see them fail.
// PATCHED login — parameterised, credentials bound as data
app.post('/login', (req, res) => {
const { user, pass } = req.body;
const row = db.prepare(
'SELECT * FROM users WHERE user = ? AND pass = ?'
).get(user, pass); // values never parsed as SQL
res.send(row ? 'Welcome ' + row.user : 'Denied');
});
// PATCHED comments — contextual HTML encoding on output
function esc(s) {
return s.replace(/[&<>"']/g, ch => ({
'&':'&','<':'<','>':'>','"':'"',"'":'''
}[ch]));
}
app.post('/comment', (req, res) => {
db.prepare('INSERT INTO comments(text) VALUES (?)').run(req.body.text);
const all = db.prepare('SELECT text FROM comments').all();
res.send(all.map(c => `<p>${esc(c.text)}</p>`).join('')); // rendered as text
});Step 4 — Testing & Verification: Prove It Stays Fixed
Finally, lock the fixes in with automated tests, so a future change cannot silently reopen either hole. Write one test asserting the tautology login is now denied, and another asserting a submitted script payload comes back HTML-encoded rather than as live markup. A green test suite is your evidence that both vulnerabilities are closed, and your early warning if anyone ever regresses them.
// verify.test.js — proves both flaws are closed and stay closed
test('SQLi tautology is rejected after parameterisation', async () => {
const res = await post('/login', { user: 'admin', pass: "' OR '1'='1" });
expect(res.text).toBe('Denied'); // no auth bypass
});
test('stored XSS payload is HTML-encoded on output', async () => {
await post('/comment', { text: '<img src=x onerror=alert(1)>' });
const page = await get('/comment');
expect(page.text).toContain('<img'); // shown as text, not run
expect(page.text).not.toContain('<img src=x onerror'); // not live markup
});Warning: Run this lab only against your own local copy. The payloads here are illustrative and safe on your machine, but sending them to any application you do not own or have explicit written permission to test is illegal in most jurisdictions. Keep the server on localhost and never point these requests at a third party's site.
Extension Challenge: Extend the lab three ways. Add a Content Security Policy header that forbids inline scripts and confirm it would block the XSS payload even without the encoding fix, demonstrating defence in depth from Lesson 10. Replace your hand-written escape function with a framework's auto-escaping template and re-run the tests. Finally, add a search endpoint, introduce a blind SQLi into it, and prove parameterisation closes the timing channel from Lesson 7.
- The full security cycle is find, safely confirm, fix at the root, and verify — a fix that is not tested is incomplete.
- A SQL injection login is confirmed with a tautology that makes the WHERE clause always true, bypassing authentication.
- Stored XSS is confirmed by submitting a payload such as an image error handler and reloading to see it execute for every viewer.
- Parameterisation fixes SQLi structurally by binding credentials as data, so a tautology becomes a harmless literal that matches no user.
- Contextual output encoding fixes XSS by rendering user text as text, so submitted tags are displayed rather than executed by the browser.
- Automated tests lock the fixes in, proving both holes are closed and providing an early warning if a future change reopens them.