Most attacks against companies go through the software they wrote themselves. The OWASP Top 10 is the long-running list of the web application weaknesses that actually get exploited, and it is remarkably stable: the same injection, access control and misconfiguration bugs appear year after year, because they come from how code is naturally written under deadline. This module takes the categories that matter most to an engineer, shows how each one works from the attacker's side, and gives the fix that holds — not a filter that blocks yesterday's payload, but a design that removes the bug class.
- Explain how injection, broken access control, SSRF, XSS and CSRF work and recognise them in code
- Apply the fix that removes each bug class: parameterised queries, server-side authorisation, allow-lists, output encoding, same-site cookies
- Configure the security headers and framework defaults that make whole categories harder to exploit
- Validate input and handle errors without leaking information
- Add security tests and reviews to the normal development flow
Injection: data that becomes code
Injection happens whenever untrusted input is concatenated into something that is then interpreted: a SQL query, a shell command, an LDAP filter, a template, an OS path. The interpreter cannot tell where your code ends and the attacker's data begins. SQL injection remains the classic: a login form that builds ... WHERE user = '" + name + "' turns the input ' OR 1=1 -- into a query that matches everyone.
# VULNERABLE: string formatting builds the query
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
# SAFE: parameterised query; the driver sends the value separately from the SQL text
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
# SAFE with an ORM (SQLAlchemy): the same principle, expressed as objects
session.execute(select(User).where(User.email == email))
# Shell: never build a command string; pass an argument list and do not use shell=True
subprocess.run(["convert", filename, "-resize", "200x200", out], check=True)The fix is structural, not a blocklist: use parameterised queries or an ORM for every query, argument lists for every subprocess, safe APIs for LDAP and XML, and a templating engine that escapes by default. Input validation (is this a valid email, is this integer in range) is still worth doing, but it is a second layer; escaping and parameterisation are the layer that holds when validation misses something.
Escaping quotes in a query string is not a fix; it is a fragile filter with encoding edge cases. If you see a query built by concatenation anywhere, rewrite it with parameters, even if "the input is validated".
Broken access control
The most common category in real assessments is not injection but broken access control: the code verifies that a caller is logged in and then trusts the identifiers they send. Change /api/orders/1042 to /api/orders/1043 and read another customer's order (insecure direct object reference). Send "role": "admin" in a profile update and become one (mass assignment). Call an admin endpoint that was only hidden in the UI (missing function-level check).
- Deny by default: every endpoint declares who may call it, and unlisted means forbidden.
- Authorise at the data layer: queries are scoped by the caller's identity, so foreign ids do not exist.
- Bind request bodies to explicit allow-lists of fields; never map arbitrary JSON onto a model.
- Enforce on the server; the client's hidden buttons and disabled fields are not controls.
- Test it: for every endpoint, a test that calls it as the wrong user and expects a 403 or 404.
from pydantic import BaseModel, EmailStr
class ProfileUpdate(BaseModel):
display_name: str | None = None
email: EmailStr | None = None
# note: no 'role', no 'is_admin', no 'credit_balance'
model_config = {"extra": "forbid"} # unknown fields -> 422, not silently accepted
@app.patch("/me")
def update_me(body: ProfileUpdate, caller: User = Depends(current_user)):
for field, value in body.model_dump(exclude_unset=True).items():
setattr(caller, field, value)
db.commit()The browser side: XSS and CSRF
Cross-site scripting (XSS) is injection into HTML: untrusted data rendered into a page without encoding becomes script that runs with the victim's session. Stored XSS (a comment containing <script>) hits every viewer; reflected XSS arrives in a crafted link; DOM XSS happens entirely in client-side code that writes input into innerHTML. The fix is contextual output encoding, which modern template engines do by default — the vulnerability appears where someone turns that off (|safe, dangerouslySetInnerHTML, string-built HTML). A strict Content Security Policy is the second layer: with no inline scripts allowed and script sources restricted, an injected <script> tag does nothing.
Cross-site request forgery (CSRF) exploits the browser's habit of attaching cookies to every request: a form on an attacker's page posts to your /transfer endpoint and the victim's session cookie goes along. Defences: SameSite=Lax (or Strict) on session cookies, which modern browsers default to; a per-session anti-CSRF token on state-changing forms; and requiring a custom header or JSON content type for API calls, which cross-site forms cannot set. Never perform state changes on GET.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-R4nd0m'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/Start CSP in report-only mode (Content-Security-Policy-Report-Only) with a reporting endpoint. You will discover inline scripts and third-party tags you did not know about, fix them, then enforce.
Server-side request forgery and the cloud
Server-side request forgery (SSRF) makes your server fetch a URL the attacker chooses: a "fetch this image" feature, a webhook tester, a PDF renderer that loads remote resources. Because the request originates inside your network, it reaches things the attacker cannot: internal admin panels, databases with HTTP interfaces, and, most dangerously in the cloud, the instance metadata service at 169.254.169.254, which hands out the instance's credentials. Several major breaches began exactly this way.
- Allow-list destinations by host, not by pattern; if users must supply URLs, resolve the name and reject private, loopback, link-local and metadata ranges — after redirects too.
- Make outbound requests from a separate, egress-restricted component with no cloud role, or through a proxy that enforces the allow-list.
- Require the metadata service's hardened mode (session tokens, hop limit 1) so a simple forwarded GET cannot read credentials.
- Do not return the raw response body or headers to the user; leak less.
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
ALLOWED_HOSTS = {"images.partner.example", "cdn.example.com"}
def safe_fetch(url: str) -> bytes:
u = urlparse(url)
if u.scheme != "https" or u.hostname not in ALLOWED_HOSTS:
raise ValueError("destination not allowed")
for info in socket.getaddrinfo(u.hostname, 443):
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError("resolves to an internal address")
r = httpx.get(url, follow_redirects=False, timeout=5.0)
r.raise_for_status()
return r.content[:5_000_000]Misconfiguration, dependencies and the everyday bugs
Several Top 10 categories are less about code than about settings and habits. Security misconfiguration: debug mode in production, default credentials, directory listings, verbose stack traces, permissive CORS (Access-Control-Allow-Origin: * with credentials), sample apps left deployed. Vulnerable and outdated components: the one library with a known remote code execution in a dependency tree of eight hundred. Identification and authentication failures: weak password rules, missing rate limits, session ids that never rotate after login. Logging and monitoring failures: the attack was visible in logs nobody read.
| Category | Check that catches it | Fix that holds |
|---|---|---|
| Misconfiguration | Config review; scanner (ZAP) against staging | Hardened framework defaults; no debug in prod; CORS allow-list |
| Vulnerable components | Dependency scanning in CI (pip-audit, npm audit, Trivy) | Pin, update on a cadence, remove unused libraries |
| Auth failures | Login rate-limit test; session rotation test | Framework auth; MFA; rotate session on login |
| Logging failures | Try an attack in staging; did anything alert? | Structured security events; alerts on auth anomalies |
Handle errors so that they inform you and not the attacker: log the full detail with a correlation id, return a generic message with that id to the user. Validate input at the boundary with a schema and reject what does not fit; treat file uploads as hostile (size cap, type checked by content, stored outside the web root with a random name, served with a fixed content type). Rate-limit anything that can be brute-forced or used to exhaust resources.
Frameworks do most of this for you when you use them as designed: ORM queries, template auto-escaping, built-in CSRF protection, secure session cookies. The bugs appear where someone bypasses the framework "for flexibility".
Making it routine
Application security works when it is part of the normal flow rather than an annual event. In code review, look for the patterns: concatenated queries, raw HTML rendering, endpoints without an authorisation decorator, URL fetches, new dependencies. In CI, run static analysis (Semgrep with the OWASP rulesets, CodeQL) and dependency checks on every pull request, and a dynamic scan (ZAP) against staging on a schedule. Write security tests the same way you write functional ones: the wrong-user test for every resource, the injection payload test for every query parameter, the missing-header test for every response.
def test_cannot_read_another_users_order(client, user_a_token, user_b_order):
r = client.get(f"/orders/{user_b_order.id}", headers={"Authorization": f"Bearer {user_a_token}"})
assert r.status_code == 404
def test_search_is_not_injectable(client, user_a_token):
payload = "' OR 1=1 --"
r = client.get("/orders", params={"q": payload}, headers={"Authorization": f"Bearer {user_a_token}"})
assert r.status_code == 200
assert r.json()["items"] == [] # treated as a literal string, matches nothingFinally, practise on purpose: free deliberately vulnerable applications and lab platforms let you exploit each category safely, and there is no substitute for having seen a payload work. An engineer who has once pulled cloud credentials out of a metadata endpoint through an image-fetch feature never writes an unrestricted URL fetcher again.