Property Level Authorization: Mass Assignment and Over-Exposure

The right object, the wrong fields — reading what you should not see, and writing what you should not touch.

OWASP API3 covers two halves of one problem: the caller reaches an object they legitimately own, and then reads fields they should not see or writes fields they should not control.

Half one: excessive data exposure

The endpoint serialises a model and returns every column. The UI shows three fields, so nobody noticed the other fourteen — passwordHash, totpSecret, internalRiskScore, isAdmin, another user's email on a nested relation.

"The frontend does not display it" is not a control. The response body is the API.

Half two: mass assignment

The handler passes req.body straight into an update. The form sent two fields; an attacker sends twelve, including role, emailVerified, accountBalance, tenantId, and createdAt.

The rule for both

Allowlist in, allowlist out. Never a denylist — a denylist is one new column away from being wrong, and the column will be added by someone who has never read this code.

  • In: pick the writable fields explicitly, and reject unknown ones rather than dropping them silently.
  • Out: construct the response object by hand, or through a serializer that requires a field list.

Fields depend on the viewer

The same object has different shapes for different callers. A user sees their own email; another user sees only a display name; an admin sees the risk score. That is per-role serialisation, and it is worth building as a first-class thing rather than as scattered conditionals.

Watch the second path

The REST route is usually shaped correctly. The CSV export, the search index, the webhook payload and the GraphQL resolver frequently are not — and they return the same rows.

Example

Example · javascript
// ❌ Both halves wrong in four lines
app.patch('/api/users/me', auth, async (req, res) => {
  await db.users.update(req.user.id, req.body);          // writes ANY field
  res.json(await db.users.findById(req.user.id));        // returns EVERY column
});

// PATCH /api/users/me  {"name":"Alice","role":"admin","emailVerified":true}
// → 200, and the response helpfully includes passwordHash and totpSecret.

// ✅ Allowlist in, allowlist out
const WRITABLE = ['name', 'bio', 'avatarUrl', 'locale'];
const READABLE = ['id', 'email', 'name', 'bio', 'avatarUrl', 'locale', 'createdAt'];

app.patch('/api/users/me', auth, validate(profileSchema), async (req, res) => {
  const patch = pick(req.body, WRITABLE);
  await db.users.update(req.user.id, patch);
  res.json(pick(await db.users.findById(req.user.id), READABLE));
});

When to use it

  • A user grants themselves admin by adding a role field to a profile update that the form never sent.
  • A mobile app's user endpoint returns password hashes and TOTP secrets because the model was serialised directly.
  • A CSV export leaks internal risk scores that the carefully shaped REST endpoint correctly withholds.

More examples

Serialisation that depends on the viewer

The NEVER set is the safety net: a future developer adding a column to the admin view cannot accidentally include a credential field.

Example · javascript
// The same row has different shapes for different callers. Make that explicit
// rather than scattering `if (isAdmin)` through the handlers.
const FIELDS = {
  // What anyone may see about a user
  public:  ['id', 'name', 'avatarUrl'],
  // What you may see about YOURSELF
  self:    ['id', 'name', 'avatarUrl', 'email', 'locale', 'createdAt',
            'mfaEnabled', 'emailVerified'],
  // What support may see
  support: ['id', 'name', 'avatarUrl', 'email', 'createdAt', 'lastLoginAt',
            'suspendedAt', 'plan'],
  // What an admin may see
  admin:   ['id', 'name', 'avatarUrl', 'email', 'createdAt', 'lastLoginAt',
            'suspendedAt', 'plan', 'internalRiskScore', 'signupIp'],
  // NEVER, for anyone, on any path:
  //   passwordHash, totpSecret, recoveryCodes, refreshTokenHash, ssn
};

const NEVER = new Set(['passwordHash', 'totpSecret', 'recoveryCodes',
                       'refreshTokenHash', 'ssn']);

export function serializeUser(row, viewer) {
  const view = !viewer ? 'public'
    : viewer.id === row.id ? 'self'
    : viewer.role === 'admin' ? 'admin'
    : viewer.role === 'support' ? 'support'
    : 'public';

  const out = {};
  for (const field of FIELDS[view]) {
    // Belt and braces: even a mistake in FIELDS cannot leak these.
    if (NEVER.has(field)) continue;
    if (field in row) out[field] = row[field];
  }
  return out;
}

// Usage is uniform and greppable
app.get('/api/users/:id', optionalAuth, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return res.sendStatus(404);
  res.json(serializeUser(user, req.user));
});

// And a test that survives new columns being added to the table
it('never leaks credential columns to any viewer', async () => {
  const row = await db.users.findById(alice.id);
  for (const viewer of [null, bob, supportUser, adminUser, alice]) {
    const out = serializeUser(row, viewer);
    for (const field of NEVER) expect(out).not.toHaveProperty(field);
  }
});

Blocking mass assignment at three layers

Replacing req.body with the parsed result is easy to skip and important: validating the input while continuing to use the raw object defeats the whole exercise.

Example · javascript
// ── Layer 1: schema validation that REJECTS unknown fields ────────────
import { z } from 'zod';

const updateProfileSchema = z.object({
  name: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
  locale: z.enum(['en', 'fr', 'de', 'es']).optional(),
}).strict();      // ← .strict() ERRORS on extra keys. Without it they pass through.

function validate(schema) {
  return (req, res, next) => {
    const parsed = schema.safeParse(req.body);
    if (!parsed.success) {
      return res.status(400).json({
        error: 'validation_failed',
        // Naming the unexpected fields surfaces client bugs AND probing.
        issues: parsed.error.issues.map((i) => ({
          path: i.path.join('.'), message: i.message,
        })),
      });
    }
    req.body = parsed.data;      // ← replace with the PARSED value, not the raw one
    next();
  };
}

// ── Layer 2: an explicit pick before the write ────────────────────────
const WRITABLE = ['name', 'bio', 'locale'];
const patch = Object.fromEntries(
  Object.entries(req.body).filter(([k]) => WRITABLE.includes(k)));

// ── Layer 3: the model refuses unfillable attributes ──────────────────
// Laravel:  protected $fillable = ['name', 'bio', 'locale'];
//           (use $fillable — NEVER $guarded, which is a denylist)
// Prisma:   select the fields explicitly in the update call
// Sequelize: User.update(patch, { fields: WRITABLE })

await db.users.update(req.user.id, patch);

// ── The prototype pollution variant, worth knowing ────────────────────
// {"__proto__": {"isAdmin": true}} can poison Object.prototype in JS, so an
// unrelated `if (user.isAdmin)` elsewhere becomes true for every object.
// .strict() rejects it; so does JSON.parse with a reviver:
JSON.parse(raw, (key, value) =>
  ['__proto__', 'constructor', 'prototype'].includes(key) ? undefined : value);

// Any ONE layer would do. Three means a mistake in one is not a breach.

Auditing what your API actually returns

Running the scanner over export and webhook payloads as well as HTTP responses is what catches the leak that the reviewed REST endpoint does not have.

Example · javascript
// Fields creep in when a column is added to a table that some endpoint
// serialises wholesale. Catch it in CI rather than in a bug bounty report.

const FORBIDDEN_KEYS = [
  /password/i, /secret/i, /token/i, /hash$/i, /^ssn$/i, /credit_?card/i,
  /private_?key/i, /recovery_?code/i, /internal_/i, /_internal$/i,
];

function scanForLeaks(value, path = '$', found = []) {
  if (value === null || typeof value !== 'object') return found;

  for (const [key, child] of Object.entries(value)) {
    const here = `${path}.${key}`;
    if (FORBIDDEN_KEYS.some((re) => re.test(key))) {
      found.push({ path: here, key });
    }
    scanForLeaks(child, here, found);       // nested relations leak too
  }
  return found;
}

// Run it against EVERY endpoint that returns a body, for every role.
describe('no endpoint leaks sensitive fields', () => {
  const readRoutes = routes.filter((r) => r.method === 'GET');

  for (const route of readRoutes) {
    for (const [roleName, token] of Object.entries(TOKENS)) {
      it(`${route.path} (${roleName})`, async () => {
        const res = await request(app)
          .get(route.path.replace(/:\w+/g, seededId(route.path)))
          .set('Authorization', `Bearer ${token}`);

        if (res.status !== 200) return;
        const leaks = scanForLeaks(res.body);
        expect(leaks).toEqual([]);
      });
    }
  }
});

// Do the same for the OTHER paths to the same data — the ones that get missed:
//   the CSV export, the webhook payload, the search index document,
//   the GraphQL resolver, and the admin panel's raw view.
// Same scanner, different producers.

Discussion

  • Be the first to comment on this lesson.