Education › Security › Stage 2: Securing systems

Application security & the OWASP Top 10

Injection, broken access control, SSRF and friends — how each works and the fix that actually holds.

Intermediate ~40 min read Module 7 of 16

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.

After this module you can
  • 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 versus safe. Parameters keep data as data; the database never parses it as SQL.
python
# 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.

Watch out

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.
An explicit schema for updates stops mass assignment: fields not in the model are ignored or rejected, and privileged fields are simply not there.
python
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.

Security headers that remove or blunt whole classes of browser-side attack. Set them at the framework or the edge.
text
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=/
Tip

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.

INSIDE THE NETWORK1. submit URL2. server fetches itor the admin panel3. credentials returnedfix: delegateonly allow-listed hostsAttackerurl=http://169.254...App: fetch(url)has a cloud roleMetadata service169.254.169.254Internal adminno auth, private IPSafe fetcherallow-list, no roleApproved hostcdn.example.com
Server-side request forgery: the attacker cannot reach the metadata service or the internal admin panel directly, so they make your server fetch it for them. The fix is a fetcher that allow-lists destinations, blocks internal ranges after DNS resolution, and holds no cloud role.
  • 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.
Validating a user-supplied URL before fetching: scheme, resolved address ranges, and no automatic redirects.
python
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.

CategoryCheck that catches itFix that holds
MisconfigurationConfig review; scanner (ZAP) against stagingHardened framework defaults; no debug in prod; CORS allow-list
Vulnerable componentsDependency scanning in CI (pip-audit, npm audit, Trivy)Pin, update on a cadence, remove unused libraries
Auth failuresLogin rate-limit test; session rotation testFramework auth; MFA; rotate session on login
Logging failuresTry 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.

Note

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.

Two security tests that belong next to the functional ones.
python
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 nothing

Finally, 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.

Hands-on practice

Find, exploit and fix in a safe target

  1. Run a deliberately vulnerable app locally (OWASP Juice Shop via Docker is a good choice) and complete the challenges for SQL injection, an access control flaw and a reflected XSS. Note the payload that worked in each case.
  2. In your own project (or the zero-to-production API), add the ProfileUpdate-style schema with extra: forbid to one update endpoint, and write the wrong-user 404 test for one resource endpoint.
  3. Add the six security headers from the lesson in report-only mode for CSP. Load the app, read the violation reports, fix or allow-list what you find, then switch CSP to enforce.
  4. Grep the codebase for f"SELECT, format(, shell=True, |safe, innerHTML and dangerouslySetInnerHTML. For each hit decide: safe by construction, or rewrite.
  5. Add semgrep --config p/owasp-top-ten and a dependency audit (pip-audit or npm audit --audit-level=high) to CI, and make one deliberately vulnerable commit to confirm they fail the build.
  6. Run ZAP's baseline scan against your staging URL and triage the report: true positive, false positive, accepted.
Cheat sheet

Application security & the OWASP Top 10 — at a glance

Main things to focus on

  • Injection: parameterise every query, argument lists for every command, escape-by-default templates
  • Access control: deny by default, authorise at the data layer, explicit field allow-lists, wrong-user tests
  • XSS: contextual output encoding plus a strict CSP; CSRF: SameSite cookies plus tokens; no state change on GET
  • SSRF: allow-list destinations, block private and metadata ranges after resolution, egress-restricted fetcher
  • Misconfiguration and dependencies: hardened defaults, no debug in prod, scan and update on a cadence
  • Security tests and scanners in the same pipeline as functional tests

Injection fixes

cursor.execute("... WHERE x = %s", (val,))Parameterised query; data never parsed as SQL
subprocess.run([cmd, arg1, arg2], check=True)Argument list, no shell=True
ORM query buildersParameterised by construction
never: f-strings / concatenation into SQL, shell, LDAP, XPathThe bug pattern to grep for

Access control

deny by default; explicit @requires(role)Unlisted endpoint is forbidden
WHERE id = ? AND owner_id = caller.idForeign ids do not exist; return 404
schema with extra=forbid, no privileged fieldsStops mass assignment
test: call as wrong user -> 403/404One per resource endpoint
server enforces; UI hiding is not a controlAPIs are called directly

Browser-side

auto-escaping templates; avoid |safe, innerHTMLContextual output encoding
Content-Security-Policy: script-src 'self' 'nonce-...'Injected scripts do not run
Set-Cookie: HttpOnly; Secure; SameSite=LaxSession cookie defaults
anti-CSRF token on forms; custom header on APIsCross-site forms cannot forge
Strict-Transport-Security; X-Content-Type-Options: nosniffHTTPS only; no MIME sniffing

SSRF and uploads

allow-list hostnames; https onlyDo not accept arbitrary URLs
reject private, loopback, link-local, 169.254.169.254Check after DNS resolution and per redirect
follow_redirects=False, timeout, size capBound the fetch
IMDSv2 required (hop limit 1)Metadata credentials need a token
uploads: size cap, content-sniffed type, random name, outside web rootTreat files as hostile

Pipeline

semgrep --config p/owasp-top-tenStatic analysis on every PR
pip-audit / npm audit --audit-level=high / trivy fs .Known-vulnerable dependencies
zap-baseline.py -t https://staging.example.comDynamic scan on a schedule
review grep: format( SELECT, shell=True, |safe, innerHTMLThe patterns reviewers look for

Common pitfalls

  • Escaping quotes instead of parameterising, then being bypassed by an encoding you did not think of.
  • Checking authentication and trusting the ids and fields the client sends.
  • Marking template output as safe to render user content "just this once".
  • Accepting any URL for a fetch feature and forgetting that the cloud metadata endpoint is a URL.
  • Running debug mode or verbose errors in production because it helped once during an incident.
  • Treating the annual penetration test as the security process instead of tests and scanners on every change.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →