搞Java开发这些年,我见得最多的安全漏洞不是SQL注入,也不是XSS,而是越权——尤其是标题里的水平越权和垂直越权这两类。很多项目上线后被第三方安全测试一扫描,报告里先蹦出来的往往就是一个订单查询接口越权访问的记录。这玩意儿不像底层内存破坏漏洞那样听着技术感十足,但危害一点不少:用户能看到别人的订单、账单、通讯录,或者普通账号摸到后台管理接口,把数据批量拖走。在Java生态里,这个问题基本是"版本常青树",从老掉牙的SSH项目到最新的Spring Boot单体服务,我经手过的系统里,几乎每换一家公司都能在遗留代码里翻出几处。这篇文章就把水平越权和垂直越权这两条线从头到尾拆一遍,包括它们怎么产生、怎么被利用、怎么修复,以及怎么从架构层面防患于未然。做业务开发的兄弟、刚转安全的测试同学,还有负责技术架构的Leader,都能在这篇里找到自己关心的那部分。1. 越权漏洞的本质与两种形态1.1 认证与授权的边界为什么总被混淆先理清两个概念。认证是"证明你是谁",授权是"确定你能干什么"。很多越权漏洞的根子,就是把权限判断和身份判断写在了一起,或者干脆只做了认证就认为万事大吉。用生活里的场景打比方:小区大门刷卡进入,这是认证;走进楼道后,你家房门的钥匙只开你家的锁,这是授权。如果你进了小区大门就以为可以顺手推开每一户人家的房门,那就闹出越权笑话了。开发时也一样:用户登录之后,系统拿到了他的身份,不代表他天然有权访问接口里的任何数据。我见过不少项目,登录逻辑做得挺严格,密码加密、令牌过期都齐全,结果Controller里所有查询接口直接拿前端传的ID去数据库检索,没有任何对象归属的判断。这种状态下,只要登录了,用户A把请求里的数字改成用户B的ID,就能把B的数据看个底朝天。这就是典型的把授权完全交给了前端,服务端只做了认证。很多刚入行的开发容易把"登录页面做了校验"等同于"接口安全了",这是对安全模型最大的误解之一。1.2 水平越权与垂直越权的差异对照水平越权,也叫横向越权,针对的是同一个权限层级内的资源访问。普通用户A去看普通用户B的私有数据,就是水平越权。它的核心问题是"对象归属"校验缺失,系统只确认了请求者是合法用户,但没确认被访问的数据属于这个用户。垂直越权则完全不同,它跨的是权限层级边界。普通账号直接调用只有管理员才允许执行的接口,比如批量删除用户、修改全局配置、查看审计日志,这些就属于垂直越权。它的核心问题是"角色等级"校验缺失,或者校验方式能被绕过。两者从攻击面上看差异很大:水平越权通常依赖业务数据的ID枚举,往往要猜或者遍历数字ID;垂直越权则更多是直接猜接口路径、抓前端请求,或者利用常规权限校验的盲区。把两者在产生原因、典型场景、攻击难度上的区别列出来,排障时可以对照:对比维度水平越权垂直越权权限层级同一层级内的越界跨层级的越权产生原因缺少对象归属校验缺少角色/权限校验典型场景查看他人订单、他人资料普通用户调用管理后台接口攻击路径枚举ID、遍历业务键猜测URL、直接构造请求防护思路查询时校验所有权方法级角色权限控制我经常发现刚接触安全的同学把两者混为一谈,拿到安全报告后一直在改登录逻辑,结果越权还是不断。先分清类型,再对症下药,至少能少走一半弯路。1.3 Java项目为什么容易踩越权的坑Java服务端的基建已经相当完善,但框架的便利性反而让越权问题有了生存空间。第一个原因是很多项目默认只配了Spring Security或者Shiro的登录认证,没有把方法级权限打开。框架默认行为是"能登录就能访问,只要URL能匹配上"。第二个原因是业务开发节奏快,接口设计常把用户身份当作"运行前提"而不是"查询条件"。大家默认当前用户肯定能传对参数,却没有意识到前端表单里的ID是可以被篡改的。第三个原因是权限模型的建立往往滞后于数据模型,为了赶进度先用一个boolean字段区分"是不是管理员",等角色多了才想起角色-权限映射,中间这段空窗期足够出一堆垂直越权。这三个原因叠加下来,Java项目里的越权漏洞出现频率高到离谱,而且越是业务模块多、接口复用多的后台系统,越权点越多。所以每次做安全评审,我第一件事就是问:这个系统现在有权限模型吗?权限校验统一收口了吗?得到的答案往往让人心里一凉。2. 水平越权的典型场景与技术解析2.1 一个订单查询接口的越权事故复盘分享一个我实际排查过的案例,代码经过简化但保留了核心问题。业务是一个订单管理服务,Controller里的查询接口长这样:@RestController @RequestMapping("/api/order") public class OrderController { @GetMapping("/{orderId}") public OrderVO loadOrder(@PathVariable("orderId") Long orderId) { Order order = orderService.getById(orderId); return OrderConvert.INSTANCE.toVO(order); } }这个接口从入参到返回,所有逻辑都只围绕orderId,完全没有当前登录用户的参与。正常情况下,用户在订单列表页点一条订单,前端把orderId传到后端,看起来没问题。但问题就出在orderId是自增主键,用户A的订单是1001号,用户B的订单是1002号,A把自己的请求改成1002,拿到的是B的完整订单信息,包括收货人、手机号、购买明细。这个改动在前端连一个回车都不用,抓包工具或者浏览器开发者工具改一下路径就能完成。更麻烦的是,这种接口不止一个,付款记录、收货地址、发票信息全都类似,等于把整套隐私数据都挂了网。2.2 越权修复的常见弯路第一个弯路是给前端加判断:前端判断当前订单的所属用户ID等于登录用户ID就显示详情按钮,否则隐藏。这能挡住普通用户的操作路径,但挡不住构造请求。前端隐藏按钮只是遮挡,后端接口依然对所有人开放,攻击者用脚本直接请求接口,一样能拿到数据。第二个弯路是只给数字ID加个难猜的编码,比如把1002改成一段随机字符串。这个做法的确提高了枚举成本,但接口本身仍然缺少归属校验,一旦ID通过订单号、邀请链接、搜索结果泄露出去,越权照样发生。核心结论是:任何不由服务端主动带入的归属条件都不可靠,安全边界必须建立在服务端不可绕过的基础上。2.3 把授权条件压进查询的正确思路正确的修复方法,是把当前登录用户的ID作为查询条件的一部分,而不是等查询结果出来后再做比较。这么做有一个关键好处:当数据不存在时,接口天然返回"不存在",而不是"无权限",更能防止侧信道泄露。改造后的Service层代码类似:@Service public class OrderService { private final OrderMapper orderMapper; public OrderVO loadOrder(Long orderId, Long currentUserId) { Order order = orderMapper.selectByOrderIdAndUserId(orderId, currentUserId); if (order == null) { throw new OrderNotFoundException("订单不存在"); } return OrderConvert.INSTANCE.toVO(order); } }对应的MyBatis XML里也同步把用户条件写进SQL:<select id="selectByOrderIdAndUserId" resultType="Order"> SELECT id, order_no, user_id, receiver_name, receiver_phone FROM t_order WHERE id = #{orderId} AND user_id = #{currentUserId} </select>这样修改之后,A去请求B的订单ID,SQL查不到任何记录,返回"订单不存在"。攻击者既拿不到数据,也确认不了这个订单号是否真实存在。当前用户ID从哪来?从SecurityContext或者Session中取,无论如何都不该由前端传过来。这里要特别强调,用ORM的Java对象比较方式做"先查出对象再判断userId是否相等"也能实现功能,但不如SQL条件来得干净,因为后面的方案天然避免了把他人数据加载进内存的副作用。2.4 业务键枚举与ID选型的冷思考有些人认为只要把自增ID换成UUID就能避免水平越权。这个思路的逻辑是UUID不可枚举,攻击者没有顺序扫描的抓手。但我要泼一盆冷水:UUID能减缓攻击,不能消灭漏洞。业务场景里UUID照样可能通过订单通知、导出文件、分享链接暴露出去,拿到一个有效的UUID一样可以水平越权。我的建议是ID选型可以兼顾安全和易用,但永远不要觉得"ID不可猜"就能替代授权判断。业务键层面的防枚举只是纵深防御的一层,和SQL里的用户条件配合使用,才能真正把水平越权堵住。另外,如果因为历史原因必须保留自增ID,那就更要在查询层严加校验,因为自增ID的枚举成本几乎为零。3. 垂直越权的攻击路径与防护手段3.1 后台管理接口的垂直越权事故垂直越权的典型案例是普通用户访问管理员功能。之前审查过一个内容管理系统的后台,管理端Controller是这样的:@RestController @RequestMapping("/admin/system") public class SystemAdminController { @DeleteMapping("/users/{id}") public ResultVO deleteUser(@PathVariable("id") Long id) { userAdminService.forceDeleteUser(id); return ResultVO.success("操作成功"); } }这个接口的URL以/admin开头,看起来"天然"只有管理员知道,前端管理页面也压根不在普通用户的菜单里展示。但后端没有任何角色校验,接口直接裸奔在环境中。普通用户拿到自己的登录态,手动在浏览器地址栏输入一个删除用户的URL,请求就会被服务器接受,目标账号就被强制删除了。有些场景更夸张,整个/admin前缀的URL都在登录拦截器的白名单之外,实际上以"只要登录就行"的规则放行,连普通账号都不用太精心挑选,随手就能调。3.2 为什么URL前缀拦截与前端菜单隐藏不可靠先说前端菜单隐藏的问题。它和水平越权里隐藏按钮的道理一样,只影响界面,不影响接口受理。再细看URL前缀拦截,很多项目在拦截器或者Filter里写了一段逻辑:请求路径以/admin开头就必须有管理员身份。这个思路听着靠谱,落地全是漏洞。第一,接口可能是通过路径拼接方式绕过前缀匹配的,这类URL解析差异在Web容器之间并不完全一致;第二,如果项目里有网关层和业务层两套路由,URL在网关层被重写,前缀规则就可能失效;第三,代码里直接调用服务方法不走HTTP的情况,比如定时任务、监听器里调用了管理逻辑,URL拦截对这些入口完全失效。权限判断如果只依赖"路径长什么样",本质上还是在碰运气。3.3 方法级权限校验才是垂直越权的解药要真正解决垂直越权,得把权限判断从拦截层搬到方法级。Spring Security框架中,开启方法安全注解后可以直接在接口方法上声明所需权限:@Configuration @EnableMethodSecurity public class SecurityConfig { }方法上加上注解:@PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/users/{id}") public ResultVO deleteUser(@PathVariable("id") Long id) { userAdminService.forceDeleteUser(id); return ResultVO.success("操作成功"); }加完之后,普通用户再请求删除接口,会在进入方法之前就被AOP拦截下来,抛出访问拒绝异常。和拦截器相比,方法级校验直接作用在真正执行操作的方法上,不管请求是从Controller进来、从消息队列触发,还是被内部组件调用,只要进了这个带注解的方法就得过权限关。这里特别强调"调用入口统一"的价值,因为服务间调用、异步任务、消息消费这些非HTTP链路如果只靠Web层校验,很容易变成权限的真空地带。3.4 从布尔标记到RBAC权限模型的演进老项目里垂直越权一个常见根源是用boolean字段判断管理员身份。用户表里一个is_admin字段,值为1就是管理员,0就不是。这种方式在角色少的时候确实够用,但随着系统发展,就出现了几个明显问题:一是超级管理员和管理员无法区分,权限粒度太粗;二是新增运营这类中间角色无从安置,只能不断叠加标记字段;三是没有权限点层面的控制,所有管理功能只要拥有管理员角色就都能操作,审计和授权都无从谈起。切换到RBAC模型之后,用户-角色-权限三张表,角色绑定权限点,用户绑定角色,方法级注解直接从权限点映射。这样权限判断变得可配置、可审计,垂直越权的修复也落到了数据层面,而不是散落在各种if-else里。从实际改造经验来看,RBAC的落地并不复杂,复杂的是把老代码里隐含的角色假设一个个找出来,然后重新映射到权限点上。4. 越权漏洞的排查思路与修复实操4.1 代码审计阶段怎么快速定位越权点接手一个老项目,想要全面自查越权漏洞,不需要把所有代码从头读到尾。我的经验是先做接口清单扫描,再做针对性审查。第一步,从Controller层把所有对外暴露的HTTP接口列出来,标注方法和路径。第二步,重点挑出带路径参数的接口,比如@GetMapping("/xx/{id}")这类,几乎是水平越权的重灾区。第三步,选出所有返回业务数据明细的查询方法,看方法体内有没有复用当前用户上下文。如果Controller里只写了业务Service调用,没提到任何用户信息,那这条链路极可能就是越权点。垂直越权的排查则简单直接:找出所有管理端Controller,检查每个写操作和敏感读操作背后有没有权限注解。把接口清单、权限注解情况、是否带用户条件三列信息填到一张表里,漏洞分布立刻就有画面了。我习惯用IDE的全局搜索功能把@PreAuthorize、@RequiresPermissions这类注解和所有@GetMapping、@PostMapping都拉出来,对比一下覆盖关系,比一行一行读源码效率高得多。4.2 修复时常用的三类落地姿势第一类是查询条件合并,适用于水平越权。把用户条件放进SQL或JPA查询,确保查询结果天然属于当前用户。第二类是权限注解,适用于垂直越权。在管理方法上加方法级安全注解,并配置好权限模型。第三类是远程调用的上下文透传,适用于微服务场景。用户身份不能在每个服务里各自从请求头里猜,需要透传并统一校验。三类姿势可以组合使用,比如一个删除接口既要做管理员角色校验,又要确认被删资源归属某个组织。修复时不要图省事只加一个角色判断就收工,要把资源归属的校验一并补上,否则你修完垂直越权,可能又留下一个水平越权。4.3 统一数据权限与租户隔离的实践经验当系统里用户权限不是单层面的"我的数据",而是还牵扯部门和组织,比如销售只能看本部门订单,部门总监能看本部门全部订单,光靠一个userId条件就不够了。这时候需要把"数据范围"也抽象出来。我经历过的一个项目做法是引入数据权限枚举:全部、本部门及下级、仅本人、自定义。查询层统一拼接对应的数据范围条件,通过MyBatis拦截器在SQL尾部追加过滤片段,业务Service里不用重复写权限SQL。多租户SaaS系统里的租户隔离是同一个道理,在每张业务表里带上tenant_id,所有查询强制追加tenant_id等于当前租户。这层逻辑一旦不小心在某个Service里漏了,数据串租户的越权风险马上就会在线上爆发。我的体会是这种公共逻辑一定由底层统一处理,不要在业务代码里各自为战。一旦权限拼接逻辑散落到上百个Mapper里,迟早会有漏网之鱼。4.4 自动化测试中把越权用例固化进CI越权漏洞修好之后,怎么防止下次改动代码时回退?答案是把它写成自动化用例,塞进CI流程。我在项目里维护了一套越权测试用例集,核心思路是用两个测试账号:一个普通用户,一个管理员用户。每个关键接口都跑一遍"低权限用户访问高权限接口"以及"用户A访问用户B数据"的路径,断言结果是403或者"不存在"。这套逻辑用接口层的集成测试就能跑,写一次以后每次发版前自动执行。成本不高,但收益立竿见影,很多回归型越权在合并请求阶段就被拦下来了,而不是等到生产环境出事才补救。我还习惯把测试用例的命名写得直白一些,比如orderDetail_cannot_see_other_users_order,这样失败的时候一眼就能看出是哪条安全规则被破坏了。5. 越权漏洞的常见问题排查实录5.1 处置越权漏洞时的高频难题把日常支持过程中被问得最多的问题整理成一张速查表,配合相应的处置思路,排障时可以直接按图索骥:问题现象可能原因处置建议前端隐藏了管理入口,抓包仍能调用后台接口后端缺少角色校验在管理方法上增加权限注解用户改URL里的ID能看他人数据查询未绑定用户条件查询SQL中追加用户归属条件修改ID后提示"无权限",但信息仍泄露一点返回体中带出了目标数据是否存在统一返回"不存在",不区分原因普通账号能访问部分管理功能权限模型只有角色没有权限点切分角色与资源权限,逐项收敛微服务内部调用不校验权限身份上下文中缺少用户角色传透透传登录上下文并统一校验这张表之外还有几个高频场景值得强调。比如"接口返回403,但日志里数据还是被查询了一次",说明校验点放得太靠后,或者查询语句已经执行了才做判断。遇到这种情况要把校验前移,最理想是在进入Service之前就完成授权。5.2 排查越权时的两个反直觉提醒第一,不要把"登录用户ID没传"当作小问题。有的接口把userId当作可选参数,前端不传后端就去会话上下文取,某个路径忘了取就默认全查。这种隐式的假设一旦被某个跨页面调用打破,就给了越权可乘之机。我在代码评审时看到@RequestParam(required = false) Long userId都会格外警惕,这种参数几乎就是为了越权准备的。第二,警惕公共下拉框和导出功能。列表查询大多数人知道加权限条件,但导出Excel接口和下拉选项接口往往被忽略。用户A导出订单Excel时,如果导出逻辑没有绑定userId,那么所有订单都进了文件里。这类功能影响面大、数据量全,比普通详情接口的危害高一个量级。排查越权时,导出一类接口必须单独立项过一遍。6. 从架构层面把越权漏洞堵住6.1 把授权判断收敛到服务层一个比较粗暴但有效的实践,是把授权判断的收口位置从Controller层下放到Service层入口。为什么这么做?Controller层通常只是参数和HTTP语义的翻译层,如果有多个Controller指向同一个Service方法,比如App端、管理端、开放接口各来一个Controller,在Controller层分别判断角色容易遗漏。Service层的入口是业务逻辑的统一汇集点,在这里做权限判断,所有进到这个业务方法的调用者都必须过同一道门。再加上方法级安全注解之后,一个入口判断就能保护所有调用来源,还少写很多重复代码。这个思路的额外好处是权限逻辑和业务逻辑写在同一个地方,代码评审的时候一眼就能看到某个方法允许谁调用,而不是需要从网关到拦截器再到Controller一路翻代码。6.2 用户上下文与身份透传机制越权漏洞修复时最麻烦的问题是"拿不到当前用户"和"拿到的不是真实用户"。单体应用里从SecurityContextHolder拿用户比较直接,微服务拆分后,服务间调用往往走内部RPC或消息队列,登录态令牌不会自动传过去。方案是为每个请求生成带用户身份与角色的上下文对象,网关或上游服务把它塞进RPC的附加信息,下游服务校验签名后恢复上下文。如果没有统一的身份透传机制,下游服务拿不到用户信息,干脆就不做权限判断,等于默认放行所有内部调用,这是比HTTP层越权更危险的架构级漏洞。所以越权治理不单是写几行校验代码,更多时候要把用户身份这条链路打通。这块基建如果没做好,后面所有的服务级权限控制都是纸上谈兵。6.3 开发规范、Code Review与安全培训越权漏洞靠再多的技术手段也兜不住没有安全意识的人。我在团队里推过三件小事,效果比想象中好:第一,把越权防护写进团队开发规范,所有查询接口必须说明"当前用户数据如何隔离",没有说明的合并请求不允许合入;第二,Code Review里专门加一个安全检查项,看新增接口有没有授权注解、查询是否绑定了用户条件;第三,给后端同学做一次越权漏洞专题分享,直接用项目里真实出过的问题做案例,比任何安全培训材料都触目惊心。这三件事的成本极低,但能大幅减少越权问题的反复出现。尤其是Code Review的检查项,等于把安全防线前置到了日常开发流程里,不需要等安全测试来发现问题,开发阶段就把隐患按住了。6.4 安全测试与上线前的最后防线就算前面所有环节都做了,上线前的安全测试依然必不可少。我建议把越权测试做成安全测试用例里的固定栏目,每次发版前跑一轮。测试账号体系平时就要准备齐全,不能等到测试了才发现连个低权限账号都没有。安全测试的预算,优先覆盖订单详情、用户信息、后台管理等最敏感的数据链路。上线前发现的越权问题,改起来可能只需要几行代码,上线后变成安全事件再进行紧急修复,成本和影响完全不是一个数量级。这一点不管项目大小都成立,自动化越权用例、固定账号、安全测试流程,是防住越权的最后一关。我亲眼见过一个系统扛过了功能测试、性能测试,最后在安全测试阶段被翻出十几个越权点,项目差点延期上线。所以越权问题一定要提前管,别指望最后关头能一晚上修完。我在实际项目中跟用户数据安全打了多年交道,最大的体会是:越权问题永远不是"改一个接口就结束",它是权限体系设计在工程落地上的持续考验。每次修复完一个越权点,我都会顺手把同类接口全查一遍,把测试用例补上,再回到设计层面看看有没有共性问题。如果你的项目还没建立权限模型,那就从现在开始梳理角色与资源权限,把用户上下文和Service层权限校验的骨架搭起来;如果已经出了问题,也别慌,按这篇文章的排查步骤先把漏洞点堵上,再逐步收敛到统一机制。最后提醒一句:授权判断一定要放在服务端,永远不要把权限的底牌放在前端。
搞Java开发这些年,我见得最多的安全漏洞不是SQL注入,也不是XSS,而是越权——尤其是标题里的水平越权和垂直越权这两类。很多项目上线后被第三方安全测试一扫描,报告里先蹦出来的往往就是一个订单查询接口越权访问的记录。这玩意儿不像底层内存破坏漏洞那样听着技术感十足,但危害一点不少:用户能看到别人的订单、账单、通讯录,或者普通账号摸到后台管理接口,把数据批量拖走。在Java生态里,这个问题基本是"版本常青树",从老掉牙的SSH项目到最新的Spring Boot单体服务,我经手过的系统里,几乎每换一家公司都能在遗留代码里翻出几处。这篇文章就把水平越权和垂直越权这两条线从头到尾拆一遍,包括它们怎么产生、怎么被利用、怎么修复,以及怎么从架构层面防患于未然。做业务开发的兄弟、刚转安全的测试同学,还有负责技术架构的Leader,都能在这篇里找到自己关心的那部分。1. 越权漏洞的本质与两种形态1.1 认证与授权的边界为什么总被混淆先理清两个概念。认证是"证明你是谁",授权是"确定你能干什么"。很多越权漏洞的根子,就是把权限判断和身份判断写在了一起,或者干脆只做了认证就认为万事大吉。用生活里的场景打比方:小区大门刷卡进入,这是认证;走进楼道后,你家房门的钥匙只开你家的锁,这是授权。如果你进了小区大门就以为可以顺手推开每一户人家的房门,那就闹出越权笑话了。开发时也一样:用户登录之后,系统拿到了他的身份,不代表他天然有权访问接口里的任何数据。我见过不少项目,登录逻辑做得挺严格,密码加密、令牌过期都齐全,结果Controller里所有查询接口直接拿前端传的ID去数据库检索,没有任何对象归属的判断。这种状态下,只要登录了,用户A把请求里的数字改成用户B的ID,就能把B的数据看个底朝天。这就是典型的把授权完全交给了前端,服务端只做了认证。很多刚入行的开发容易把"登录页面做了校验"等同于"接口安全了",这是对安全模型最大的误解之一。1.2 水平越权与垂直越权的差异对照水平越权,也叫横向越权,针对的是同一个权限层级内的资源访问。普通用户A去看普通用户B的私有数据,就是水平越权。它的核心问题是"对象归属"校验缺失,系统只确认了请求者是合法用户,但没确认被访问的数据属于这个用户。垂直越权则完全不同,它跨的是权限层级边界。普通账号直接调用只有管理员才允许执行的接口,比如批量删除用户、修改全局配置、查看审计日志,这些就属于垂直越权。它的核心问题是"角色等级"校验缺失,或者校验方式能被绕过。两者从攻击面上看差异很大:水平越权通常依赖业务数据的ID枚举,往往要猜或者遍历数字ID;垂直越权则更多是直接猜接口路径、抓前端请求,或者利用常规权限校验的盲区。把两者在产生原因、典型场景、攻击难度上的区别列出来,排障时可以对照:对比维度水平越权垂直越权权限层级同一层级内的越界跨层级的越权产生原因缺少对象归属校验缺少角色/权限校验典型场景查看他人订单、他人资料普通用户调用管理后台接口攻击路径枚举ID、遍历业务键猜测URL、直接构造请求防护思路查询时校验所有权方法级角色权限控制我经常发现刚接触安全的同学把两者混为一谈,拿到安全报告后一直在改登录逻辑,结果越权还是不断。先分清类型,再对症下药,至少能少走一半弯路。1.3 Java项目为什么容易踩越权的坑Java服务端的基建已经相当完善,但框架的便利性反而让越权问题有了生存空间。第一个原因是很多项目默认只配了Spring Security或者Shiro的登录认证,没有把方法级权限打开。框架默认行为是"能登录就能访问,只要URL能匹配上"。第二个原因是业务开发节奏快,接口设计常把用户身份当作"运行前提"而不是"查询条件"。大家默认当前用户肯定能传对参数,却没有意识到前端表单里的ID是可以被篡改的。第三个原因是权限模型的建立往往滞后于数据模型,为了赶进度先用一个boolean字段区分"是不是管理员",等角色多了才想起角色-权限映射,中间这段空窗期足够出一堆垂直越权。这三个原因叠加下来,Java项目里的越权漏洞出现频率高到离谱,而且越是业务模块多、接口复用多的后台系统,越权点越多。所以每次做安全评审,我第一件事就是问:这个系统现在有权限模型吗?权限校验统一收口了吗?得到的答案往往让人心里一凉。2. 水平越权的典型场景与技术解析2.1 一个订单查询接口的越权事故复盘分享一个我实际排查过的案例,代码经过简化但保留了核心问题。业务是一个订单管理服务,Controller里的查询接口长这样:@RestController @RequestMapping("/api/order") public class OrderController { @GetMapping("/{orderId}") public OrderVO loadOrder(@PathVariable("orderId") Long orderId) { Order order = orderService.getById(orderId); return OrderConvert.INSTANCE.toVO(order); } }这个接口从入参到返回,所有逻辑都只围绕orderId,完全没有当前登录用户的参与。正常情况下,用户在订单列表页点一条订单,前端把orderId传到后端,看起来没问题。但问题就出在orderId是自增主键,用户A的订单是1001号,用户B的订单是1002号,A把自己的请求改成1002,拿到的是B的完整订单信息,包括收货人、手机号、购买明细。这个改动在前端连一个回车都不用,抓包工具或者浏览器开发者工具改一下路径就能完成。更麻烦的是,这种接口不止一个,付款记录、收货地址、发票信息全都类似,等于把整套隐私数据都挂了网。2.2 越权修复的常见弯路第一个弯路是给前端加判断:前端判断当前订单的所属用户ID等于登录用户ID就显示详情按钮,否则隐藏。这能挡住普通用户的操作路径,但挡不住构造请求。前端隐藏按钮只是遮挡,后端接口依然对所有人开放,攻击者用脚本直接请求接口,一样能拿到数据。第二个弯路是只给数字ID加个难猜的编码,比如把1002改成一段随机字符串。这个做法的确提高了枚举成本,但接口本身仍然缺少归属校验,一旦ID通过订单号、邀请链接、搜索结果泄露出去,越权照样发生。核心结论是:任何不由服务端主动带入的归属条件都不可靠,安全边界必须建立在服务端不可绕过的基础上。2.3 把授权条件压进查询的正确思路正确的修复方法,是把当前登录用户的ID作为查询条件的一部分,而不是等查询结果出来后再做比较。这么做有一个关键好处:当数据不存在时,接口天然返回"不存在",而不是"无权限",更能防止侧信道泄露。改造后的Service层代码类似:@Service public class OrderService { private final OrderMapper orderMapper; public OrderVO loadOrder(Long orderId, Long currentUserId) { Order order = orderMapper.selectByOrderIdAndUserId(orderId, currentUserId); if (order == null) { throw new OrderNotFoundException("订单不存在"); } return OrderConvert.INSTANCE.toVO(order); } }对应的MyBatis XML里也同步把用户条件写进SQL:<select id="selectByOrderIdAndUserId" resultType="Order"> SELECT id, order_no, user_id, receiver_name, receiver_phone FROM t_order WHERE id = #{orderId} AND user_id = #{currentUserId} </select>这样修改之后,A去请求B的订单ID,SQL查不到任何记录,返回"订单不存在"。攻击者既拿不到数据,也确认不了这个订单号是否真实存在。当前用户ID从哪来?从SecurityContext或者Session中取,无论如何都不该由前端传过来。这里要特别强调,用ORM的Java对象比较方式做"先查出对象再判断userId是否相等"也能实现功能,但不如SQL条件来得干净,因为后面的方案天然避免了把他人数据加载进内存的副作用。2.4 业务键枚举与ID选型的冷思考有些人认为只要把自增ID换成UUID就能避免水平越权。这个思路的逻辑是UUID不可枚举,攻击者没有顺序扫描的抓手。但我要泼一盆冷水:UUID能减缓攻击,不能消灭漏洞。业务场景里UUID照样可能通过订单通知、导出文件、分享链接暴露出去,拿到一个有效的UUID一样可以水平越权。我的建议是ID选型可以兼顾安全和易用,但永远不要觉得"ID不可猜"就能替代授权判断。业务键层面的防枚举只是纵深防御的一层,和SQL里的用户条件配合使用,才能真正把水平越权堵住。另外,如果因为历史原因必须保留自增ID,那就更要在查询层严加校验,因为自增ID的枚举成本几乎为零。3. 垂直越权的攻击路径与防护手段3.1 后台管理接口的垂直越权事故垂直越权的典型案例是普通用户访问管理员功能。之前审查过一个内容管理系统的后台,管理端Controller是这样的:@RestController @RequestMapping("/admin/system") public class SystemAdminController { @DeleteMapping("/users/{id}") public ResultVO deleteUser(@PathVariable("id") Long id) { userAdminService.forceDeleteUser(id); return ResultVO.success("操作成功"); } }这个接口的URL以/admin开头,看起来"天然"只有管理员知道,前端管理页面也压根不在普通用户的菜单里展示。但后端没有任何角色校验,接口直接裸奔在环境中。普通用户拿到自己的登录态,手动在浏览器地址栏输入一个删除用户的URL,请求就会被服务器接受,目标账号就被强制删除了。有些场景更夸张,整个/admin前缀的URL都在登录拦截器的白名单之外,实际上以"只要登录就行"的规则放行,连普通账号都不用太精心挑选,随手就能调。3.2 为什么URL前缀拦截与前端菜单隐藏不可靠先说前端菜单隐藏的问题。它和水平越权里隐藏按钮的道理一样,只影响界面,不影响接口受理。再细看URL前缀拦截,很多项目在拦截器或者Filter里写了一段逻辑:请求路径以/admin开头就必须有管理员身份。这个思路听着靠谱,落地全是漏洞。第一,接口可能是通过路径拼接方式绕过前缀匹配的,这类URL解析差异在Web容器之间并不完全一致;第二,如果项目里有网关层和业务层两套路由,URL在网关层被重写,前缀规则就可能失效;第三,代码里直接调用服务方法不走HTTP的情况,比如定时任务、监听器里调用了管理逻辑,URL拦截对这些入口完全失效。权限判断如果只依赖"路径长什么样",本质上还是在碰运气。3.3 方法级权限校验才是垂直越权的解药要真正解决垂直越权,得把权限判断从拦截层搬到方法级。Spring Security框架中,开启方法安全注解后可以直接在接口方法上声明所需权限:@Configuration @EnableMethodSecurity public class SecurityConfig { }方法上加上注解:@PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/users/{id}") public ResultVO deleteUser(@PathVariable("id") Long id) { userAdminService.forceDeleteUser(id); return ResultVO.success("操作成功"); }加完之后,普通用户再请求删除接口,会在进入方法之前就被AOP拦截下来,抛出访问拒绝异常。和拦截器相比,方法级校验直接作用在真正执行操作的方法上,不管请求是从Controller进来、从消息队列触发,还是被内部组件调用,只要进了这个带注解的方法就得过权限关。这里特别强调"调用入口统一"的价值,因为服务间调用、异步任务、消息消费这些非HTTP链路如果只靠Web层校验,很容易变成权限的真空地带。3.4 从布尔标记到RBAC权限模型的演进老项目里垂直越权一个常见根源是用boolean字段判断管理员身份。用户表里一个is_admin字段,值为1就是管理员,0就不是。这种方式在角色少的时候确实够用,但随着系统发展,就出现了几个明显问题:一是超级管理员和管理员无法区分,权限粒度太粗;二是新增运营这类中间角色无从安置,只能不断叠加标记字段;三是没有权限点层面的控制,所有管理功能只要拥有管理员角色就都能操作,审计和授权都无从谈起。切换到RBAC模型之后,用户-角色-权限三张表,角色绑定权限点,用户绑定角色,方法级注解直接从权限点映射。这样权限判断变得可配置、可审计,垂直越权的修复也落到了数据层面,而不是散落在各种if-else里。从实际改造经验来看,RBAC的落地并不复杂,复杂的是把老代码里隐含的角色假设一个个找出来,然后重新映射到权限点上。4. 越权漏洞的排查思路与修复实操4.1 代码审计阶段怎么快速定位越权点接手一个老项目,想要全面自查越权漏洞,不需要把所有代码从头读到尾。我的经验是先做接口清单扫描,再做针对性审查。第一步,从Controller层把所有对外暴露的HTTP接口列出来,标注方法和路径。第二步,重点挑出带路径参数的接口,比如@GetMapping("/xx/{id}")这类,几乎是水平越权的重灾区。第三步,选出所有返回业务数据明细的查询方法,看方法体内有没有复用当前用户上下文。如果Controller里只写了业务Service调用,没提到任何用户信息,那这条链路极可能就是越权点。垂直越权的排查则简单直接:找出所有管理端Controller,检查每个写操作和敏感读操作背后有没有权限注解。把接口清单、权限注解情况、是否带用户条件三列信息填到一张表里,漏洞分布立刻就有画面了。我习惯用IDE的全局搜索功能把@PreAuthorize、@RequiresPermissions这类注解和所有@GetMapping、@PostMapping都拉出来,对比一下覆盖关系,比一行一行读源码效率高得多。4.2 修复时常用的三类落地姿势第一类是查询条件合并,适用于水平越权。把用户条件放进SQL或JPA查询,确保查询结果天然属于当前用户。第二类是权限注解,适用于垂直越权。在管理方法上加方法级安全注解,并配置好权限模型。第三类是远程调用的上下文透传,适用于微服务场景。用户身份不能在每个服务里各自从请求头里猜,需要透传并统一校验。三类姿势可以组合使用,比如一个删除接口既要做管理员角色校验,又要确认被删资源归属某个组织。修复时不要图省事只加一个角色判断就收工,要把资源归属的校验一并补上,否则你修完垂直越权,可能又留下一个水平越权。4.3 统一数据权限与租户隔离的实践经验当系统里用户权限不是单层面的"我的数据",而是还牵扯部门和组织,比如销售只能看本部门订单,部门总监能看本部门全部订单,光靠一个userId条件就不够了。这时候需要把"数据范围"也抽象出来。我经历过的一个项目做法是引入数据权限枚举:全部、本部门及下级、仅本人、自定义。查询层统一拼接对应的数据范围条件,通过MyBatis拦截器在SQL尾部追加过滤片段,业务Service里不用重复写权限SQL。多租户SaaS系统里的租户隔离是同一个道理,在每张业务表里带上tenant_id,所有查询强制追加tenant_id等于当前租户。这层逻辑一旦不小心在某个Service里漏了,数据串租户的越权风险马上就会在线上爆发。我的体会是这种公共逻辑一定由底层统一处理,不要在业务代码里各自为战。一旦权限拼接逻辑散落到上百个Mapper里,迟早会有漏网之鱼。4.4 自动化测试中把越权用例固化进CI越权漏洞修好之后,怎么防止下次改动代码时回退?答案是把它写成自动化用例,塞进CI流程。我在项目里维护了一套越权测试用例集,核心思路是用两个测试账号:一个普通用户,一个管理员用户。每个关键接口都跑一遍"低权限用户访问高权限接口"以及"用户A访问用户B数据"的路径,断言结果是403或者"不存在"。这套逻辑用接口层的集成测试就能跑,写一次以后每次发版前自动执行。成本不高,但收益立竿见影,很多回归型越权在合并请求阶段就被拦下来了,而不是等到生产环境出事才补救。我还习惯把测试用例的命名写得直白一些,比如orderDetail_cannot_see_other_users_order,这样失败的时候一眼就能看出是哪条安全规则被破坏了。5. 越权漏洞的常见问题排查实录5.1 处置越权漏洞时的高频难题把日常支持过程中被问得最多的问题整理成一张速查表,配合相应的处置思路,排障时可以直接按图索骥:问题现象可能原因处置建议前端隐藏了管理入口,抓包仍能调用后台接口后端缺少角色校验在管理方法上增加权限注解用户改URL里的ID能看他人数据查询未绑定用户条件查询SQL中追加用户归属条件修改ID后提示"无权限",但信息仍泄露一点返回体中带出了目标数据是否存在统一返回"不存在",不区分原因普通账号能访问部分管理功能权限模型只有角色没有权限点切分角色与资源权限,逐项收敛微服务内部调用不校验权限身份上下文中缺少用户角色传透透传登录上下文并统一校验这张表之外还有几个高频场景值得强调。比如"接口返回403,但日志里数据还是被查询了一次",说明校验点放得太靠后,或者查询语句已经执行了才做判断。遇到这种情况要把校验前移,最理想是在进入Service之前就完成授权。5.2 排查越权时的两个反直觉提醒第一,不要把"登录用户ID没传"当作小问题。有的接口把userId当作可选参数,前端不传后端就去会话上下文取,某个路径忘了取就默认全查。这种隐式的假设一旦被某个跨页面调用打破,就给了越权可乘之机。我在代码评审时看到@RequestParam(required = false) Long userId都会格外警惕,这种参数几乎就是为了越权准备的。第二,警惕公共下拉框和导出功能。列表查询大多数人知道加权限条件,但导出Excel接口和下拉选项接口往往被忽略。用户A导出订单Excel时,如果导出逻辑没有绑定userId,那么所有订单都进了文件里。这类功能影响面大、数据量全,比普通详情接口的危害高一个量级。排查越权时,导出一类接口必须单独立项过一遍。6. 从架构层面把越权漏洞堵住6.1 把授权判断收敛到服务层一个比较粗暴但有效的实践,是把授权判断的收口位置从Controller层下放到Service层入口。为什么这么做?Controller层通常只是参数和HTTP语义的翻译层,如果有多个Controller指向同一个Service方法,比如App端、管理端、开放接口各来一个Controller,在Controller层分别判断角色容易遗漏。Service层的入口是业务逻辑的统一汇集点,在这里做权限判断,所有进到这个业务方法的调用者都必须过同一道门。再加上方法级安全注解之后,一个入口判断就能保护所有调用来源,还少写很多重复代码。这个思路的额外好处是权限逻辑和业务逻辑写在同一个地方,代码评审的时候一眼就能看到某个方法允许谁调用,而不是需要从网关到拦截器再到Controller一路翻代码。6.2 用户上下文与身份透传机制越权漏洞修复时最麻烦的问题是"拿不到当前用户"和"拿到的不是真实用户"。单体应用里从SecurityContextHolder拿用户比较直接,微服务拆分后,服务间调用往往走内部RPC或消息队列,登录态令牌不会自动传过去。方案是为每个请求生成带用户身份与角色的上下文对象,网关或上游服务把它塞进RPC的附加信息,下游服务校验签名后恢复上下文。如果没有统一的身份透传机制,下游服务拿不到用户信息,干脆就不做权限判断,等于默认放行所有内部调用,这是比HTTP层越权更危险的架构级漏洞。所以越权治理不单是写几行校验代码,更多时候要把用户身份这条链路打通。这块基建如果没做好,后面所有的服务级权限控制都是纸上谈兵。6.3 开发规范、Code Review与安全培训越权漏洞靠再多的技术手段也兜不住没有安全意识的人。我在团队里推过三件小事,效果比想象中好:第一,把越权防护写进团队开发规范,所有查询接口必须说明"当前用户数据如何隔离",没有说明的合并请求不允许合入;第二,Code Review里专门加一个安全检查项,看新增接口有没有授权注解、查询是否绑定了用户条件;第三,给后端同学做一次越权漏洞专题分享,直接用项目里真实出过的问题做案例,比任何安全培训材料都触目惊心。这三件事的成本极低,但能大幅减少越权问题的反复出现。尤其是Code Review的检查项,等于把安全防线前置到了日常开发流程里,不需要等安全测试来发现问题,开发阶段就把隐患按住了。6.4 安全测试与上线前的最后防线就算前面所有环节都做了,上线前的安全测试依然必不可少。我建议把越权测试做成安全测试用例里的固定栏目,每次发版前跑一轮。测试账号体系平时就要准备齐全,不能等到测试了才发现连个低权限账号都没有。安全测试的预算,优先覆盖订单详情、用户信息、后台管理等最敏感的数据链路。上线前发现的越权问题,改起来可能只需要几行代码,上线后变成安全事件再进行紧急修复,成本和影响完全不是一个数量级。这一点不管项目大小都成立,自动化越权用例、固定账号、安全测试流程,是防住越权的最后一关。我亲眼见过一个系统扛过了功能测试、性能测试,最后在安全测试阶段被翻出十几个越权点,项目差点延期上线。所以越权问题一定要提前管,别指望最后关头能一晚上修完。我在实际项目中跟用户数据安全打了多年交道,最大的体会是:越权问题永远不是"改一个接口就结束",它是权限体系设计在工程落地上的持续考验。每次修复完一个越权点,我都会顺手把同类接口全查一遍,把测试用例补上,再回到设计层面看看有没有共性问题。如果你的项目还没建立权限模型,那就从现在开始梳理角色与资源权限,把用户上下文和Service层权限校验的骨架搭起来;如果已经出了问题,也别慌,按这篇文章的排查步骤先把漏洞点堵上,再逐步收敛到统一机制。最后提醒一句:授权判断一定要放在服务端,永远不要把权限的底牌放在前端。