浏览器可能为攻击者触发的请求附带会话 Cookie。CSRF 防御检查请求上下文是否可信,却不能证明用户真的看过或有意批准某个动作。

浏览器判别是否带上 Cookie,依据的是目标域名、路径、Secure、SameSite 及其他浏览器规则,而不是「这次请求是由用户先前的什么意图触发的」。于是,来自攻击者网站的页面,也能驱动受害者的浏览器发出一条改变状态的请求——改密、改资料——并且这条请求会带上已登录的会话 Cookie。服务器看到这样一条请求,会以为它来自合法用户。这正是关键:请求带了凭据,并不能证明这是用户的本意;浏览器只是在执行它的 Cookie 规则,而不是用户的意志。

令牌把动作与来源绑定

对策是让「改变状态的动作」带一个攻击者无法预料的参数。服务端为每个会话生成一个不可预测的反 CSRF 令牌,在它自己的表单里带上,提交时再比对;攻击者的跨站页面既没有这个令牌,校验就会失败。配合严格的方法约束——不要让 GET 去改变状态——让「带令牌」成为改变状态的唯一途径。比对必须严格:缺失或不匹配就拒绝,而且绝不接受攻击者自带的 origin。

SameSite 是浏览器护栏

SameSite=Strict 不在跨站请求中发送该 Cookie;显式设置 Lax 时,使用安全方法的顶层导航仍可携带它。这些限制减少凭据被跨站运送的机会,但本设计仍要求服务端校验令牌。同站范围大于同源,浏览器支持和请求上下文也会影响结果。示例采用服务端保存的同步令牌,而非双提交 Cookie;两种模式不能仅因为都叫令牌,就混为同一种实现。源内脚本注入可以替用户发出合法上下文中的请求,所以还需单独防御 XSS。

把 Origin 当作来源信号

请求存在 Origin 时先检查它;缺失时,服务端可以检查 Referer 的来源部分。两个字段都可能缺失,Origin 也可能是字面值 null,不能把这些情况当作同源证据。本夹具有意同时要求有效令牌和可信来源信号,缺一即拒绝;实际兼容策略也应明确写出,不能默默跳过整个保护。应比较完整的协议、主机与端口,不能只匹配域名前缀,防止相似域名通过。

Sec-Fetch-Site 提供额外浏览器上下文,但表示站点关系,不等于完整来源匹配。命令行客户端可以伪造这些首部,来源检查针对的是浏览器被诱发的请求,不是用户认证或人类意图证明。反过来说,curl 能伪造首部,也不意味着攻击页面能够让受害者浏览器任意发送它们。不同威胁模型应分别理解,不能据此撤销浏览器侧防护。

坏掉的样子

第一种是把状态变更放在获取方法后面。图片与链接就能触发,宽松的站点隔离仍会在顶层获取导航里带上会话,伪造的跳转带着真实凭据到达,服务端却误以为是用户本意。排查时只看是否登录成功没有意义,还要看这条改变状态的请求能不能被一行标签触发。

第二种是显式跨站 Cookie 没有配套保护。SameSite=None; Secure 允许跨站传送,改变状态的接口仍需其他 CSRF 防御。现代浏览器会拒绝缺少 Secure 的 None Cookie,不能把它描述为普遍放行;旧版行为也不能用作安全保证。开发环境为了联调开放跨站传送,上线后忘记核对令牌和来源规则,仍可能留下真实攻击路径。

第三种是缺失令牌时跳过校验。处理器仅在参数存在时比对,接口路径干脆跳过校验,或者把令牌放在查询串里,经引用头与日志到处扩散,攻击者拿到就能重放。源内脚本注入也可能窃取或使用令牌,这是另一个必须防御的边界。正确做法是缺失与不匹配一律拒绝,且只接受请求体或专用首部里的值。

第四种是子域信任过大。站点隔离比的是站点而非来源,受控子域与主站可能同站;若它能设置父域 Cookie,就可能干扰会话处理或为天真的双提交方案种下已知令牌。优先使用主机范围的会话 Cookie,并拒绝含义不明的重复 Cookie。登录接口若不做同样的双校验,还会留下登录伪造,攻击者用自己的账号让受害者登录,后续操作全部记在攻击者账下。每种失败里凭据都是有效的,凭据都不足以独立说明请求合法。

快速验证

把下面程序存为 csrf_probe.py,运行 python3 csrf_probe.py。它只用标准库模拟服务端关卡:方法必须是大写提交,令牌必须与会话值一致,来源信号必须吻合。不起网络服务,不碰浏览器,各组用例可独立执行。固定令牌只是测试数据,真实服务必须为会话生成不可预测的值。

from hmac import compare_digest
from urllib.parse import urlsplit
import sys

EXPECTED_ORIGIN = "https://bank.example"
SESSION_TOKEN = "token-issued-with-own-form"

def decide(method, origin, referer, token):
    """模拟服务端关卡:仅 POST 变更、令牌与来源信号必须同时通过。"""
    if method != "POST":
        return 405, "state change requires POST"
    if not isinstance(token, str) or not token.isascii() or not compare_digest(token, SESSION_TOKEN):
        return 403, "missing or wrong token"
    if origin:
        if origin != EXPECTED_ORIGIN:
            return 403, "origin mismatch"
    elif referer:
        base = urlsplit(EXPECTED_ORIGIN)
        try:
            seen = urlsplit(referer)
        except ValueError:
            return 403, "referer mismatch"
        if seen.scheme != base.scheme or seen.netloc != base.netloc:
            return 403, "referer mismatch"
    else:
        return 403, "no origin signal"
    return 200, "transfer accepted"

def check(actual, expected, label):
    assert actual == expected, f"{label}: got {actual}, want {expected}"
    print(f"{label}: {actual[0]} {actual[1]}")

check(decide("POST", EXPECTED_ORIGIN, "", SESSION_TOKEN),
      (200, "transfer accepted"), "legitimate form post")
check(decide("POST", "https://evil.example", "", SESSION_TOKEN),
      (403, "origin mismatch"), "forged cross-site post")
check(decide("POST", EXPECTED_ORIGIN, "", ""),
      (403, "missing or wrong token"), "missing token")
check(decide("POST", "", "https://bank.example/transfer", SESSION_TOKEN),
      (200, "transfer accepted"), "referer fallback navigation")
check(decide("GET", EXPECTED_ORIGIN, "", SESSION_TOKEN),
      (405, "state change requires POST"), "state change over GET")

check(decide("POST", "", "", SESSION_TOKEN),
      (403, "no origin signal"), "missing origin signal")
check(decide("POST", "https://evil.bank.example", "", SESSION_TOKEN),
      (403, "origin mismatch"), "same-site subdomain")
check(decide("POST", EXPECTED_ORIGIN, "", "令牌"),
      (403, "missing or wrong token"), "non-ASCII token")
check(decide("POST", "", "https://[", SESSION_TOKEN),
      (403, "referer mismatch"), "malformed referer")

print("PASS: legitimate, forged, and missing-token cases behave differently")
print(sys.version)

合法的同源提交带对令牌返回成功,伪造来源即使令牌正确也返回禁止,缺失令牌即使来源正确同样拒绝。回退到引用头的同源请求正常放行,获取方法改状态返回方法不允许,正好说明安全方法为何重要。预期输出以成功标记结尾并打印解释器版本,全部断言通过才算成功,任一组失败都会直接抛出异常中断。

补充用例还拒绝缺失来源、同站子域、非 ASCII 令牌与格式错误的引用地址。这个夹具只验决策逻辑,不验浏览器是否真的按站点隔离丢弃凭据,也不验传输加密与会话过期。上线前要在真实浏览器里打开开发者工具,对改密转账这类接口再测一次跨站与同站行为,确认请求头与响应状态符合预期,才能把关卡当作完成。

延伸阅读

来源