Excessive Data Exposure

Returning more than the caller needs — the leak that happens without any attack at all.

The endpoint is authenticated, authorized, and returns exactly the object the caller is entitled to. It also returns fourteen fields the screen never displays. Nobody attacked anything; the data is simply out there.

How it happens

  • Serialising the model. res.json(user) returns every column, forever, including ones added next year.
  • Eager-loaded relations. include: { user: true } on a comment returns the author's email and login history.
  • "The frontend filters it." The response body is the API. What the UI renders is irrelevant.
  • Debug fields that were temporary.
  • Aggregates that leak individuals — a count of one is an identification.

The fix is explicit shaping

Construct the response by hand or through a serializer that requires a field list. It is more typing, and it means adding a column to a table cannot silently publish it.

Different callers, different shapes

A user sees their own email; another user sees a display name; support sees the plan; an admin sees the risk score. Build that as a first-class concept rather than scattered conditionals.

Errors leak too

A stack trace names file paths, library versions and internal hostnames. A database error can echo the query. A distinctive error for "no such user" versus "wrong password" is a user enumeration API. Return a generic message and a request id; log the detail internally.

Watch the second path

The REST endpoint is usually shaped. The CSV export, the webhook payload, the search index document, the GraphQL resolver and the admin panel usually are not — and they return the same rows.

Example

Example · javascript
// ❌ Every column, now and forever
app.get('/api/users/:id', auth, async (req, res) => {
  res.json(await db.users.findById(req.params.id));
});
// { id, email, passwordHash, totpSecret, internalRiskScore, signupIp,
//   stripeCustomerId, isAdmin, notes, ... }

// ✅ An explicit shape, per viewer
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));
});

When to use it

  • A mobile app's user endpoint is found to return password hashes because the model was serialised directly.
  • A comments endpoint leaks every author's email through an eager-loaded relation nobody reviewed.
  • A new internal_notes column appears in a public API response the day it is added, because responses were never shaped.

More examples

A serializer that cannot be used unsafely

Validating the view definitions at boot means a developer adding a credential field to the admin view gets a startup crash rather than a production leak.

Example · javascript
// The design goal: res.json(row) should look wrong in review, and the safe
// version should be no harder to write.

const NEVER_EXPOSE = new Set([
  'passwordHash', 'totpSecret', 'recoveryCodes', 'refreshTokenHash',
  'apiKeyHash', 'ssn', 'stripeSecretKey', 'internalNotes',
]);

export class Serializer {
  static define(name, views) {
    // Fail at BOOT if a view ever includes a forbidden field.
    for (const [view, fields] of Object.entries(views)) {
      const bad = fields.filter((f) => NEVER_EXPOSE.has(f));
      if (bad.length) {
        throw new Error(`Serializer ${name}.${view} exposes ${bad.join(', ')}`);
      }
    }
    return (row, view = 'public') => {
      const fields = views[view];
      if (!fields) throw new Error(`Unknown view ${name}.${view}`);
      const out = {};
      for (const f of fields) if (f in row) out[f] = row[f];
      return out;
    };
  }
}

export const serializeUser = Serializer.define('user', {
  public:  ['id', 'name', 'avatarUrl'],
  self:    ['id', 'name', 'avatarUrl', 'email', 'locale', 'mfaEnabled', 'createdAt'],
  support: ['id', 'name', 'email', 'plan', 'createdAt', 'lastLoginAt', 'suspendedAt'],
  admin:   ['id', 'name', 'email', 'plan', 'createdAt', 'lastLoginAt',
            'suspendedAt', 'riskScore', 'signupIp'],
});

export function viewFor(row, viewer) {
  if (!viewer) return 'public';
  if (viewer.id === row.id) return 'self';
  if (viewer.role === 'admin') return 'admin';
  if (viewer.role === 'support') return 'support';
  return 'public';
}

// Usage
res.json(serializeUser(user, viewFor(user, req.user)));

// And a lint rule so the unsafe version is caught mechanically:
// eslint no-restricted-syntax:
//   selector: "CallExpression[callee.property.name='json'] > Identifier"
//   message: "Do not serialise a model directly — use a Serializer view."

The relations and aggregates that leak quietly

Small-cell suppression is the standard defence for aggregate endpoints, and it is routinely missing from analytics APIs that otherwise authorize correctly.

Example · javascript
// ── Eager-loaded relations ────────────────────────────────────────────
// ❌ Every comment now carries the author's full user row
const comments = await prisma.comment.findMany({
  where: { postId },
  include: { author: true },              // ← email, ip, admin flag, everything
});

// ✅ Select explicitly, at every level
const comments = await prisma.comment.findMany({
  where: { postId },
  select: {
    id: true, body: true, createdAt: true,
    author: { select: { id: true, name: true, avatarUrl: true } },
  },
  take: 50,
});

// ── Aggregates that identify individuals ──────────────────────────────
// A "count of users matching this filter" endpoint is an oracle: filter until
// the count is 1, and you have identified someone.
app.get('/api/analytics/segment', auth, async (req, res) => {
  const count = await countMatching(req.query.filters);

  // Suppress small cells — standard practice in statistical disclosure control.
  if (count > 0 && count < MIN_CELL_SIZE) {      // typically 5 or 10
    return res.json({ count: `<${MIN_CELL_SIZE}`, suppressed: true });
  }
  res.json({ count });
});

// ── Timing and status codes as oracles ────────────────────────────────
// ❌ Different responses reveal existence
// GET /api/users/by-email/[email protected]  → 200
// GET /api/users/by-email/[email protected] → 404
// An attacker now knows who has an account.

// ✅ Do not offer the lookup at all, or require a relationship
app.get('/api/users/by-email/:email', auth, async (req, res) => {
  const target = await db.users.findByEmail(req.params.email);
  // Only reveal existence to someone who already shares a workspace.
  const related = target && await db.memberships.shareTenant(req.user.id, target.id);
  if (!related) return res.status(404).json({ error: 'not_found' });
  res.json(serializeUser(target, 'public'));
});

// ── The second paths — same rows, different producer ──────────────────
// exports, search index documents, webhook payloads, GraphQL resolvers,
// admin panels, PDF renders, email templates.
// Run the same field scanner over ALL of them, not just HTTP responses.

A CI scanner for response fields

Keeping an explicit exception list rather than relaxing the patterns preserves the signal — a loosened regex silently stops catching the thing it was written for.

Example · javascript
// Catch new leaks on the day the column is added.
const FORBIDDEN_PATTERNS = [
  /password/i, /secret/i, /token/i, /_hash$/i, /^hash$/i,
  /^ssn$/i, /credit_?card/i, /cvv/i, /private_?key/i,
  /recovery_?code/i, /internal_/i, /_internal$/i, /risk_?score/i,
];

function findLeaks(value, path = '$', out = []) {
  if (value === null || typeof value !== 'object') return out;
  for (const [key, child] of Object.entries(value)) {
    const here = `${path}.${key}`;
    if (FORBIDDEN_PATTERNS.some((re) => re.test(key))) out.push(here);
    findLeaks(child, here, out);
  }
  return out;
}

describe('no endpoint exposes sensitive fields', () => {
  const producers = [
    // HTTP responses
    ...readRoutes.map((r) => ({
      name: `GET ${r.path}`,
      run: (token) => request(app).get(fill(r.path)).set('Authorization', `Bearer ${token}`)
        .then((res) => (res.status === 200 ? res.body : null)),
    })),
    // The second paths, which is where the bugs are
    { name: 'csv export', run: async () => parseCsv((await exportInvoices(alice.id)).text) },
    { name: 'webhook payload', run: async () => buildWebhookPayload(await anyInvoice()) },
    { name: 'search document', run: async () => buildSearchDocument(await anyInvoice()) },
    { name: 'graphql user', run: (t) => gql('{ me { id email } }', t).then((r) => r.data) },
  ];

  for (const producer of producers) {
    for (const [role, token] of Object.entries(TOKENS)) {
      it(`${producer.name} (${role})`, async () => {
        const body = await producer.run(token);
        if (!body) return;
        expect(findLeaks(body)).toEqual([]);
      });
    }
  }
});

// Pattern matching on names is imperfect — it misses a leak called `notes` and
// flags a harmless `tokenCount`. Maintain an explicit exception list rather
// than loosening the patterns:
const KNOWN_SAFE = new Set(['$.data.tokenCount', '$.usage.tokensUsed']);

Discussion

  • Be the first to comment on this lesson.