The Hotel Upgrade Hack: Mass Assignment Vulnerabilities in APIs
One extra JSON field in an API request can hand attackers admin privileges. Mass assignment vulnerabilities occur when a server binds user-supplied data directly to an internal model without filtering which fields the user is allowed to set. This is a common, often overlooked security flaw in web APIs.
How It Works
In a Rails application, the dangerous pattern looks like this:
# Dangerous — creates a user from whatever the client sends
User.create(params)
In Node.js / TypeScript:
// Dangerous
const user = await db.insert(users).values(req.body);
// Also dangerous
Object.assign(existingUser, req.body);
Suppose your users table has columns like email, name, passwordHash, isAdmin, and role. A normal sign-up request sends:
{ "email": "user@example.com", "name": "User", "password": "secret" }
An attacker sends:
{ "email": "attacker@example.com", "name": "Attacker", "password": "hunter2", "isAdmin": true, "role": "superuser" }
If the server binds req.body directly, both requests succeed. The attacker now has an admin account.
Real-World Impact: GitHub 2012
In March 2012, security researcher Egor Homakov exploited a mass assignment vulnerability in GitHub. He injected organization_id into a form POST meant only for updating his own profile, adding himself as a member of the rails/rails organization without invitation. GitHub suspended his account initially, then reinstated it and acknowledged the bug. This incident led Rails to introduce strong_parameters for explicit field whitelisting.
Why It's Dangerous
- Privilege escalation: Attackers set
isAdmin: true,role: "admin",subscriptionTier: "enterprise". - Data corruption: Overwriting
createdAt,updatedAt, or audit fields breaks tamper-evident logging. - Multi-tenancy bypass: Injecting
siteIdallows writing data into another tenant's namespace or escaping rate limits. - Subtle escalation: Dangerous fields aren't always
isAdmin; they can beplanId,ownerId,verifiedAt,emailVerified, orstripeCustomerId.
Three Concrete Defense Layers
The article presents a defense-in-depth approach using a real-world schema from the author's project, Lumibase.
Layer 1: Zod Strips Unknown Keys at the Boundary
Parse every incoming request through a Zod schema. By default, z.object() strips unknown keys during parsing.
import { z } from 'zod';
export const createItemSchema = z.object({
collectionId: z.string().min(1),
data: z.record(z.unknown()),
status: z.enum(['draft', 'review']).default('draft'),
// Notice: no siteId, no createdBy, no publishedAt, no id
});
In the route handler, parse the body:
const raw = await c.req.json();
const input = createItemSchema.parse(raw);
// input is now typed as CreateItemInput — no isAdmin, no siteId, no createdBy
const item = await itemService.createItem(c.get('tenant').siteId, c.get('user').id, input);
siteId and createdBy are injected from middleware context, not the request body.
Layer 2: Typed DTOs in the Service Layer
Define strict DTO types that service functions accept. The function signature makes the contract machine-checkable.
export class ItemService {
async createItem(
siteId: string,
createdBy: string,
dto: CreateItemInput, // only the fields Zod already validated
) {
const now = new Date();
const record = {
id: nanoid(),
siteId, // from verified tenant context
createdBy, // from verified auth context
collectionId: dto.collectionId,
data: dto.data,
status: dto.status,
createdAt: now,
updatedAt: now,
publishedAt: null, // only set by publish workflow
};
const [inserted] = await this.db.insert(items).values(record).returning();
return inserted;
}
}
TypeScript will refuse to compile if you accidentally pass isAdmin or siteId and spread it into record.
Layer 3: Readonly Fields — Never Let User Data Touch System Fields
Fields like id, siteId, createdBy, createdAt, updatedAt, and publishedAt should never be in any user-facing DTO. They are always set programmatically.
For updates, define a separate schema:
export const updateItemSchema = z.object({
data: z.record(z.unknown()).optional(),
status: z.enum(['draft', 'review']).optional(),
// collectionId omitted — you can't move items between collections via update
});
Attempting to pass { siteId: "other-tenant", publishedAt: "2020-01-01" } will silently drop both fields.
Whitelist, Never Blacklist
A blacklist approach like const { isAdmin, role, siteId, ...safeBody } = req.body; breaks when you add a new sensitive column and forget to update the destructuring. Whitelisting flips the default: only explicitly declared fields pass through.
Conclusion
Three layers working together: Zod parses and strips at the HTTP boundary, typed DTOs constrain what the service can receive, and system fields are never in the DTO. Each layer catches what the previous one might miss. All three together make mass assignment effectively impossible to sneak through.
Next steps: Audit your API endpoints for raw req.body usage. Implement Zod or similar schema validation at every entry point. Define strict DTOs for every service function. Never trust the client to supply system fields.


