Capstone: Hardened Notes API
What changed
The project setup had deliberately vulnerable routes. This capstone shows the final version with every defense applied.
| Vulnerability | Defense | File |
|---|---|---|
| SQL injection | Parameterized queries | All db.prepare calls |
| LIKE injection | escapeLike function | notes.ts search route |
| Command injection | writeFile instead of exec | notes.ts export route |
| Header injection | Allowlisted header values | Any route setting headers from input |
| Stored/reflected XSS | escapeHtml on all HTML output | notes.ts view route |
| CSP | Nonce-based policy | app.ts onResponse |
| IDOR | Ownership check in every query | All notes.ts routes |
| Open redirects | Relative-path validation | auth.ts login redirect |
| SSRF | URL validation with DNS check | bookmarks.ts |
| Path traversal | path.resolve + startsWith | files.ts |
| Mass assignment | Zod schema / explicit field picking | notes.ts create route |
| ReDoS | No nested quantifiers, use Zod | All validation |
| Large payloads | Content-Length check | app.ts onRequest |
The hardened project structure
src/
app.ts # setup(), security headers, CSP, body size limit
server.ts # starts the server
db.ts # SQLite database, tables, seed data
sessions.ts # session store
cookies.ts # cookie helpers
auth.ts # authenticate function
escape.ts # escapeHtml, escapeLike
url-validator.ts # isUrlSafe for SSRF prevention
routes/
auth.ts # login with redirect validation
notes.ts # CRUD with IDOR checks, parameterized queries, XSS encoding
bookmarks.ts # URL fetching with SSRF protection
files.ts # file serving with path traversal protection The defense functions
Every defense is a small, reusable function:
// src/escape.ts
export function escapeHtml(input: string): string {
return input
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export function escapeLike(input: string): string {
return input.replace(/[%_\\]/g, "\\$&");
} // src/url-validator.ts (summarized)
export async function isUrlSafe(url: string): Promise<boolean> {
// 1. Parse the URL (reject invalid URLs)
// 2. Check protocol (only http/https)
// 3. Check hostname against blocklist (localhost, metadata endpoints)
// 4. Resolve DNS and check for private IPs
return true; // or false
} // Path traversal check (inline in route)
const filePath = path.resolve(UPLOADS_DIR, filename);
if (!filePath.startsWith(UPLOADS_DIR + path.sep)) {
return Response.json({ error: "Invalid filename" }, { status: 400 });
} // Open redirect check (inline in route)
function isSafeRedirect(url: string): boolean {
return url.startsWith("/") && !url.startsWith("//");
} Each defense is a few lines. No frameworks, no middleware. They are functions you call when you need them.
The hardened notes search route
This single route demonstrates four defenses:
route.get("/notes/search", {
resolve: (c) => {
const user = authenticate(c.request); // Authentication
if (user instanceof Response) return user;
const query = new URL(c.request.url).searchParams.get("q") ?? "";
const notes = db
.prepare("SELECT * FROM notes WHERE user_id = ? AND title LIKE ? ESCAPE '\\'")
.all(user.id, `%${escapeLike(query)}%`); // SQL injection + LIKE injection + IDOR
return Response.json(notes);
},
}); - Authentication:
authenticatechecks the session - IDOR:
user_id = ?ensures only the user’s notes are returned - SQL injection:
?placeholders, no concatenation - LIKE injection:
escapeLikeneutralizes%and_wildcards
What you learned
This course taught one principle applied twelve different ways: never trust input that crosses the trust boundary.
The specific defenses vary by context:
| Where the input goes | What can go wrong | How to fix it |
|---|---|---|
| SQL query | SQL injection | Parameterized queries |
| Shell command | Command injection | execFile with arg arrays, or avoid shell |
| HTTP header | Header injection | Validate/allowlist values |
| HTML page | XSS | Output encoding + CSP |
| Database query with ID | IDOR | Ownership check |
| Redirect URL | Open redirect | Validate path is relative |
| Server-side fetch | SSRF | Validate URL, block private IPs |
| File path | Path traversal | resolve + startsWith check |
| Database record | Mass assignment | Explicit field picking / Zod |
| Regex / JSON parser | DoS | Avoid nested quantifiers, limit body size |
Every row is the same idea: untrusted input reaches an interpreter or system, and the fix is to validate, encode, or restrict it before it gets there.
Challenges
Challenge 1: Add rate limiting to the search endpoint. An attacker could use the search endpoint for enumeration (trying different queries to discover note contents). Apply the rate limiter from the Securing Your API course.
Challenge 2: Add an audit log. Log every security-relevant event (failed SQL injection attempts, IDOR attempts, SSRF blocks, path traversal blocks). Use the structured logging pattern from the Securing Your API course.
Challenge 3: Run a security scan. Use an open-source tool like OWASP ZAP to scan your app. Compare its findings with the vulnerabilities you fixed. Did it find anything you missed?
Challenge 4: Add Content-Type validation on file uploads. The path traversal lesson secured file downloads. Add a file upload route that validates the file’s MIME type (do not trust the Content-Type header — check the file’s magic bytes).
What is the single principle behind every defense in this course?
Which defense from this course is used the most often (appears in the most routes)?