Project Setup
The app we are building
We need a realistic app to attack. We will build a notes and bookmarks API with users, notes (title, body, tags), bookmarks (URLs with auto-fetched titles), file attachments, and an admin role. This gives us enough surface area for every vulnerability: text fields for XSS, database queries for SQL injection, URL fetching for SSRF, file handling for path traversal, and role-based access for IDOR and mass assignment.
Create the project
mkdir web-security-course
cd web-security-course
npm init -y
npm install @hectoday/http zod srvx better-sqlite3 bcryptjs
npm install -D typescript @types/node @types/better-sqlite3 @types/bcryptjs tsx We use better-sqlite3 instead of in-memory Maps. SQL injection requires a real SQL database — you cannot inject SQL into a Map.get() call.
[!NOTE] If you completed the “SQLite with Turso” course, you know SQL databases. If not, do not worry — the SQL in this course is simple:
SELECT,INSERT,UPDATE,DELETE, and basicWHEREclauses. We explain every query.
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "./src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"]
} The database
Create src/db.ts:
// src/db.ts
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const db = new Database("app.db");
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
);
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
url TEXT NOT NULL,
title TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// Seed data
const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get("[email protected]");
if (!existingUser) {
const hash = bcrypt.hashSync("password123", 10);
db.prepare("INSERT INTO users (id, email, name, password_hash, role) VALUES (?, ?, ?, ?, ?)").run(
"user-1",
"[email protected]",
"Alice",
hash,
"user",
);
db.prepare("INSERT INTO users (id, email, name, password_hash, role) VALUES (?, ?, ?, ?, ?)").run(
"admin-1",
"[email protected]",
"Admin",
bcrypt.hashSync("admin123", 10),
"admin",
);
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
"note-1",
"user-1",
"My First Note",
"This is a test note.",
"personal,test",
);
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
"note-2",
"user-1",
"Shopping List",
"Milk, eggs, bread",
"shopping",
);
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
"note-3",
"admin-1",
"Admin Notes",
"Secret admin content",
"admin",
);
}
export default db; Two users (Alice and Admin), three notes. Alice should only see her own notes. The admin’s note should be private. We will test this boundary.
Session, cookie, and auth helpers
Same patterns as the auth courses, condensed:
// src/sessions.ts
const store = new Map<string, { userId: string; createdAt: number }>();
const MAX_AGE = 24 * 60 * 60 * 1000;
export function createSession(userId: string): string {
const id = crypto.randomUUID();
store.set(id, { userId, createdAt: Date.now() });
return id;
}
export function getSession(id: string) {
const s = store.get(id);
if (!s) return undefined;
if (Date.now() - s.createdAt > MAX_AGE) {
store.delete(id);
return undefined;
}
return s;
} // src/cookies.ts
export function parseCookies(h: string | null): Record<string, string> {
if (!h) return {};
const c: Record<string, string> = {};
for (const p of h.split(";")) {
const [n, ...r] = p.trim().split("=");
if (n) c[n] = r.join("=");
}
return c;
}
export function getSessionId(req: Request) {
return parseCookies(req.headers.get("cookie"))["session"];
}
export function sessionCookie(id: string) {
return `session=${id}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400`;
} // src/auth.ts
import db from "./db.js";
import { getSessionId } from "./cookies.js";
import { getSession } from "./sessions.js";
export interface User {
id: string;
email: string;
name: string;
role: string;
}
export function authenticate(request: Request): User | Response {
const sid = getSessionId(request);
if (!sid) return Response.json({ error: "Unauthorized" }, { status: 401 });
const session = getSession(sid);
if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
const user = db
.prepare("SELECT id, email, name, role FROM users WHERE id = ?")
.get(session.userId) as User | undefined;
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
return user;
} The notes routes (deliberately vulnerable)
This is the important part. We write routes with intentional vulnerabilities that we attack and fix in the coming lessons.
// src/routes/notes.ts
import { route, group } from "@hectoday/http";
import db from "../db.js";
import { authenticate } from "../auth.js";
export const notesRoutes = group([
// VULNERABLE: SQL injection via string concatenation
route.get("/notes/search", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const query = new URL(c.request.url).searchParams.get("q") ?? "";
// DELIBERATELY VULNERABLE
const notes = db
.prepare(`SELECT * FROM notes WHERE user_id = '${user.id}' AND title LIKE '%${query}%'`)
.all();
return Response.json(notes);
},
}),
// VULNERABLE: IDOR — no ownership check
route.get("/notes/:id", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
// DELIBERATELY VULNERABLE — does not verify note belongs to user
const note = db.prepare("SELECT * FROM notes WHERE id = ?").get(c.params.id);
if (!note) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(note);
},
}),
// VULNERABLE: Mass assignment — user can set user_id
route.post("/notes", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
const id = crypto.randomUUID();
// DELIBERATELY VULNERABLE — body.user_id can override ownership
db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
id,
body.user_id ?? user.id,
body.title,
body.body,
body.tags ?? "",
);
return Response.json({ id }, { status: 201 });
},
}),
]); [!WARNING] These routes are deliberately vulnerable. They contain SQL injection, IDOR, and mass assignment bugs. We attack and fix each one in the coming lessons. Do not use these patterns in real code.
Login route and app shell
// src/routes/auth.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import bcrypt from "bcryptjs";
import db from "../db.js";
import { createSession } from "../sessions.js";
import { sessionCookie } from "../cookies.js";
const LoginBody = z.object({ email: z.email(), password: z.string().min(1) });
export const authRoutes = group([
route.post("/login", {
request: { body: LoginBody },
resolve: async (c) => {
if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
const { email, password } = c.input.body;
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any;
if (!user) return Response.json({ error: "Invalid credentials" }, { status: 401 });
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return Response.json({ error: "Invalid credentials" }, { status: 401 });
const sid = createSession(user.id);
return Response.json(
{ user: { id: user.id, email: user.email, name: user.name, role: user.role } },
{ headers: { "set-cookie": sessionCookie(sid) } },
);
},
}),
]); // src/app.ts
import { setup, route } from "@hectoday/http";
import { authRoutes } from "./routes/auth.js";
import { notesRoutes } from "./routes/notes.js";
export const app = setup({
routes: [
route.get("/health", { resolve: () => Response.json({ status: "ok" }) }),
...authRoutes,
...notesRoutes,
],
}); // src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 }); Add to package.json: "type": "module" and "scripts": { "dev": "tsx watch src/server.ts" }.
Verify it works
npm run dev
# Log in as Alice
curl -c cookies.txt -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"password123"}'
# Search notes
curl -b cookies.txt "http://localhost:3000/notes/search?q=test" You should see Alice’s note. In the next lesson, we break the search.
Exercises
Exercise 1: Log in as Alice and use GET /notes/note-3 (the admin’s note). Does it return the admin’s data? It should — that is the IDOR bug.
Exercise 2: Look at the search route. Can you spot where the SQL injection happens before reading the next lesson?
Why do we use SQLite instead of in-memory Maps in this course?