CMP-420 · Lesson 9 of 12 · 34 min

Secure software II: web application security

Web applications are where most organizations meet the internet, and where most attacks on them begin. They combine untrusted input from browsers, business logic, databases and third-party code, often written quickly by teams under pressure. This lesson works through the OWASP Top 10, the most widely used summary of web application risks, and for each category concentrates on the controls that prevent it: parameterized queries, output encoding, CSRF tokens, security headers, server-side URL allow-lists and dependency pinning. All examples show the safe pattern.

CMPUT-291 covered SQL injection and database permissions from the database side, and CMPUT-301 covered the secure development life cycle; CISSP-115 covered security testing programs. This lesson focuses on how a developer builds a web application that resists attack.

The OWASP Top 10 (2025)

The Open Worldwide Application Security Project (OWASP) publishes the Top 10 every few years from data contributed by testing firms and a community survey. The 2025 edition reorganized several categories: supply chain failures gained their own category, server-side request forgery was folded into broken access control, and a new category covers mishandling of exceptional conditions.

Access control and design (A01, A06)

Broken access control stays at number one because it is easy to introduce and hard to detect with generic scanners: the application works perfectly for honest users. The defenses come from Lesson 7: a central authorization layer that every request passes through, deny-by-default rules, object-level checks that tie each record to its owner, and automated tests that try to read and modify other users' data. Insecure design is the broader version of the same problem: a flow that is correct line by line but unsafe as a whole, such as a password reset that can be attempted unlimited times or a checkout that trusts a price sent by the browser. Threat modeling during design, covered in Lesson 1, is the main control.

Injection (A05)

Injection happens when untrusted data is mixed into a command or query that an interpreter parses, so that the data can change the structure of the command. The cure is to keep code and data separate. For SQL, use parameterized queries, also called prepared statements: the query text contains placeholders and the values are sent separately, so the database never parses them as SQL. An apostrophe in a surname is then just a character. Parts of a query that cannot be parameterized, such as column names for sorting, must come from a fixed allow-list.

Parameterized queries treat input purely as data; identifiers come from an allow-list
import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE students (id INTEGER PRIMARY KEY, surname TEXT, program TEXT)")
con.executemany("INSERT INTO students (surname, program) VALUES (?, ?)",
                [("O'Brien", "Computing"), ("Nguyen", "Biology"), ("Obi", "Computing")])
def find(surname: str):
    # Placeholders send the value separately from the SQL text, so it is never parsed as SQL
    return con.execute("SELECT id, surname, program FROM students WHERE surname = ?", (surname,)).fetchall()
print(find("O'Brien"))           # an apostrophe is just data
print(find("Nguyen"))
print(find("nobody"))
# Identifiers (column names) cannot be placeholders, so allow-list them instead
ALLOWED_SORT = {"surname", "program"}
def list_sorted(col: str):
    if col not in ALLOWED_SORT:
        raise ValueError("unsupported sort column")
    return [r[0] for r in con.execute(f"SELECT surname FROM students ORDER BY {col}, id")]
print(list_sorted("surname"))
try:
    list_sorted("surname; anything")
except ValueError as e:
    print("rejected:", e)
Output
[(1, "O'Brien", 'Computing')]
[(2, 'Nguyen', 'Biology')]
[]
['Nguyen', "O'Brien", 'Obi']
rejected: unsupported sort column

The same principle applies to other interpreters. For operating system commands, pass arguments as a list to subprocess and never use a shell to join strings; for LDAP, XPath and NoSQL queries, use the library's parameter mechanisms; for templates, never build a template from user input. Object-relational mappers parameterize by default, but most also offer raw-query escape hatches that reintroduce the risk.

Cross-site scripting and output encoding

Cross-site scripting (XSS) is injection into HTML: if a page includes user-supplied text without encoding it, the browser may interpret part of it as markup or script, running in the context of the site with access to the victim's session. The defense is context-aware output encoding at the point where data is inserted: HTML-escape text placed in element content, use attribute encoding for attributes, URL-encode values placed in links and JavaScript-encode values placed in scripts. Modern template engines such as Jinja2, Django templates and React's JSX escape by default; the risk returns wherever developers mark content as safe or build HTML by string concatenation.

Escaping comment text for HTML and URL-encoding a value for a link
import html
from string import Template
# Context-aware output encoding: data is escaped for the HTML context it lands in
PAGE = Template('<p class="comment">$body</p><a href="/profile?u=$user">profile</a>')
def render(body: str, user: str) -> str:
    from urllib.parse import quote
    return PAGE.substitute(body=html.escape(body), user=quote(user, safe=""))
print(render("Great lecture <3 & thanks", "ana lima"))
print(render('He said "use <b>bold</b>"', "o'brien"))
Output
<p class="comment">Great lecture &lt;3 &amp; thanks</p><a href="/profile?u=ana%20lima">profile</a>
<p class="comment">He said &quot;use &lt;b&gt;bold&lt;/b&gt;&quot;</p><a href="/profile?u=o%27brien">profile</a>

A Content Security Policy (CSP) adds a second layer: a response header telling the browser which script sources are allowed, so that even if markup is injected, inline or foreign scripts do not run. A strict CSP using nonces or hashes for allowed scripts is one of the most effective defenses against XSS, though it takes effort to retrofit to older applications.

A flow from untrusted input through validation at the entry point, then storage, then output. At output the data branches into four contexts, each with its own encoding: HTML body uses HTML escaping, an HTML attribute uses attribute encoding, a URL uses percent-encoding, and SQL uses parameter binding. A Content Security Policy shield sits at the browser as a second layer.
Validate on input, encode for each output context, and add a Content Security Policy as a second layer

Cross-site request forgery and cookies

Cross-site request forgery (CSRF) abuses the fact that browsers send cookies automatically: a page on another site can cause the victim's browser to submit a form to your site, and the request arrives with valid session cookies. Defenses include the SameSite cookie attribute, which stops cookies being sent on most cross-site requests (browsers now default to Lax), and anti-CSRF tokens: an unpredictable value bound to the session, embedded in each form and checked on submission. A request from another site cannot read the token, so it cannot include it. Requests that change state should never use GET.

A session-bound CSRF token verified in constant time, and recommended cookie attributes
import hmac, hashlib, secrets
SERVER_KEY = b"demo key; load from a secrets manager in real code"
def csrf_token(session_id: str) -> str:
    # Synchronizer token bound to the session (HMAC), so it cannot be reused across sessions
    return hmac.new(SERVER_KEY, session_id.encode(), hashlib.sha256).hexdigest()
def check(session_id: str, submitted: str) -> bool:
    return hmac.compare_digest(csrf_token(session_id), submitted)
s1, s2 = "session-A-demo", "session-B-demo"
t1 = csrf_token(s1)
print("form token for session A:", t1[:24] + "...")
print("valid token, same session :", check(s1, t1))
print("token from another session:", check(s2, t1))
print("missing token              :", check(s1, ""))
cookie = "sid=...; Secure; HttpOnly; SameSite=Lax; Path=/"
print("cookie attributes:", cookie.split("; ", 1)[1])
Output
form token for session A: a26eeb8c98583703b50eb468...
valid token, same session : True
token from another session: False
missing token              : False
cookie attributes: Secure; HttpOnly; SameSite=Lax; Path=/

Misconfiguration and headers (A02)

Many breaches need no clever attack at all: an administration panel with a default password, a storage bucket left public, directory listings turned on, stack traces shown to users or test endpoints left in production. Configuration should be defined as code, reviewed and applied automatically, with the same hardened baseline in every environment. Security response headers are a cheap part of that baseline: HTTP Strict Transport Security tells browsers to use HTTPS only, Content-Security-Policy restricts scripts and framing, X-Content-Type-Options stops content-type guessing, and Referrer-Policy limits what URLs leak to other sites.

Comparing a response's security headers with a baseline
# Checking a response's security headers against a baseline
baseline = {
    "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "strict-origin-when-cross-origin",
}
response = {"Content-Type": "text/html; charset=utf-8", "X-Content-Type-Options": "nosniff",
            "Strict-Transport-Security": "max-age=600", "Server": "ExampleServer/2.4.1"}
for name, expected in baseline.items():
    got = response.get(name)
    if got is None:
        print(f"MISSING  {name}")
    elif name == "Strict-Transport-Security" and int(got.split("=")[1].split(";")[0]) < 31536000:
        print(f"WEAK     {name}: {got} (use at least one year)")
    else:
        print(f"OK       {name}")
if "Server" in response and any(ch.isdigit() for ch in response["Server"]):
    print("ADVISE   remove version numbers from the Server header")
Output
MISSING  Content-Security-Policy
WEAK     Strict-Transport-Security: max-age=600 (use at least one year)
OK       X-Content-Type-Options
MISSING  Referrer-Policy
ADVISE   remove version numbers from the Server header

Server-side request forgery

Many applications fetch URLs on the server: link previews, webhooks, image imports and PDF generators. If the URL comes from a user, the server might be induced to request internal services that are not reachable from outside, such as cloud metadata endpoints or administration interfaces. OWASP 2025 places this server-side request forgery (SSRF) under broken access control. The defense is an allow-list of schemes and destination hosts, a check of the IP address the name actually resolves to, rejection of private, loopback and link-local ranges, and network egress rules so the fetching service cannot reach sensitive internal addresses even if the application check fails.

Allow-listing schemes and hosts for server-side fetches and refusing internal addresses
import ipaddress, socket
from urllib.parse import urlsplit
# Server-side URL fetching (for example, link previews): allow-list schemes and hosts, block internal addresses
ALLOWED_HOSTS = {"images.example.org", "cdn.example.net"}
def check_url(url: str, resolve=lambda h: "93.184.215.14") -> str:
    parts = urlsplit(url)
    if parts.scheme != "https":
        return "rejected: only https is allowed"
    if parts.hostname not in ALLOWED_HOSTS:
        return "rejected: host not on the allow-list"
    ip = ipaddress.ip_address(resolve(parts.hostname))      # check the address actually used
    if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
        return "rejected: resolves to an internal address"
    return f"allowed: {parts.hostname} -> {ip}"
print(check_url("https://images.example.org/cat.png"))
print(check_url("http://images.example.org/cat.png"))
print(check_url("https://intranet.local/admin"))
print(check_url("https://cdn.example.net/x", resolve=lambda h: "10.0.0.5"))
Output
allowed: images.example.org -> 93.184.215.14
rejected: only https is allowed
rejected: host not on the allow-list
rejected: resolves to an internal address

Supply chain and integrity (A03, A08)

A typical web application runs far more third-party code than first-party code: frameworks, libraries and their dependencies, base container images and build tools. Attackers increasingly target that supply chain, compromising a maintainer account, publishing look-alike package names, or tampering with a build system, as in the 2020 SolarWinds compromise and the 2024 discovery of a backdoor planted in the xz compression library. Defenses include lock files that pin exact versions and hashes, so a changed artifact is rejected; a software bill of materials (SBOM) listing every component, so you can answer quickly whether you are affected by a new vulnerability; automated dependency updates with review; signed releases verified before deployment; and reproducible, isolated builds. Integrity failures also include unsafe deserialization: formats such as Python's pickle can execute code while loading, so data from untrusted sources should use simple formats such as JSON and be validated against a schema.

Installing only artifacts whose hashes match the lock file
import hashlib
# Verifying a downloaded package against a pinned hash, as "pip install --require-hashes" does
lockfile = {"tinylib-1.4.2.tar.gz": "sha256:" + hashlib.sha256(b"tinylib source 1.4.2").hexdigest()}
def install(name: str, data: bytes) -> str:
    want = lockfile.get(name)
    if want is None:
        return f"refuse {name}: not in the lock file"
    got = "sha256:" + hashlib.sha256(data).hexdigest()
    return f"install {name}" if got == want else f"refuse {name}: hash mismatch"
print(install("tinylib-1.4.2.tar.gz", b"tinylib source 1.4.2"))
print(install("tinylib-1.4.2.tar.gz", b"tinylib source 1.4.2 (altered)"))
print(install("tinylib-9.9.9.tar.gz", b"unexpected new version"))
print("lock entry:", "tinylib==1.4.2 --hash=" + lockfile["tinylib-1.4.2.tar.gz"][:30] + "...")
Output
install tinylib-1.4.2.tar.gz
refuse tinylib-1.4.2.tar.gz: hash mismatch
refuse tinylib-9.9.9.tar.gz: not in the lock file
lock entry: tinylib==1.4.2 --hash=sha256:2cbc17f6ad8323763385390...

In the United States, Executive Order 14028 (2021) led to the NIST Secure Software Development Framework (SP 800-218) and a requirement that software sold to federal agencies come with a secure development attestation; SBOMs are widely requested in federal procurement. The FTC also expects reasonable security for web applications that handle consumer data.

Logging and exceptional conditions (A09, A10)

Detection and error handling complete the picture. Log security-relevant events (logins, failures, access denials, changes of privileges and data exports) with enough context to investigate, without logging secrets such as passwords or full tokens, and send alerts on suspicious patterns. Handle errors centrally so that every failure path fails closed: if an authorization service cannot be reached, deny access rather than allowing it, and show users a generic message while recording details server-side.

Worked examples

Worked problem

Fixing a search feature

A product search builds SQL as "SELECT * FROM products WHERE name LIKE '%" + term + "%' ORDER BY " + sort. Rewrite it safely.

Show solution
  1. Replace string concatenation for the search term with a placeholder: WHERE name LIKE ? with the parameter "%" + term + "%".
  2. The sort column cannot be a placeholder, so map the user's choice through an allow-list such as {"name": "name", "price": "price"} and reject anything else.
  3. Select only the columns needed instead of *, run the query as a database user with read-only permissions, and add a test with an apostrophe in the term.

Answer: Parameterize values, allow-list identifiers, select specific columns and use a least-privilege database account.

Worked problem

Hardening a comment page

A forum shows user comments by inserting them into HTML with string concatenation. What changes stop script injection and limit damage if one slips through?

Show solution
  1. Render comments through a template engine with autoescaping, or call html.escape on each comment before insertion.
  2. If rich text is needed, accept a limited markup language and sanitize the result with a maintained allow-list sanitizer.
  3. Add a strict Content-Security-Policy with script nonces, and set session cookies HttpOnly so scripts cannot read them.

Answer: Autoescape output, sanitize any allowed markup, deploy a strict CSP and use HttpOnly cookies.

Worked problem

Responding to a vulnerable dependency

A critical vulnerability is announced in a logging library. A team runs 40 services. How do an SBOM and lock files help?

Show solution
  1. Query the SBOMs for the library and affected versions to find every service that includes it, directly or through other packages.
  2. Update the pinned version in each lock file, regenerate hashes and let the pipeline rebuild and test each service.
  3. Deploy the fixed builds, then check runtime inventories to confirm no old version remains, and record the response.

Answer: SBOMs show where the component is used in minutes; lock files make the update precise and verifiable.

Common mistakes

  • MistakeEscaping quotes in input prevents SQL injection.

    InsteadUse parameterized queries; manual escaping misses cases and differs between databases.

  • MistakeInput validation alone prevents cross-site scripting.

    InsteadEncode output for its context; validation cannot anticipate every place data is displayed.

  • MistakeHiding an admin URL protects it.

    InsteadEnforce authorization on the server for every request.

  • MistakeUsing a popular library means the supply chain is safe.

    InsteadPin versions with hashes, keep an SBOM and review updates; popular packages are prime targets.

  • MistakeDetailed error messages help users and are harmless.

    InsteadShow generic messages, log details server-side and fail closed.

Summary and key terms

  • The OWASP Top 10 (2025) is led by broken access control, misconfiguration and supply chain failures.
  • Keep code and data separate: parameterized queries, argument lists for commands, allow-lists for identifiers.
  • Prevent XSS with context-aware output encoding and add a strict Content Security Policy.
  • Stop CSRF with SameSite cookies and session-bound tokens; never change state on GET.
  • Harden configuration as code and send security headers such as HSTS and CSP.
  • Prevent SSRF with scheme and host allow-lists, resolved-address checks and egress rules.
  • Secure the supply chain with lock files, hashes, SBOMs and signatures, and avoid unsafe deserialization.
Injection
A flaw where untrusted data changes the structure of a command or query.
Parameterized query
A query whose values are passed separately from its text, so they are never parsed as code.
Cross-site scripting
Injection of markup or script into pages viewed by other users.
Output encoding
Converting data so it is treated as text in a specific output context.
Content Security Policy
A response header restricting which scripts and resources a page may load.
CSRF
Cross-site request forgery: tricking a browser into sending authenticated requests to another site.
SameSite
A cookie attribute that limits sending cookies on cross-site requests.
SSRF
Server-side request forgery: inducing a server to fetch URLs chosen by an attacker.
SBOM
Software bill of materials: a list of all components in a piece of software.
Lock file
A file pinning exact dependency versions and hashes for reproducible installs.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Which category tops the OWASP Top 10 (2025)?

    Show answer

    A01 Broken Access Control.

  2. Why can an apostrophe not break a parameterized query?

    Show answer

    The value is sent separately and treated as data, never parsed as SQL.

  3. How should a sort column chosen by the user be handled?

    Show answer

    Map it through a fixed allow-list of permitted column names.

  4. What does html.escape do to < and &?

    Show answer

    It converts them to &lt; and &amp; so they display as text.

  5. How does a CSP limit damage from XSS?

    Show answer

    It tells the browser to refuse scripts from sources not explicitly allowed.

  6. Why can a malicious site not include a valid CSRF token?

    Show answer

    It cannot read pages from your site, so it cannot learn the session-bound token.

  7. What does HSTS tell the browser?

    Show answer

    To use only HTTPS for the site for the specified period.

  8. Name two checks against SSRF.

    Show answer

    Allow-list destination hosts and reject resolved private, loopback or link-local addresses.

  9. Why avoid pickle for untrusted data?

    Show answer

    Loading a pickle can execute code; use JSON with schema validation instead.

  10. What question does an SBOM answer during an incident?

    Show answer

    Which of our systems contain the affected component and version.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz