In the previous lesson we identified nine vulnerabilities in a slice of BazarNube and logged them as BZN-xxx records in the backlog. Finding the problem is only half the job; the other half -the one that actually reduces risk- is remediating it well. In this lab we take those same nine records and, one by one, implement the correct control: corrected and commented code, mapping to the ASVS requirement it satisfies, and verification that the fix works via a ZAP re-scan and targeted tests. The rule of the exercise is the one that governs real AppSec work: a finding is not closed until it is verified. We reuse, without re-explaining, concepts already covered -parameterized queries (03-03), object-level authorization (03-01), headers and helmet (03-06), cryptography (03-02), ASVS (M4) and ZAP (M6)-; here we apply them.
Contents
- From finding to control: the remediation flow
- Remediation per finding (corrected code)
- Master table: finding → control → ASVS → verification
- Verification: re-scan and regression
- Common mistakes and tips
- Exercises
- Conclusion
From finding to control: the remediation flow
Each backlog record goes through the same cycle before it can be marked closed:
graph LR
A[Open BZN finding] --> B[Choose the right control]
B --> C[Implement secure code]
C --> D[Map to ASVS requirement]
D --> E[Verify: re-scan and test]
E --> F{Verified?}
F -->|Yes| G[Close BZN]
F -->|No| C
Two principles guide the choice of control:
- Fix the cause, not the symptom. Filtering a quote does not fix a SQLi; parameterizing the query does. We look for the control that eliminates the entire class of flaw.
- Defense in depth. When it is cheap, controls are combined (validate input and parameterize and least privilege) so that a single failing control is not enough.
Remediation per finding (corrected code)
BZN-134 — SQLi in the search (A03 → parameterization)
The cause is concatenating q into the SQL string. The control is a parameterized query: the data travels separately from the statement and is never interpreted as code.
// catalog.js — fixed
router.get('/api/products/search', async (req, res) => {
const q = String(req.query.q ?? '').slice(0, 100); // type and length validation
const sql = 'SELECT id, name, price FROM products WHERE name ILIKE $1';
const { rows } = await db.query(sql, [`%${q}%`]); // the value is passed as a parameter
res.json(rows);
});The % is added to the value, not the statement: the PostgreSQL driver escapes it. We also validate type and length (defense in depth). Satisfies ASVS V5.3.4 (parameterized queries).
BZN-101 — IDOR in orders (A01 → object-level authorization)
The control is to check that the object belongs to the authenticated user. It is done in the query itself to avoid race conditions and oversights.
// orders.js — fixed
router.get('/api/orders/:id', auth, async (req, res) => {
const { rows } = await db.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2', // bound to the owner
[req.params.id, req.user.id]
);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json(rows[0]);
});We return 404 (not 403) so as not to reveal the existence of the other user's order. Satisfies ASVS V4.2.1 (object-level authorization).
BZN-140 — Path traversal in invoices (A01 → canonicalization and whitelist)
User input is never joined to a path without normalizing. The name is validated and the resolved path is checked to stay inside the allowed directory.
// orders.js — fixed
const INVOICES_DIR = '/var/bazarnube/invoices';
router.get('/api/invoices', auth, async (req, res) => {
const name = String(req.query.file ?? '');
if (!/^[0-9]{4}-[0-9]{6}\.pdf$/.test(name)) { // format whitelist
return res.status(400).json({ error: 'Invalid name' });
}
const full = path.resolve(INVOICES_DIR, name);
if (!full.startsWith(INVOICES_DIR + path.sep)) { // the path does not escape the directory
return res.status(400).json({ error: 'Path not allowed' });
}
// Also: check that the invoice belongs to req.user before serving it
res.sendFile(full);
});Double control: format whitelist and verification that the canonicalized path does not escape. Satisfies ASVS V12.3.1 / V12.3.2.
BZN-131 — SSRF in import product (A10 → destination validation)
The control against SSRF is not letting the user dictate where the server calls: validate scheme and domain against an allowlist and reject internal IPs.
// catalog.js — fixed
const ALLOWED_HOSTS = new Set(['images.bazarnube.com', 'cdn.supplier.com']);
router.post('/api/products/import', auth, async (req, res) => {
let url;
try { url = new URL(req.body.imageUrl); } catch { return res.status(400).end(); }
if (url.protocol !== 'https:' || !ALLOWED_HOSTS.has(url.hostname)) {
return res.status(400).json({ error: 'Origin not allowed' }); // blocks metadata, localhost, etc.
}
const resp = await fetch(url, { redirect: 'error' }); // do not follow redirects to internal targets
const buffer = Buffer.from(await resp.arrayBuffer());
res.json({ ok: true });
});The allowlist and blocking redirects prevent reaching 169.254.169.254 or internal services. Satisfies ASVS V12.6.1 (SSRF protection).
BZN-087 — Stored XSS in reviews (A03 → escape/sanitize output)
The flaw is dangerouslySetInnerHTML with user content. The ideal fix is to let React escape the text; if formatting is needed, it is sanitized with a library.
// ProductReviews.jsx — fixed
export function ProductReviews({ reviews }) {
return (
<ul>
{reviews.map((r) => (
<li key={r.id}>{r.body}</li> // React escapes by default: no injectable HTML
))}
</ul>
);
}If the business requires bold text or links, use DOMPurify.sanitize(r.body) with an allowlist of tags, never raw HTML. Satisfies ASVS V5.3.3 (contextual output encoding).
BZN-155 — Embedded and weak JWT secret (A02 → secrets management)
Secrets move out of the code and are loaded from the environment; a minimum length is enforced.
// config.js — fixed
const jwtSecret = process.env.JWT_SECRET; // injected by the secrets manager
if (!jwtSecret || jwtSecret.length < 32) {
throw new Error('JWT_SECRET missing or too short');
}
module.exports = {
jwtSecret,
jwtAlg: 'HS256',
db: { host: 'db', user: 'app', password: process.env.DB_PASSWORD, ssl: true },
cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
};The secret no longer lives in Git (gitleaks would also block it, 07-03) and the DB connection uses TLS. Satisfies ASVS V6.4.1 / V2.10.4 (secrets management).
BZN-041 and BZN-039 — Headers and cookies (A05 → helmet and flags)
A single change at bootstrap resolves both configuration records: helmet adds CSP and headers, and cookies are issued with all three flags.
// app.js — fixed
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"] } },
}));
// The session cookie is issued with the flags defined in config.cookie:
// res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax' });Satisfies ASVS V14.4.x (headers) and V3.4.x (cookie attributes).
BZN-060 — Leaks through verbose errors (A05 → error handling)
The client receives a generic message; the detail goes to the internal log (the basis for the detection in 03-11).
// app.js — fixed
app.use((err, req, res, next) => {
logger.error({ msg: err.message, stack: err.stack, reqId: req.id }); // detail only in the log
res.status(500).json({ error: 'Internal error', reqId: req.id }); // nothing sensitive to the client
});The reqId allows correlation without exposing anything. Satisfies ASVS V7.4.1 (do not leak sensitive information in errors).
Master table: finding → control → ASVS → verification
This table is the deliverable of the exercise: full traceability for each record, from problem to proof.
| Record | Category | Control implemented | ASVS requirement | How it is verified |
|---|---|---|---|---|
BZN-134 |
A03 | Parameterized query + validation | V5.3.4 | ZAP active: SQLi alert gone; q=' OR 1=1-- does not alter results |
BZN-101 |
A01 | user_id filter in the query |
V4.2.1 | Test with 2 users: A cannot see B's order (404) |
BZN-140 |
A01 | Whitelist + canonicalized path | V12.3.1 | ?file=../../etc/passwd returns 400; ZAP reports no traversal |
BZN-131 |
A10 | Host allowlist + no redirect | V12.6.1 | imageUrl=http://169.254.169.254/... returns 400 |
BZN-087 |
A03 | React escaping by default | V5.3.3 | Review <img onerror=...> shows as text, does not execute |
BZN-155 |
A02 | Secret in environment, minimum length | V6.4.1 | gitleaks finds no secrets; boot fails without JWT_SECRET |
BZN-041 |
A05 | helmet + CSP | V14.4.x | ZAP passive: "CSP Header Not Set" gone |
BZN-039 |
A05 | HttpOnly/Secure/SameSite flags | V3.4.x | Inspect Set-Cookie; ZAP does not flag insecure cookie |
BZN-060 |
A05 | Generic error + internal log | V7.4.1 | Trigger a 500: response without stack; detail is in the log |
Verification: re-scan and regression
Closing a record requires evidence that the fix works, not the developer's word. We apply two complementary checks:
- ZAP re-scan against staging with the fix deployed. We reuse the 06-04 baseline: the alerts that previously appeared (
SQL Injection,Path Traversal,CSP Header Not Set, insecure cookie, error disclosure) must disappear from the report. If one persists, the fix did not reach the scanned route. - Targeted tests for what ZAP cannot see: the IDOR (
BZN-101) is verified with two real users; the SSRF (BZN-131), by firing the request at an internal target and confirming the400; the secret (BZN-155), by running gitleaks in the pipeline.
These verification tests become regression tests: they enter the CI suite (07-03) so the flaw does not reappear in a future change. In BazarNube, every closed BZN-xxx leaves behind a test that watches it. That way today's fix is not undone tomorrow.
# Baseline re-scan in CI, reusing the 06-04 config
docker run -t ghcr.io/zaproxy/zaproxy zap-baseline.py \
-t https://staging.bazarnube.com \
-c zap-baseline.conf \
-r verification-report.html
# The pipeline fails if any alert we already closed reappearsCommon Mistakes and Tips
- Fixing the symptom. Escaping quotes by hand, putting a WAF in front, or filtering
../with areplaceare fragile patches. Attack the cause: parameterize, canonicalize, authorize. - Fixing without verifying. A fix without a re-scan or test is not finished; many "fixes" do not cover all routes or are deployed incorrectly.
- Forgetting defense in depth. Validating input is good, but it does not replace parameterizing the query. Combine controls when the cost is low.
- Leaving no regression test. Without a test to guard it, the flaw returns in the next refactor. Each closure should generate its test.
- Tip: remediate by class of flaw, not by instance. If
BZN-134was a SQLi from concatenation, look for the other concatenations in the code and fix them all; there are probably more of the same family.
Exercises
Exercise 1. The team wants to strengthen the already-parameterized search (BZN-134) with input validation. Write the validation (type and length) and explain why it is defense in depth and not a substitute for the parameterized query.
Exercise 2. For the SSRF (BZN-131), a colleague proposes "blocking URLs that contain localhost or 127.0.0.1" with a text filter. Explain why that allowlist-by-negation is insufficient and which approach is correct.
Exercise 3. After deploying the fixes, the ZAP re-scan still reports "CSP Header Not Set" on a specific route. List three possible causes and how you would investigate it.
Solutions
Solution 1. Validation:
It is defense in depth because it reduces the surface (rejects absurd inputs, caps length to prevent performance abuse), but it is not the protection against SQLi: even if an attacker sent a q that is valid in length but malicious in content, it is the parameterization that prevents it from being interpreted as SQL. Validation complements, it does not replace; relying on it alone (for example, with a blacklist of SQL keywords) would be evadable.
Solution 2. Filtering the text localhost/127.0.0.1 is a blacklist that is trivially evadable: there are 0.0.0.0, [::1], 127.0.0.2, the decimal IP 2130706433, DNS that resolves to an internal host (DNS rebinding), redirects to internal targets, and the metadata address 169.254.169.254. Enumerating the bad always leaves gaps. The correct approach is an allowlist of scheme (https) and permitted hosts, plus redirect: 'error' and, where possible, resolving the IP and rejecting private/link-local ranges. You allow the known-good; you do not chase the bad.
Solution 3. Three possible causes: (1) the fix does not cover that route -helmet is applied before a separately mounted router that responds without going through the middleware-; (2) a cached response or an intermediate proxy/CDN serves the old version without the header; (3) that route is served by another service (the legacy Java one) that does not carry helmet. Investigation: curl -I directly against the endpoint to see the real headers, check the order of middlewares in app.js, and verify whether the route is served by Express or the legacy backend.
Conclusion
We have closed the full cycle on the nine records: for each one, the control that attacks the cause, the ASVS requirement that backs it and the verification -ZAP re-scan or targeted test- that proves it works, plus the regression test that keeps it from reappearing. This is the real work of AppSec: finding is not enough, you have to remediate well and prove the remediation. Exercises 08-01 and 08-02 have walked the defensive cycle end to end on concrete code. Now we switch perspective: instead of preventing in the lab, in lesson 08-03 we investigate what happens when one of these vulnerabilities is not fixed in time and ends up in a real incident. We will analyze a data breach at BazarNube: its timeline, how it was detected, the root cause and which OWASP control would have prevented it.
OWASP Course: Guidelines and Standards for Web Application Security
Module 1: Introduction to OWASP
Module 2: Main OWASP Projects
- OWASP Top Ten
- OWASP ASVS (Application Security Verification Standard)
- OWASP SAMM (Software Assurance Maturity Model)
- OWASP ZAP (Zed Attack Proxy)
- Other Key Projects: WSTG, Cheat Sheets and Dependency-Check
Module 3: OWASP Top Ten 2021 in Depth
- A01:2021 – Broken Access Control
- A02:2021 – Cryptographic Failures and Sensitive Data Exposure
- A03:2021 – Injection
- Cross-Site Scripting (XSS) in Depth
- A04:2021 – Insecure Design
- A05:2021 – Security Misconfiguration
- XML External Entities (XXE)
- A06:2021 – Vulnerable and Outdated Components
- A07:2021 – Identification and Authentication Failures
- A08:2021 – Software and Data Integrity Failures (Insecure Deserialization)
- A09:2021 – Security Logging and Monitoring Failures
- A10:2021 – Server-Side Request Forgery (SSRF)
Module 4: OWASP ASVS (Application Security Verification Standard)
Module 5: OWASP SAMM (Software Assurance Maturity Model)
Module 6: OWASP ZAP (Zed Attack Proxy)
- Introduction to ZAP
- Installation and Configuration
- Vulnerability Scanning
- Automating Security Testing
Module 7: Best Practices and Recommendations
- Secure Software Development Life Cycle (SDLC)
- Threat Modeling
- Integrating Security into DevOps (DevSecOps)
- Security Training and Awareness
- Additional Tools and Resources
Module 8: Practical Exercises and Case Studies
- Exercise 1: Identifying Vulnerabilities
- Exercise 2: Implementing Security Controls
- Case Study 1: Analyzing a Security Incident
- Case Study 2: Improving the Security of a Web Application
