← 全部新闻
data-exposure5 分钟阅读

Your ORM's findAll() is probably sending password hashes to the client

作者:Seaworthy · 2026年8月12日

Close-up of running water from a faucet with splashing droplets

Photo by Ian Talmacs on Unsplash

Ask an ORM for a user and, by default, you get all of them. Not all the users, all the columns:

const user = await User.findByPk(req.params.id)
res.json(user)

This is the version of the endpoint that gets written first, because it's the version that's fastest to write and it works in the browser network tab exactly the way you'd expect. The user's name and email show up on the profile page. Nobody notices anything wrong, because the frontend only reads the three or four fields it needs and ignores the rest of the JSON object silently sitting in the response.

The rest of that JSON object is the problem. User.findByPk selects every column on the model unless you tell it not to, and on most apps that includes things nobody meant to ship to a browser: password_hash, a stripe_customer_id, an internal admin_notes field, a failed_login_attempts counter. None of it renders anywhere on the page. All of it is sitting in the response body, one "view source" or one glance at the network tab away from anyone who's curious.

Why this is worse than it looks

A password hash in an API response doesn't get you logged in, since it's hashed. But it does hand an attacker an offline cracking target with none of the rate limiting or lockout logic that protects your actual login endpoint. Internal fields like admin_notes or is_flagged_for_review tell a user things about their own account status that your product was never designed to reveal to them. And a stripe_customer_id or internal user ID that was meant to stay server-side becomes something a client can now reference directly, which opens the door to a different bug entirely if any other endpoint trusts an ID passed in from the client without checking it belongs to the requesting user.

None of this requires a vulnerability in the traditional sense. The query works exactly as designed. The design just never decided which columns were meant to leave the server.

Selecting only what you serialize

Most ORMs support scoping a query down to specific columns, and it costs one extra line:

const user = await User.findByPk(req.params.id, {
  attributes: ['id', 'name', 'email', 'avatar_url'],
})
res.json(user)

This is Sequelize's syntax specifically, but the shape is the same everywhere: Prisma's select, TypeORM's select option, Django's .only(). The pattern isn't tied to one framework, it's tied to the habit of asking the ORM for exactly what the response needs instead of everything the table happens to contain.

A response serializer is the more durable fix

An explicit attributes list on every query works, but it's easy to add a new sensitive column to a table six months later and forget which of the dozen queries against that model need updating. A dedicated serializer function that whitelists the outbound shape once, and gets reused everywhere that model reaches an API response, moves the decision to one place instead of every call site:

function serializeUser(user) {
  return {
    id: user.id,
    name: user.name,
    email: user.email,
    avatarUrl: user.avatar_url,
  }
}

res.json(serializeUser(user))

New columns are excluded by default under this pattern, since they simply aren't listed, rather than included by default and requiring someone to remember to exclude them. That inversion is the entire value of doing it this way.

Check the easy places first

Nested associations make this worse in a way that's easy to miss: an endpoint that correctly scopes the top-level user record can still leak everything through an included model, like an order's full customer object attached via a .include. If your ORM eager-loads relations, check that the included model is scoped the same way the top-level one is, since the default there is unrestricted too.

Seaworthy checks API responses for over-fetched fields against common patterns for sensitive column names, since this is rarely caught by manual review when the leaking field never shows up rendered anywhere in the UI.

本文由 AI 生成,总结了公开可用的来源。

来自 Seaworthy 团队

Seaworthy 会扫描你的代码仓库,发现类似本文提到的各种问题。

安全漏洞、泄露的密钥和配置错误——在部署前捕获。免费运行,无需账户。

$ npx seaworthycode