水平权限校验:按id取数据的接口怎么收口

写权限校验这件事,新手往往在第一个人事系统上线时才第一次认真对待。前面几个月所有后台都在「登录了就能用」的状态下跑着,功能都能点开,谁也没觉得有问题。直到某天要把编辑权限给一个新来的实习生,才发现后台只有「管理员」和「普通用户」两个角色,想让他只能改自己那一栏的文章,没有地方能配。这时候才回过头去看代码,发现每个接口第一行都是同一句判断:会话里有用户 id 就放行。这句话的潜台词是,只要登录成功,整个后台的所有数据都属于你。

本文只讲一个具体问题:后台接口里那种「按 id 取数据、不做归属判断」的写法,也就是常说的水平权限问题(越权访问同一层的他人资源),怎么系统地找出来、怎么在没有框架托底的情况下用一个统一入口收口、以及上线之后怎么依靠日志发现漏网的路径。不讨论与它并列的垂直权限(角色能不能进某个后台模块),那是另一套判断。

一、先分清两种越权,不要混在一起改

垂直权限判断的是「这个角色能不能调用这个功能」,比如编辑能不能进用户管理页、客服能不能看财务流水。它的判断粒度在接口级别,通常在路由中间件里一句角色比对就能完成,漏掉了也很容易发现,因为页面根本打不开。

水平权限判断的是「这个身份能不能操作这条数据」。接口本身对所有登录用户开放,用户点开列表页看到的也确实只有自己的数据,一切看起来正常,但只要手工改一下地址栏里的 id,就能读到别人的内容。这类问题不会在界面上暴露,只有主动构造请求才看得见,所以它是后台最难靠点击测试发现的一类缺陷。

一个典型例子是导出功能。列表页因为有 WHERE user_id = 当前用户 的条件,看起来是安全的;而导出接口为了支持「导出全部」,把条件去掉了,只要求登录。用户从列表页复制出导出链接一改参数,就能把整个公司的订单拉走。判断这两种越权的方法完全不同:垂直权限看接口清单和角色表,水平权限必须逐个接口去看「查询条件里有没有带上当前用户的边界」。

二、用 grep 把「按 id 取数据」的接口全找出来

找问题的第一步不是读业务逻辑,而是找出所有从请求参数里取 id 的地方。PHP 项目里这一类调用高度集中,用几条 grep 就能列出候选清单,比从头读一遍代码快得多:

# 从请求里直接取 id 的位置
grep -rnE "\$_(GET|POST|REQUEST)\[['\"](id|uid|order_id|article_id|pid)['\"]\]" app/ api/ --include=*.php

# 只用 id 做主键、没有第二个条件的查询
grep -rnE "WHERE\s+id\s*=\s*\?" app/ api/ --include=*.php

# 框架里各种按主键取的快捷方法
grep -rnE "->find\(|findOrFail\(|getByPk\(|load\(\$id\)" app/ --include=*.php

清单出来之后不要急着改代码,先按「读」和「写」分开。读接口(详情、下载、导出)漏判只泄露数据,写接口(修改、删除、审核、转账)漏判会直接破坏数据,处置优先级完全不同。在清单上给每条标三列:入口路由、取 id 的方式、查询里是否带了当前用户条件。第三列是空的,就是嫌疑接口。

还有一类更容易被漏掉:id 不在请求参数里,而是从上一级资源推出来的。比如 /api/orders/1024/items/7,代码先校验了订单 1024 属于当前用户,然后直接用 item_id=7 去查明细,没校验这条明细是不是属于 1024 号订单。这种「父级校验过、子级没接上」的写法在多层嵌套的资源里非常常见,grep 只能找到入口,必须顺着资源层级看一遍。

三、判断依据只有一条:查询条件里必须有归属边界

判断一个接口有没有水平权限问题,标准可以压缩成一句话:凡是读或写某一行数据的地方,WHERE 子句里必须同时包含「主键」和「归属条件」,且归属条件的值来自服务端会话,不能来自客户端。

// 有问题:只按主键取
$order = $db->query("SELECT * FROM orders WHERE id = ?", [$id]);

// 没问题:主键 + 会话里的用户边界
$order = $db->query(
    "SELECT * FROM orders WHERE id = ? AND user_id = ?",
    [$id, $session['user_id']]
);
if (!$order) { http_response_code(404); exit; }

两个细节值得强调。第一,查不到的时候返回 404 而不是 403。403 等于告诉对方「这条数据存在,但不是你的」,这本身就是一条信息泄露,攻击者可以据此枚举出系统里有多少条记录。第二,这个条件必须写进 SQL,不能写成「先取出来再在 PHP 里 if 一下」。后者看着也能拦住,但每多一个出口就多一次漏判机会,而且事务、并发、批量操作里很容易绕过。

对于确实需要跨用户访问的场景,比如管理员查看下属的数据、客服代客操作,不要用「如果是管理员就跳过条件」的写法,那会把权限判断从数据层挪回应用层,一处漏判就全盘失效。更稳的做法是算出「当前身份可见的用户 id 集合」,把它作为条件注入每个查询,管理员只是集合大一点,查询结构完全一致。这样即使某个接口忘了判角色,也只是可见范围稍大,不会变成全库可读。

四、用一个统一入口收口,而不是逐个接口打补丁

发现几十个接口有问题时,最诱人的做法是一个个加条件。短期有效,但下一批新接口上线时同样的问题会再来一遍。真正要解决的是让「带归属条件的查询」成为唯一可用的写法,代价最小的落地方式是给数据访问加一层薄封装。

function owned_row(string $table, int $id, array $session): ?array
{
    // 只有登记过归属列的表才允许这么查,白名单防止表名注入
    $ownCol = [
        'orders'   => 'user_id',
        'articles' => 'user_id',
        'files'    => 'owner_id',
    ];
    if (!isset($ownCol[$table])) {
        throw new RuntimeException("table not registered: $table");
    }
    $sql = sprintf(
        "SELECT * FROM %s WHERE id = ? AND %s = ? LIMIT 1",
        $table, $ownCol[$table]
    );
    $st = db()->prepare($sql);
    $st->execute([$id, $session['user_id']]);
    return $st->fetch() ?: null;
}

封装的价值不在代码本身,而在于它把「归属列」这件事从业务代码里提出来,变成一张可以审阅的表。新表接入时必须登记归属列,审查的人一眼就能看出它有没有漏。同理,删除和更新也应该有对应的封装,并且一条记录都影响不到时直接返回 404,而不是把「没权限」和「没找到」分开处理。

如果项目已经成型、短期内不可能全员改用封装,退一步的做法是在入口中间件里做一层粗筛。把所有需要资源归属校验的路由集中登记,中间件在分发前先按登记表查一次归属,查不到直接 404。这样新接口只要登记了就不会漏,没登记的也能在每日巡检里被发现。

五、六种最常见的漏判写法

下面这几种写法在自查里出现频率最高,每一种单独看都「有做校验」,但都拦不住真正的越权。

其一,校验了登录没校验归属。最常见的形态就是接口第一行 if (!$session) exit,然后直接用 id 查。登录只是身份,不是权限。

其二,把 id 存在前端,后端不校验。列表接口返回的数据里带着 user_id,前端据此决定显示哪些按钮,后端以为前端已经过滤过了。任何一次直接请求都能绕过,前端的任何判断都不能作为安全依据。

其三,用 HMAC 或散列后的 id 冒充权限。把 id 做一次不可逆变换确实能提高盲猜成本,但它是「不可猜」不是「不可访问」,链接一旦被分享、被日志记录、被 Referer 带出去,保护就失效了。它适合作为额外的防枚举手段,不能替代归属校验。

其四,只校验了第一层。前面提到的订单与明细就是典型,父级通过了,子级直接用 id 查。嵌套资源的每一层都要接上归属条件。

其五,批量接口漏判。批量删除接收一个 id 数组,循环里逐个删,但没有校验数组里每个元素都属于当前用户。这类接口的风险被放大了几十倍,一次请求就能清掉别人一整批数据。

其六,软删除与状态条件混用。查询写成了 WHERE id = ? AND status = 1,看起来有两个条件,但 status 是业务状态不是归属,等于没有归属校验。判断时只认「这一列的值是否来自会话里的身份」,其他条件一律不算。

六、用日志和巡检确认真的收口了

改完之后不能只靠通读代码确认。最有效的验证是让所有归属判断失败都留下痕迹,然后看这些痕迹出现在哪里。

// 归属校验失败时记一条,字段要能定位到接口和用户
error_log(sprintf(
    "perm_denied path=%s uid=%s target=%s ip=%s",
    $_SERVER['REQUEST_URI'], $session['user_id'] ?? '-',
    $id ?? '-', $_SERVER['REMOTE_ADDR'] ?? '-'
), 3, '/var/log/php/perm.log');

这份日志有三个用法。正常运营时,它应该几乎为空;出现少量记录,多半是用户收藏了旧链接或书签失效。第二,上线新功能后的一周内,逐条看有没有同一个用户反复命中不同 target 的记录,那是典型的枚举行为。第三,按月统计命中量最高的几个路径,它们往往是权限模型没设计好的地方,比如某个页面的详情接口对所有角色的归属条件都不一样,导致大家频繁触发。

除了日志,还应该有一个静态巡检:把登记表和代码里的实际查询对比,找出「用了 WHERE id = ? 却没有出现在登记表里」的接口。这个检查不需要跑流量,每周跑一次,用 grep 加上一张白名单就能实现,是防止新接口回退的最便宜的手段。

七、把权限校验写进开发流程,而不是留在事故复盘里

技术层面的收口做完之后,真正决定它能不能持久的,是流程。三条规则成本很低但效果明显。

第一,任何按 id 取数据的接口,评审时必须回答「这个 id 是谁的」。答不上来就不合并。这一条能把大部分问题挡在上线之前,而且不需要评审者有安全背景。

第二,测试用例里加上「用 A 的会话访问 B 的数据」这一条,期望结果是 404。这类用例写起来只有几行,但它是唯一能自动发现回归的手段。很多团队的安全测试做了很多年,最后发现缺的就是这一条最基础的用例。

第三,权限模型变更时同步更新登记表。角色新增、归属列改名、数据从单人所有变成团队所有,这几种变更都会让原来的判断失效。把登记表当作权限模型的一部分来维护,而不是当作一份文档。

最后一句经验:水平权限的问题数量和你后台的接口数量基本成正比,任何一次大规模功能上线之后都应该重新跑一遍巡检。它不像 SQL 注入那样一个参数化查询就能一次性解决,它更像是一张需要定期重新核对的清单。

Last modification:November 7th, 2026 at 01:24 pm

Leave a Comment