企业员工数据越权访问事故频发如何防范水平越权权限隔离与审计日志让每个账号只能看自己的数据
你有没有遇到过这种事
上个月有个朋友的公司出了事,财务小张不小心(或者说被诱导)打开了一个链接,页面弹出来让他输入系统账号密码。他填了,回来继续干活。三天后,公司财务系统被拖了个干净,损失八百万。
这不是虚构的故事,这是2023年某中型企业真实发生的事。事后复盘,技术团队才发现,系统根本没有做水平越权防护——任何登录账号都能通过修改URL参数里的ID,看到别人的数据。
今天就来聊聊这个坑,以及如何把坑填上。
什么是水平越权,它到底有多大杀伤力
水平越权(Horizontal Privilege Escalation),说白了就是”你不是管理员,但你拿到了别人的数据”。
举几个例子你就懂了:
- 员工A登录系统,看到自己的订单ID是
order_id=10001,他改成order_id=10002,结果看到了员工B的订单信息。 - HR系统里,张三查员工档案时传了
user_id=200,他改成user_id=201,看到了同事的工资条。 - 电商平台,买家A把订单接口里的订单号换成买家的,居然能查到别人的收货地址和电话。
这不是”可能”发生,这是每天都在发生的事故。
根据Gartner的统计,72%的数据泄露事件都跟权限管理不当有关,其中水平越权占了相当大的比例。
为什么?因为开发人员的思维惯性——他们默认”能登录系统的人,就是系统用户”,却忘了登录≠有权限访问所有数据。
先搞懂:垂直越权和水平越权有什么区别
很多人把两种越权混为一谈,但其实它们是不同的东西:
垂直越权——低权限用户做了高权限用户才能做的事。比如普通员工访问了管理员后台。
水平越权——同权限用户访问了别人的数据。比如员工A查到了员工B的数据。
垂直越权通常有明确的角色边界(RBAC模型),相对容易防护。水平越权才是真正的”隐形杀手”——它在系统内部,在正常权限范围内游走,很难被发现。
根源在哪:为什么系统会出这种问题
1. 把用户ID当参数,从请求里直接取
这是最常见的写法:
# 危险的代码示例
@app.route('/api/order/<int:order_id>')
def get_order(order_id):
current_user = get_current_user() # 从session里拿
order = db.query(Order).filter(Order.id == order_id).first()
return jsonify(order)
这段代码的问题很明显——order_id 来自URL,current_user 来自session,两者之间没有任何关联校验。员工A完全可以传一个属于员工B的 order_id,系统照样返回数据。
2. 只靠前端隐藏按钮,不校权限
有些团队觉得”我把查询所有员工数据的接口藏起来,不让用户看到就行了”。
这是天真的想法。前端代码任何人都能看,接口任何人都能调。攻击者随便用Postman或者Burp Suite就能绕过前端,直接请求API。
3. 没有审计日志,出了事根本查不到
很多系统的日志只记了”谁在什么时候访问了接口”,但没有记录”访问了哪个资源、返回了什么”。出了事故之后,连”是谁、看了谁的数据、看了多少次”都查不清楚。
怎么防:从设计层面把漏洞堵死
第一层:后端必须做数据归属校验
核心原则——永远不要信任前端传来的资源ID,必须在后端校验”当前用户是否有权访问这个资源”。
正确的写法:
@app.route('/api/order/<int:order_id>')
def get_order(order_id):
current_user = get_current_user()
# 关键:查询时同时带上当前用户ID作为过滤条件
order = db.query(Order).filter(
Order.id == order_id,
Order.user_id == current_user.id # 必须属于当前用户
).first()
if not order:
return jsonify({"error": "资源不存在或无权访问"}), 403
return jsonify(order)
这样改完之后,员工A即使把 order_id 改成员工B的,查询条件里 user_id 也对不上,系统直接返回403。
这不是”加分项”,这是必选项。
第二层:用服务层统一处理权限校验
如果你的项目接口很多,每个接口都手写权限校验代码,既麻烦又容易漏。更好的做法是抽一个权限服务层:
class PermissionService:
"""权限校验服务"""
@staticmethod
def check_owner(current_user, target_user_id):
"""
校验当前用户是否是目标资源的拥有者
或者当前用户是管理员(可以访问所有数据)
"""
if current_user.is_admin:
return True
return current_user.id == target_user_id
@staticmethod
def get_user_scope(current_user):
"""
根据用户角色返回其可访问的数据范围
"""
if current_user.is_admin:
return None # 管理员无限制
return {'user_id': current_user.id} # 普通用户只能看自己的
# 在业务层使用
def get_order(order_id, current_user):
scope = PermissionService.get_user_scope(current_user)
if scope:
order = db.query(Order).filter(
Order.id == order_id,
Order.user_id == scope['user_id']
).first()
else:
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise PermissionError("无权访问该资源")
return order
这样每个接口调用时,权限逻辑是统一的,不容易出现”这个接口忘了加校验”的情况。
第三层:API层面做统一的权限拦截
如果你的项目用的是框架(比如Spring、Django、Flask),可以借助拦截器/中间件做全局权限控制:
# Flask示例:使用装饰器做统一权限校验
from functools import wraps
from flask import request, jsonify
def require_ownership(resource_model, id_field='id'):
"""
资源所有权校验装饰器
使用方式:@require_ownership(Order, 'id')
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
current_user = get_current_user()
# 从URL参数或请求体中获取资源ID
resource_id = kwargs.get(id_field) or request.args.get(id_field)
if not resource_id:
return jsonify({"error": "缺少资源ID"}), 400
# 查询资源
resource = resource_model.query.filter_by(
**{id_field: resource_id}
).first()
if not resource:
return jsonify({"error": "资源不存在"}), 404
# 校验权限
if not current_user.is_admin and resource.user_id != current_user.id:
# 记录越权尝试
log_access_violation(current_user, resource)
return jsonify({"error": "无权访问该资源"}), 403
return f(*args, **kwargs)
return decorated_function
return decorator
# 使用示例
@app.route('/api/order/<int:order_id>')
@require_ownership(Order, 'id')
def get_order(order_id):
order = Order.query.get(order_id)
return jsonify(order)
这个装饰器的价值在于——你把权限校验逻辑写一次,所有接口都能复用,不会出现某个接口开发时忘了加校验的情况。
权限隔离:不只是技术,更是管理问题
技术防护只是第一道关,管理上的权限隔离同样重要。
最小权限原则(Least Privilege)
每个账号只应拥有完成工作所需的最小权限。
具体做法:
- 普通员工只能访问自己的数据,不能查询其他同事的数据
- HR可以访问员工档案,但不能访问财务数据
- 开发人员没有生产环境的数据库访问权限
- 外包人员只有临时、限时的访问权限
角色分离(Segregation of Duties)
关键操作不能由一个人独立完成。比如:
- 申请权限的人和审批权限的人不能是同一个
- 数据导出和数据分析不能由同一人全程操作
- 财务付款的发起人和审批人必须分离
这不是技术能解决的,需要在管理制度中明确规定,并通过系统强制落地。
定期权限审查
很多公司一次权限分配,几年不改。员工岗位变动了,权限没变;项目结束了,访问权还在。
建议每季度做一次权限审查:
- 导出所有用户及其权限列表
- 部门负责人确认下属权限是否合理
- 清理离职员工、转岗员工的多余权限
- 检查异常的高权限账号
审计日志:出事之后你能不能还原真相
审计日志不是”出了事再写”的东西,它应该从系统第一天就设计好。
审计日志应该记录什么
一条合格的审计日志应该包含:
| 字段 | 说明 |
|---|---|
| 时间戳 | 精确到毫秒 |
| 操作人ID | 谁发起的请求 |
| 操作人IP | 从哪里来的 |
| 操作类型 | 查询/新增/修改/删除/导出 |
| 资源类型 | 订单/员工档案/财务数据 |
| 资源ID | 具体哪个资源 |
| 请求参数 | 传了什么 |
| 返回结果 | 成功/失败,失败原因 |
| User-Agent | 用什么客户端访问的 |
审计日志的代码实现
import logging
from datetime import datetime
from functools import wraps
from flask import request, g
# 配置审计日志
audit_logger = logging.getLogger('audit')
audit_handler = logging.FileHandler('audit.log')
audit_handler.setFormatter(logging.Formatter(
'%(asctime)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S.%f'
))
audit_logger.addHandler(audit_handler)
audit_logger.setLevel(logging.INFO)
def audit_operation(resource_type, action):
"""
审计日志装饰器
使用方式:@audit_operation('order', 'query')
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
result = f(*args, **kwargs)
# 记录审计信息
audit_logger.info(
f"user_id={g.current_user.id} | "
f"ip={request.remote_addr} | "
f"action={action} | "
f"resource_type={resource_type} | "
f"resource_id={kwargs.get('order_id', 'N/A')} | "
f"status={result.status_code if hasattr(result, 'status_code') else 'success'} | "
f"ua={request.headers.get('User-Agent', 'unknown')}"
)
return result
return decorated_function
return decorator
# 使用示例
@app.route('/api/order/<int:order_id>')
@require_ownership(Order, 'id')
@audit_operation('order', 'query')
def get_order(order_id):
order = Order.query.get(order_id)
return jsonify(order)
审计日志的几个关键设计原则
1. 日志不可篡改
审计日志本身如果被篡改,就失去了意义。做法:
- 日志写入后追加不可修改(WORM存储)
- 使用哈希链:每条日志包含前一条的哈希值
- 日志实时同步到独立的日志服务器,本地只留副本
2. 敏感操作单独记录
导出、批量删除、权限变更这类操作,应该额外记录操作前后的数据快照,方便事后比对。
3. 日志集中管理
不要散落在各个服务器的本地文件中。用ELK(Elasticsearch+Logstash+Kibana)或者类似方案,集中收集、检索和分析。
真实案例:某电商公司是怎么修的
2023年,某中型电商平台发生了水平越权事件。用户发现,只要知道另一个用户的订单号,就能在APP里看到对方的收货地址和手机号。
事故原因排查下来,问题出在订单查询接口:
# 问题代码
@app.route('/api/my_orders/<order_id>')
def get_order_detail(order_id):
order = OrderService.get_by_id(order_id) # 只校验了订单存在,没校验归属
return order
修复方案分三步走:
第一步:后端加归属校验
@app.route('/api/my_orders/<order_id>')
def get_order_detail(order_id):
current_user = get_current_user()
order = OrderService.get_by_id_and_owner(order_id, current_user.id)
if not order:
return jsonify({"error": "订单不存在"}), 404
return order
第二步:全量接口审计
安全团队写了脚本,对所有API接口逐一测试,用不同账号尝试访问不属于自己资源,发现还有5个接口存在类似问题,一并修复。
第三步:上线审计日志
所有订单相关操作全部接入审计系统,运营人员可以通过Kibana看板实时查看”谁在什么时候查了哪个订单”。
这件事之后,公司还做了一件重要的事——建立了安全开发规范,要求所有新接口必须通过安全测试才能上线,权限校验代码纳入Code Review必查项。
给开发团队的实操检查清单
如果你现在就要去排查自己公司的系统,可以用这份清单:
代码层面
- [ ] 所有查询接口是否都校验了资源归属(当前用户ID与资源用户ID匹配)
- [ ] 是否所有API都通过了”用A账号访问B资源”的越权测试
- [ ] 权限校验逻辑是否集中在服务层,而非散落在各个接口里
- [ ] 是否有统一的权限装饰器/拦截器,避免遗漏
- [ ] 管理员接口是否有额外的二次验证(如短信验证码)
日志层面
- [ ] 关键操作(查询、导出、修改、删除)是否有审计日志
- [ ] 日志是否包含操作人、时间、IP、资源、操作类型
- [ ] 日志是否实时同步到独立服务器
- [ ] 是否有日志异常告警(如某账号短时间内大量查询不同资源)
管理层面
- [ ] 是否执行最小权限原则
- [ ] 是否有定期的权限审查机制
- [ ] 离职/转岗员工权限是否及时回收
- [ ] 外包人员是否有独立的访问账号和时限控制
最后说几句
数据越权访问这个问题,说难不难,说简单也不简单。技术上有成熟的方案,难的是把每件小事都做到位。
我见过太多团队,安全测试只做一次,上线了就万事大吉。结果三个月后,新同事加了几个接口,没加权限校验,事故就出了。
安全不是一次性的工作,它是持续的过程。每一个新接口上线,每一次权限调整,每一次人员变动,都可能带来新的风险点。
最好的防御,是让”越权访问”在系统设计之初就不存在,而不是等出了事再补救。
如果你正在负责一个系统的安全建设,我的建议是:先从自己业务的接口审计开始,用脚本跑一遍”水平越权测试”,你会发现——大概率,问题比你想象的要多。
