hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Web Security Fundamentals with @hectoday/http

The Attacker's Mindset

  • Thinking Like an Attacker
  • Project Setup

Injection Attacks

  • SQL Injection
  • SQL Injection: Beyond the Basics
  • Command Injection
  • Header Injection

Cross-Site Scripting (XSS)

  • What Is XSS?
  • Output Encoding
  • Content Security Policy in Practice

Broken Access and Redirects

  • Insecure Direct Object References (IDOR)
  • Open Redirects
  • Server-Side Request Forgery (SSRF)

File and Data Handling

  • Path Traversal
  • Mass Assignment
  • Denial of Service via Input

Putting It All Together

  • Security Testing
  • The OWASP Top 10
  • Capstone: Hardened Notes API

Mass Assignment

The laziest vulnerability

Mass assignment happens when you accept whatever the client sends and use it directly. The developer intends for the user to set title and body. The attacker also sends user_id or role and the server happily stores it.

This is a different kind of vulnerability from the previous ones. There is no code injection and no path manipulation. The attacker simply sends extra fields that the server should not accept.

The vulnerable code

Look at the note creation route from the project setup:

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 });
  },
});

The line body.user_id ?? user.id is the problem. If the client sends a user_id field, it overrides the authenticated user’s ID.

The attack

Alice creates a note but sets user_id to the admin’s ID:

curl -b cookies.txt -X POST http://localhost:3000/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"Sneaky Note","body":"Created by Alice, owned by admin","user_id":"admin-1"}'

The note is created with user_id: "admin-1". Now the admin sees a note they did not create. Depending on the app, this could let Alice inject content into the admin’s view, or bypass ownership checks on shared resources.

In a more dangerous scenario, imagine a user profile update route:

// VERY VULNERABLE — do not do this
route.put("/me", {
  resolve: async (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    const body = await c.request.json();

    // Updates whatever fields the client sends, including role!
    db.prepare("UPDATE users SET name = ?, role = ? WHERE id = ?").run(
      body.name ?? user.name,
      body.role ?? user.role,
      user.id,
    );

    return Response.json({ message: "Updated" });
  },
});

The attacker sends {"name": "Alice", "role": "admin"} and promotes themselves to admin.

The fix: explicit field picking

Never spread or passthrough the entire request body. Pick only the fields you expect:

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();

    // SAFE: only pick title, body, and tags. user_id comes from the session.
    const title = body.title;
    const noteBody = body.body;
    const tags = body.tags ?? "";

    if (!title || !noteBody) {
      return Response.json({ error: "Title and body are required" }, { status: 400 });
    }

    db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
      id,
      user.id,
      title,
      noteBody,
      tags,
    );

    return Response.json({ id }, { status: 201 });
  },
});

The user_id always comes from the authenticated session, never from the request body. Even if the attacker sends user_id, it is ignored.

Zod makes this easier

Zod schemas strip unknown fields by default. Define a schema with only the allowed fields:

const CreateNoteBody = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
  tags: z.string().optional().default(""),
});

route.post("/notes", {
  request: { body: CreateNoteBody },
  resolve: (c) => {
    const user = authenticate(c.request);
    if (user instanceof Response) return user;

    if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });

    const { title, body: noteBody, tags } = c.input.body;
    const id = crypto.randomUUID();

    db.prepare("INSERT INTO notes (id, user_id, title, body, tags) VALUES (?, ?, ?, ?, ?)").run(
      id,
      user.id,
      title,
      noteBody,
      tags,
    );

    return Response.json({ id }, { status: 201 });
  },
});

Zod validates that the body matches the schema and discards any extra fields. If the attacker sends user_id, Zod ignores it because user_id is not in the schema.

The rule

Never use raw request bodies for database operations. Either pick fields explicitly or use a validation schema that defines exactly which fields are accepted. Server-controlled fields (user_id, role, created_at) should never come from the client.

Exercises

Exercise 1: Before fixing, try creating a note with user_id: "admin-1". Verify the note is owned by the admin.

Exercise 2: Apply the explicit field picking fix. Try the same attack. The user_id should be ignored, and the note should be owned by Alice.

Exercise 3: Add a Zod schema. Send {"title":"Test","body":"Body","role":"admin","user_id":"admin-1"}. Verify both extra fields are silently discarded.

Why is mass assignment dangerous even when the database requires specific columns?

← Path Traversal Denial of Service via Input →

© 2026 hectoday. All rights reserved.