上一节我们一直盯着浏览器:前端代码可以被阅读,客户端校验可以被跳过,保存在浏览器里的状态也不能自动获得信任。现在把镜头往服务器方向拉远,你会发现同一个问题换了件衣服又出现了。请求经过 CDN、负载均衡、API 网关、BFF、业务服务和消息队列以后,每一层都可能觉得:“前面已经检查过了,我这里应该不用再查。”
真正危险的往往就是这个“应该”。
用户在页面上点击一次“申请退款”,系统里可能发生十几次调用:浏览器先找 BFF,BFF 再找订单服务,订单服务读取数据库并发布消息,退款消费者随后调用支付服务。对用户来说这是一个动作;对系统来说,这是一串身份转换、授权决策、数据复制和资源消耗。任何一段把“调用者是谁”“它代表谁”“它能做什么”答错,错误就会顺着整条链路扩散。
所以这一节不把架构漏洞理解成“微服务不安全”或“网关配置有问题”。我们要找的是更具体的东西:一项信任从哪里产生,穿过哪些边界,在哪一层被重新验证,又在哪一层被想当然地继承。
本节的验证只适用于本地靶场、专用测试环境或书面授权明确列出的系统。授权范围要具体到入口、服务、API 版本、租户、账号角色、允许动作、请求上限和停止条件。管理面、云元数据服务、第三方接口与真实客户租户没有被逐项列入范围时,不做主动探测。
架构图最容易画成一排方框:浏览器、网关、服务、数据库,再加几条箭头,看起来很完整,实际对安全判断帮助不大。因为方框只告诉我们“有什么”,没有告诉我们“为什么这条调用会被允许”。
一张能用于安全评审的图,至少要在每条箭头旁回答下面这些问题:
还要把浏览器之外的参与者补齐:身份提供方、缓存、对象存储、搜索索引、消息队列、定时任务、运维入口和第三方 API。它们不一定都在主请求链路上,却可能保存同一份数据,或持有比前台接口更大的权限。

只给测试人员一份域名清单还不够。更实用的做法,是在信任流图上标出三种范围:允许执行最小验证、只允许读取配置或遥测、明确排除。每一项旁边再写上环境、测试租户、测试身份和停止条件。
例如“允许测试订单系统”仍然太模糊。它至少要被展开成:只使用预发布环境的订单入口,只访问租户甲和租户乙的合成订单,只使用成员与客服两个测试角色;可以发起低频读取和一次状态变更,不允许触发真实支付、真实通知或第三方回调。范围写到这个程度,测试结果才有清楚的基线。
架构安全评估首先是一场对账。团队认为存在的路由、服务身份和数据流,与配置和遥测显示的实际状态是否一致;预期允许的动作,与系统实际作出的决定是否一致。没有基线的“发现”很难区分漏洞、正常管理员能力和测试账号配置错误。
单体、微服务、API 网关和 BFF 不是安全等级从低到高的四个台阶。它们只是把责任放在不同位置。拆分能让某些边界更清楚,也会引入新的网络调用、身份传播和策略同步问题。
单体里,订单模块调用退款模块可能只是一次进程内函数调用。没有网络,就没有服务间令牌和网关绕行问题;但功能授权、对象归属、租户过滤和数据库权限仍然存在。
单体常见的误区是“控制器查过角色,下面的服务就安全了”。一旦后台任务、批处理、管理端或另一个控制器复用同一个业务方法,原来的入口检查可能根本没有执行。更稳妥的设计是让敏感业务操作本身接收明确的调用上下文,并在靠近数据和业务规则的位置作最终授权。数据库账号也不必拥有整个实例的任意权限,至少应按应用需要限制可访问的库、表或操作。
系统拆开后,原来隐含在进程里的调用者身份需要被显式表达。订单服务调用支付服务时,支付服务既要知道“这是订单服务发来的”,也要知道“这次请求代表哪位用户或哪项后台任务”。只验证其中一个都会留下缝隙。
只看用户身份,无法限制被滥用的服务;只看服务身份,又可能让订单服务以自身高权限替任意用户发起退款。合理的决策通常同时使用两层上下文:工作负载身份说明调用来源,用户或任务上下文说明代理主体,再结合目标对象、租户、动作和业务状态作判断。
网关很适合做 TLS 终止、基础认证、请求大小限制、通用频率控制、路由和访问日志。它通常不掌握订单归属、退款状态或字段敏感级别,因此无法独自完成业务授权。
如果网关认证后写入内部身份头,至少要满足三个条件:外部传来的同名头先被删除;业务服务能确认请求确实来自受信入口;身份信息有完整性保护且语义统一。否则,一个看似普通的转发头就可能被误当成可信身份。
业务服务还要防住“第二入口”。调试端口、集群内地址、旧版路由或合作方入口只要能绕过网关,所有只写在网关上的策略就会一起消失。网关是一个策略执行点,不是替全系统签发的免检证明。

BFF 常用于特定前端。它在服务端处理登录流程和令牌,把浏览器看到的状态收敛为会话 Cookie,再代表前端调用后端资源服务。这样可以减少访问令牌直接暴露给浏览器脚本的机会,也能按前端需要裁剪接口。
代价是 BFF 自己成了高价值边界。它需要安全的会话 Cookie、跨站请求保护、固定的后端映射和严格的出站限制。浏览器传入的路径不能随意拼成后端地址;否则 BFF 既可能被当成开放代理,也可能把本应只发给指定资源服务的令牌带到错误目标。
BFF 还会改变限流视角。资源服务看到的来源可能只剩少数 BFF 实例,单纯按源地址计数会把大量用户混在一起。需要由 BFF 传递经过验证的主体标识或预算上下文,让下游按用户、租户和业务动作控制资源,同时避免让客户端自己声明这些字段。
同步请求结束后,消息可能几秒甚至几小时后才被消费。此时原用户会话也许已经过期,业务状态也可能变化。消费者不能因为消息来自“内部主题”就照单全收。
消息至少要带有可验证的类型、版本、租户上下文、业务对象、意图、幂等标识和产生时间。消费者重新检查当前业务状态,并确认生产者有权发布这类意图。队列权限要细到主题和动作,生产、消费、管理不能绑在一个共享账号上。重试、乱序、重复投递和死信处理也都要有明确规则;否则一次合法命令可能因为重复消费变成多次副作用。

“这个接口已经要求登录”只能证明系统大概知道调用者是谁,离授权还差得很远。API 每次处理请求,都要同时回答两类问题:调用者能否使用这项功能,以及它能否接触这个具体对象。
功能级授权关注“哪一种操作”。普通成员能读项目,不代表能邀请管理员;客服能查看工单,不代表能导出全部客户数据。对象级授权关注“哪一个资源”。用户有查看订单的功能权限,也不代表能查看任意订单编号对应的内容。
对象编号是不是连续、是不是 UUID,并不会改变这个判断。难猜的编号只能降低偶然碰撞,不能代替服务端检查所有者、租户和共享关系。
我们可以把授权规则写成一条可检查的句子:
在租户甲中,成员小林可以读取属于自己项目里的普通文档字段,但不能修改已归档文档。
这句话包含五个维度:
如果代码只检查了其中一两项,剩下的缺口不会被“已经认证”自动补上。授权最好由业务服务基于服务端读取到的对象事实作出,默认拒绝,并让相同规则覆盖 REST、GraphQL 解析器、批处理、消息消费者和管理接口。

下面是一段防御性的伪代码。重点不在语法,而在判断顺序:先根据可信租户查找对象,再判断功能、关系、字段和状态,最后只生成允许返回的视图。
async function readDocument(ctx, documentId) {
const document = await repository.findOne({
tenantId: ctx.tenantId,
id: documentId,
})
if (!document) return notFound()
if (!policy.can(ctx.subject, 'document.read', document)) return denied()
return presentDocument(document, {
fields: policy.
这里把租户条件放进查询,不是为了用“查不到”代替所有授权,而是减少跨租户对象进入业务内存的机会。后续的策略检查仍然必不可少,因为同一租户内还有角色、关系和状态差异。
常见错误是后端返回完整对象,前端再隐藏不该展示的字段。数据只要已经进入响应,就算页面没有渲染,也已经离开了服务端边界。
写入同样会过度暴露。把请求体直接绑定到数据库模型,会让客户端影响本不该由它控制的字段,例如角色、审核状态、租户标识或内部价格。安全的 API 契约应该分别定义可读字段与可写字段,对未知字段和敏感字段给出明确处理规则。不要共用一个“万能对象”贯穿请求、数据库和响应。
过度暴露还包括错误信息、健康检查、接口契约、调试路由和旧版本。健康检查只需要回答编排系统是否可以继续调度,不必向普通调用者返回数据库地址、队列名称和内部服务状态。旧版本如果继续运行,却没有同步新的授权和字段裁剪规则,就是一条被时间遗忘的边界。
多租户系统最容易出现的错觉是:网关从域名或令牌里解析出一次租户,后面的组件就不会再弄错。可真实系统里,租户信息可能同时出现在请求参数、令牌声明、内部请求头、数据库会话、缓存键、对象存储路径和消息字段中。只要各层选择的“真相来源”不同,边界就会漂移。
先确定一个可信租户上下文。它通常由服务端根据已验证的身份和入口关系生成,而不是直接相信客户端提交的 tenantId。每次跨服务传播时保护它的完整性;进入业务服务后,再核对主体是否属于该租户、目标对象是否属于该租户、当前工作负载是否有权处理该租户的数据。
数据库查询带上租户条件只是起点。下面这些位置同样要检查:
测试时准备两个专用租户,每个租户放少量带明显标记的合成对象,再为成员、只读者和管理员建立预期矩阵。一次只改变一个变量:主体不变只换对象租户,或租户不变只换角色。这样一旦出现异常,才能判断是对象归属、角色权限、缓存还是异步处理出了问题。
跨租户验证的目标是证明边界是否执行,不是尽可能多地读取数据。看到第一个最小合成证据后就停止,不继续枚举对象,不接触真实客户租户,并立即核对服务端日志确认根因。
微服务里常见两个极端。一个极端是所有服务共用同一把高权限凭证,谁拿到都能访问所有后端;另一个极端是部署了双向 TLS 或服务网格以后,就认为业务授权已经完成。
两者都把身份和权限混在了一起。
每个工作负载应该有独立、可轮换、短生命周期的身份。订单服务、通知服务和报表任务即使运行在同一个集群,也不需要相同权限。接收方验证服务身份后,只允许它调用业务依赖中明确列出的目标和动作。默认服务账号不应顺手获得控制面或业务数据权限,不需要令牌的工作负载也不必自动挂载令牌。
网络位置只能作为上下文,不能成为信任本身。“来自内网”“来自同一命名空间”或“经过服务网格”都不等于允许。网络分段和默认拒绝能缩小可达面,工作负载身份能确认对端服务,应用授权再决定具体动作。三层各自回答不同问题。

假设订单服务代表用户发起退款:
如果是后台对账任务,就不该伪造一个普通用户。它使用自己的任务身份,并被限制在预定数据范围和时间窗口中。显式区分“代表用户”和“服务自身”会让审计记录更容易解释,也能避免服务权限悄悄放大用户权限。
访问令牌同样需要收窄。权限范围说明大致能做什么,受众说明令牌准备交给哪个资源服务,生命周期限制泄露后的时间窗口。资源服务必须验证受众,不能把为订单服务签发的令牌当成访问所有内部服务的通票。对高价值场景,还可以把令牌与持有者密钥或受控连接绑定,降低令牌脱离原调用方后被重复使用的风险。
看到“服务端请求伪造”,很多人第一反应是写一个正则,禁止 127.0.0.1 或某些字符串。这个思路太窄了。真正的问题是:用户能影响服务器请求的目标,而服务器又能访问用户本来碰不到的网络或携带用户本来拿不到的身份。
图片抓取、Webhook、文档预览、网址转 PDF、回调验证、远程导入和 BFF 转发,都可能引入这种能力。防守时先问业务是否真的需要接收完整地址。如果目标集合是固定的,最清楚的设计是让客户端提交业务对象或目标代号,由服务端映射到已登记的主机、路径和方法,而不是让客户端决定完整目标。
当业务确实需要动态目标时,单次字符串判断不够。系统要统一解析规则,限制协议、端口、主机和路径;域名解析后检查实际地址类别;重定向要么关闭,要么对每一跳重新执行相同校验;连接建立时还要确保实际连接目标与验证结果一致。IPv4、IPv6、特殊地址段和多种文本写法都不能靠零散黑名单处理。
响应也要受控。设置连接与读取超时、响应大小上限、内容类型限制和解析预算,避免第三方返回巨大或异常内容拖垮服务。外部 API 的数据进入模板、数据库、命令或下游消息前,仍按不可信输入验证。知名供应商的响应不会自动获得更高信任。
应用层校验负责理解业务目标,网络层负责保证工作负载只能访问业务所需的出口。可以按工作负载建立默认拒绝的出站策略,通过受控出口访问批准的外部服务,并阻断环回、私有网络、集群管理面和云元数据等不应由普通业务访问的位置。
出站代理或网关需要记录目标类别、调用服务、决策结果、耗时和响应规模,但不记录令牌、Cookie 或敏感正文。DNS、代理与网络策略配置要纳入同一份变更流程,否则应用允许列表更新了,网络出口没有同步,最终不是业务中断,就是为了临时恢复而放开过大的范围。
我们可以把 SSRF 防线记成四道门:业务目标映射、解析与跳转校验、网络出站限制、响应资源预算。 任意一道门都不该独自承担全部责任。
每分钟一百次听起来像一条限制,实际没有说明系统能承受什么。读取一个小对象和导出十万行报表都算一次请求;发送一条验证码与读取一份缓存数据也算一次,但前者还会产生外部费用和业务骚扰。
资源预算至少要考虑主体、租户、业务动作和成本类型:
网关可以执行请求大小、连接数和基础频率等通用规则,业务服务更适合判断“这个租户今天还能生成几份报表”“这位用户能否再次提交退款”。两层需要共享清晰的拒绝语义和预算标识,避免客户端遇到超限后无脑重试,反而制造更大的压力。
GraphQL 尤其能说明“请求数”为什么不够。一个请求可以组合很多字段,并通过嵌套列表触发大量下游读取。防御要限制深度、列表大小、别名或批量操作,给高成本字段设置权重,在执行前估算复杂度,并给整个调用链设置时间和下游请求预算。解析器拿到对象后仍要做授权,复杂度限制不能替代对象和字段检查。
异步系统也有预算。队列长度、单租户未完成任务数、最大重试次数、消息大小、消费者并发和死信保留期都需要上限。重试必须有退避和幂等设计,否则一次下游故障就可能把队列变成资源放大器。
一条好预算能回答“谁在什么业务里消耗了哪种资源,剩余多少,超限后如何恢复”。只有“每个 IP 每分钟若干次”的规则通常看不见登录后的滥用、多用户共享出口、BFF 聚合流量和昂贵的单次操作。
架构问题跨越多个组件。客户端只看到一个成功或失败响应,无法告诉我们究竟是网关、BFF、业务服务还是下游策略作出的决定。没有可关联的证据,团队就会靠猜测修复。
可以为一次用户交互生成统一追踪标识,并让关键授权点记录结构化事件。事件通常包括环境、服务、调用方类型、脱敏主体引用、租户引用、动作、资源类型、策略版本、允许或拒绝、原因类别和追踪标识。
{
"environment": "staging",
"service": "order-service",
"caller_type": "workload",
"caller_ref": "bff-shop",
"subject_ref": "user-lab-7",
"tenant_ref": "tenant-lab-a",
"action": "refund.request",
"resource_type": "order",
"decision": "deny",
"reason": "state_not_allowed"
这份记录没有访问令牌、会话 Cookie、密码、完整订单正文或支付数据。日志的目标是解释决策,不是保存一份请求副本。追踪标签会沿服务传播,更要限制允许写入的字段;否则个人信息会被复制到比业务数据库更广的遥测系统里。

单条拒绝通常不需要报警。更有价值的是变化和矛盾:一个服务突然出现大量跨租户拒绝;某工作负载开始访问从未依赖过的服务;旧 API 版本在停用后重新出现流量;出站目标类别发生变化;网关记录允许而业务服务持续拒绝;单租户的资源成本突然逼近预算。
告警里要带上环境、服务所有者、策略版本、近期发布和相关追踪,让值班人员知道该找谁、先停什么。日志平台和追踪平台本身也要有独立权限、保留周期和审计,不能因为它们属于“可观测性”就默认所有工程师都能读取。
架构会变,静态评审只能说明当时的状态。持续治理可以做三组对账:
这三组对账适合进入发布检查和运行时监测。发现差异后先确认业务事实,再按变更流程修复,不通过主动扫描去扩大未授权范围。
架构安全验证不是把所有接口跑一遍,更不是追求完整利用链。我们只需要用最少的合成证据回答一个明确假设,然后停止并与防御侧对账。
假设是:“订单服务可能只检查了成员角色,没有检查订单的租户归属。”靶场中准备租户甲与租户乙,各放一笔合成订单;再准备甲的成员账号和乙的成员账号。整个验证不需要猜编号,也不需要接触任何真实数据,两个对象都由实验负责人提供。
先写下授权基线。明确环境、两个测试租户、测试账号、合成对象、允许的请求次数、预期结果和紧急停止方式。第三方支付、生产消息主题和管理面保持排除。
用租户甲成员读取甲的合成订单,确认正常路径可用,并在网关、订单服务和追踪系统中找到同一条决策证据。这一步建立对照组,不扩大权限。
保持账号、方法和请求结构不变,只把目标替换为负责人提供的租户乙合成订单。只发送一次低影响请求,不做对象枚举,也不跟随任何意外返回继续探索。
比较响应和服务端证据。预期是稳定拒绝,并且订单服务记录对象租户与主体租户不匹配。如果意外允许,保留最小字段证据后立即停止。
报告不要只写“存在越权”。应写清:哪个测试身份、在哪个测试租户、对哪类合成对象执行什么动作;预期规则是什么;哪一层实际允许;服务端留下了什么最小证据;根因属于对象归属、功能权限、缓存隔离还是策略漂移;修复后用哪组矩阵验收。
读到这里,你可以把应用架构安全浓缩成三句问话:调用者是谁,它代表谁,它此刻能对哪个对象做什么。 单体要防止入口授权在内部复用时丢失;网关和 BFF 要守住身份转换与代理边界;微服务要同时验证工作负载和代理主体;消息消费者要在延迟执行时重新核对状态;多租户语义要进入每一种存储和异步路径。
最后用这份清单检查一次:
下一节会沿着这些边界继续往下走:既然身份和数据需要跨网络、跨服务、跨存储流动,我们就要检查它们是否被正确加密,证书、密钥、代理和运行环境是否因为配置偏差让保护失效。架构告诉我们应该在哪里设防;加密与安全配置决定这些边界在真实流量中能不能站得住。
与服务负责人定位授权发生在哪一层。检查网关是否只完成认证、业务查询是否缺少可信租户条件、缓存键是否混用,以及策略版本是否与预期一致。
修复后使用完全相同的矩阵回归:甲读甲仍成功,甲读乙被拒绝,同租户只读角色不能修改,异步任务和缓存路径也保持隔离。确认日志既能解释新决定,也没有记录秘密。