深入 Play 27:CSRF、CORS、Host 与代理信任
相同的 Cookie 表单请求,缺少 CSRF token 时返回 403;加入被 CORS 信任的 Origin 后,在默认配置中返回 200。显式设置 bypassCorsTrustedOrigins=false,同样的可信 Origin、同样缺失的 token 又得到 403。
这组三进程对照中的差异来自框架配置联动。允许跨域读取、校验请求是否携带防伪令牌、判断用户是否有权修改对象,各有独立条件;不能从一个 200 推断这些条件都执行过。
先固定过滤器实际使用的配置
实验固定 Play 3.0.6,源码提交 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6,JDK 21。累计工程早期的合成请求曾使用 X-Play-Lab 作为 CSRF bypass header;本篇启动独立 conf/governance.conf,不 include 早期 application.conf,实际负测携带这个头仍返回 403。
专项过滤链显式包含观测、CORS、CSRF、安全响应头、AllowedHosts。CORS 位于 CSRF 之前,使其可信来源判定能参与后续防伪检查;默认联动值来自 filters reference.conf。同一文件还规定:默认在存在 Cookie 或 Authorization 时执行 CSRF 保护头检查,bypassHeaders 默认为空。
下面是专项配置中的有关部分。完整文件还提供累计 router 构造旧异步 Controller 所需的有界 dispatcher,不能把这段配置当成完整可启动应用。
1 | |
一次启动使用默认 bypassCorsTrustedOrigins=true,第二次在命令行显式设为 false;两个进程各自生成随机运行密钥、各自获取 Cookie 和 token。第三个进程只把精确的 127.0.0.1 加入代理信任列表。比较时不能携带前一个进程签发的 Cookie,否则“密钥不同导致身份失效”会混进 CSRF 变量。
Cookie 表单与跨域输入矩阵
GET /gov/login?user=alice 是显式开放的公开测试夹具,GET /gov/form 通过 AddCSRFToken 添加 token。客户端保存框架签发的 session Cookie,再对 /gov/objects/1 发出表单 POST。框架 token 的编码和校验保留给 Play,脚本只保存、回传或有意替换它。
| 输入 | 状态或响应头 | 实际检验的条件 |
|---|---|---|
| Cookie + 缺 token | 403 | 有身份上下文的写请求受到检查 |
| Cookie + 错 token | 403 | 字段存在不等于校验通过 |
| Cookie + 旧 X-Play-Lab bypass | 403 | 专项进程没有继承旧绕过配置 |
| 正确 header token / 表单正文 token | 200 / 200 | 两条合法令牌输入路径 |
| 可信预检 Origin | 200,明确 Allow-Origin,credentials=true | 当前 CORS 允许来源、方法和请求头 |
| 不可信预检 Origin | 403 | 未将任意来源纳入许可 |
| 可信 Origin、无 token、默认联动 | 200 | CORS 信任可绕过 CSRF |
| 可信 Origin、无 token、显式关闭联动 | 403 | CORS 许可不再跳过 token 校验 |
| 可信 Origin、正确 token、关闭联动 | 200 | 两个条件可以同时满足 |
同一份 CORS 配置源码 定义 allowedOrigins、方法、请求头与 supportsCredentials。实验使用准确来源字符串,没有用星号扩大匹配。Python 标准库观察的是 HTTP 响应和服务端判定,没有运行浏览器的跨域读取算法,因此这里不能声称某个浏览器已经允许脚本读取响应。
有效的 token 也不能授权另一个用户的对象。持 alice Cookie 与正确 token 修改对象 2,仍得到 403。这一项把 CSRF 防护和对象策略连在同一条实际请求中,避免“某个过滤器拒绝了请求,所以对象权限一定有效”的误判。
有期限的 Java 对照客户端
下面的完整类展示如何在同一 CookieManager 中完成合成登录、取得 token、比较缺失与合法 token。使用 JDK 21 验证编译;HttpClient 的自动关闭用法需要该版本支持。它只连接指定端口的 loopback,不接受任意目标 URL,也不输出 Cookie 或 token。
1 | |
该示例已独立编译,并在单独启动的专项进程中得到 missing=403、valid=200;本文完整矩阵来自 lab/governance_checks.py,该 Java 客户端的单独运行没有被计入历史 37 项 JUnit。客户端不发送 Origin,因而不会触发可信 Origin 的 CSRF 绕过。实际浏览器请求的 Origin、Cookie 发送条件仍需单独测试。
Host 与转发头有不同信任来源
Host 检查决定请求是否面向允许的主机。实验向 loopback 服务发送 Host: evil.example,得到 400。它不会自动判断 X-Forwarded-For 的第一个地址可信;转发头使用另一套代理信任配置。
ForwardedHeaderHandler 的扫描 从最靠近服务的连接开始,反向遍历转发条目。只有当前前一跳可信,才读取下一条;遇到不可信地址就停止。play.http.forwarded.trustedProxies 的定义见 core reference.conf。
1 | |
空信任列表的直连请求,即使带 X-Forwarded-For 与 https scheme,也观察为 127.0.0.1、secure=false。受控本地代理丢弃客户端传入的转发声明,再写入固定合成地址 203.0.113.9 与 https,后端观察为该地址、secure=true。两地址但只有一个 proto 的另一个样本停在 203.0.113.9,secure=false;不能将某一种 XFF/proto 长度组合的 scheme 结果推广到所有代理链。
信任 127.0.0.1 也包含同机其他进程。若任意不可信本地程序可以直接连接后端,它就在这条信任边界内。实际代理部署还需要限制后端可达性和规范头重写;本实验的本地标准库代理只证明所列配置与输入的组合。
响应头、密钥和日志分别核验
SecurityHeadersFilter.apply 在 Result 上添加配置的响应头。实测包括 X-Frame-Options=DENY、X-Content-Type-Options=nosniff 与配置的 Referrer-Policy。响应中有这些字段不证明页面没有脚本注入,也不证明所有其他响应走过相同路径。
loopback HTTP 样本的 session Cookie 带 HttpOnly、SameSite=Lax,没有 Secure。缺少 TLS 的测试环境不能证明生产 Cookie 策略正确;HTTPS、跨站 SameSite 行为和真实证书链未执行。
真实内置浏览器另从 http://127.0.0.1:56172 页面请求 56173 后端。两个端口构成不同 origin,仍属相同 site。credentials: include 签发并携带合成身份,列表返回 [1];自定义 Csrf-Token 头触发预检后修改得到 200,关闭 CORS 联动绕过后无 token 得到 403。页面从 localhost:56172 打开时,fetch 抛出 TypeError,服务器同时记录 Invalid CORS request,独立对照返回 403 且没有 Access-Control-Allow-Origin。这证明浏览器能读取可信来源响应、拒绝该不可信来源,不能外推到跨站 Cookie 或 TLS。
配置采用 governance-browser.conf 的真实 HOCON 列表。将列表写成 -Dplay.filters.cors.allowedOrigins=[...] 的反例导致启动失败:系统属性值是 STRING,CORSConfig 要求 LIST。失败日志与修复后浏览器 JSON、截图分别保留在本批证据。重跑时设置 JDK 21,在另一个终端执行 python3 lab/browser_security_server.py --evidence evidence/batch26-29/browser-replay,打开输出的页面并点击“运行浏览器矩阵”;结束后 Ctrl-C 清理自建服务。
三个独立进程分别使用环境变量提供随机运行密钥。每个进程结束后,脚本检查日志没有该密钥和敏感正文哨兵;历史隔离产物扫描 219 个文件,共享接入后每次扫描 253 个文件,均未发现密钥原文字节。共享 49 项 JUnit、stage、三进程网络通过,三个退出码均为 143,证据为 shared-build.json 与 shared-http/observations.json。这个检查覆盖所列本地产物与日志,不涵盖外部遥测,也不能检出任意变换后或加密后的内容。
重跑与改动实验
从第00篇取得累计工程,设置 JAVA_HOME 指向 JDK 21,在 play-lab 执行:
1 | |
历史证据位于 evidence/batch26-29/isolated/run-final3/observations.json 的 securityDefault、strictCsrf、trustedProxy、processes 节点;对应 default.log、strict.log、proxy.log 保留服务器结果。该隔离批次 37 项 JUnit、stage 与三进程网络矩阵通过,不代表共享工程的后续累计计数。
改动练习:只从 allowedOrigins 删除可信来源,保持 Cookie、token、方法和路径不变,分别重跑预检与实际 POST。记录失败发生在哪一层,再恢复来源并单独改 bypassCorsTrustedOrigins。一次同时修改两个条件,会使状态码变化失去归因。
另一个练习给转发链增加中间地址。先只信任最后一跳,再精确增加那个中间代理,观察 remoteAddress 停在哪个位置。不要把信任所有地址作为“修复测试”的手段。

