Cookie 的主机与路径作用域,决定哪些目的地有资格收到它的名称和值。缩小这个集合有助于减少暴露,但作用域不是授权,而且 Cookie 并不采用浏览器完整的源边界。本文重点讨论 Domain 与 Path;传输方式、请求语境和浏览器策略还会增加其他限制,不能只凭一个属性就推断最终发送结果。

Domain 选择主机,不隔离端口

省略 Domain 会建立 host-only Cookie。从 app.example.com 设置的 Cookie 只匹配这个主机,不匹配 shop.example.com。加入 Domain=example.com,则允许父域与匹配的子域收到它,但仍受其他 Cookie 条件约束。属性处理会忽略一个前导点,它并不是另一种通配符功能。只有那些更宽的接收方确实需要这个值,才应扩大作用域。

设置 Cookie 的主机不能指定无关的兄弟域:app.example.com 不能设置 Domain=shop.example.com。浏览器还会应用公共后缀限制,因此仅检查字符串后缀并不是完整实现。下面模型只处理已知的示例主机名,明确不实现公共后缀查找、国际化域名转换与 IP 地址规则;不能把这个演示直接复制成认证系统的 Cookie 库。

端口不是 Cookie 边界。属于 app.example.com 的合格 Cookie 可以发送到该主机的另一个端口,即使端口变化意味着 Web 来源已经变化。因此,意料之外的端口服务也必须纳入信任评估。把不可信应用放在不同路径或端口,并不能获得真正分开 Cookie 主机所带来的隔离,host-only 也不等于完整的同源限制。

默认路径来自设置请求

Path 被省略或无效时,浏览器会根据设置请求的路径计算默认值。请求路径为 /account/login 时,默认值是 /account,不是 /;请求路径为 /login 时,默认值才是 /。因此,只移动登录端点而完全不改 Set-Cookie 字符串,也可能改变 Cookie 的隐式作用域。如果应用依赖稳定范围,就应明确写出所需 Path。

路径匹配还检查斜杠边界:/admin 匹配自身以及 /admin/users,但不匹配 /administrator。这不是文件系统权限。同源脚本可以与其他路径交互,因此不能依靠 Path 把彼此不可信的应用隔离开。较窄路径减少有资格附带 Cookie 的请求,却不能代替服务端检查,也不能阻止所有读取该值的方式。

坏掉的样子

父域会话 Cookie 可能抵达一个被遗忘的兄弟服务,而该服务恰好记录了请求 Cookie。HttpOnly 无法阻止接收服务器读取自己收到的请求头。相反,默认路径过窄会让已经成功的登录在另一条路由上看起来丢失。诊断这两类问题,都要同时记录设置 URL 和属性,但不要把真实会话值打印到日志、截图或支持工单中。

同名 Cookie 还可能因作用域不同而同时存在。Cookie 请求头只包含名称和值,不包含 Domain 与 Path,应用不能从这个头恢复每一枚 Cookie 的原始范围。应避免含混的重复会话名称,并在迁移时使用匹配作用域使旧 Cookie 过期。删除另一路径上的同名 Cookie,不会删除原本那一枚;仅检查当前登录响应也不足以发现旧存储状态。

支持前缀规则的浏览器要求 __Host- Cookie 来自安全设置语境、带 Secure、明确使用 Path=/,且没有 Domain。兄弟子域因而不能用这个前缀名称植入父域 Cookie。但它仍然不隔离端口,也无法修复已被攻陷的设置主机。认证后仍须重新生成会话标识,不能把前缀当成会话固定问题的全部解法。

快速验证

把下面代码保存为 cookie-scope.py,使用 Python 3.11 或更新版本执行 python3 cookie-scope.py。它不打开任何网络套接字,只使用虚构 URL。断言验证刻意限定的域名与路径模型,不验证浏览器存储、SameSite、过期、名前缀或第三方 Cookie 政策。函数名字只是为了帮助阅读,不代表它们实现了完整用户代理。

from urllib.parse import urlsplit


def default_path(request_path):
    if not request_path.startswith("/") or request_path.count("/") <= 1:
        return "/"
    return request_path.rsplit("/", 1)[0]


def domain_match(host, domain):
    return host == domain or host.endswith("." + domain)


def path_match(request_path, cookie_path):
    return request_path == cookie_path or (
        request_path.startswith(cookie_path)
        and (cookie_path.endswith("/")
             or request_path[len(cookie_path):].startswith("/"))
    )


def scoped_cookie(setting_url, domain=None, path=None):
    setting = urlsplit(setting_url)
    host = setting.hostname
    selected = host if domain is None else domain.removeprefix(".").lower()
    if domain is not None and not domain_match(host, selected):
        raise ValueError("Domain does not match the setting host")
    return selected, domain is None, (
        path if path and path.startswith("/") else default_path(setting.path)
    )


def matches(cookie, request_url):
    domain, host_only, path = cookie
    request = urlsplit(request_url)
    host_ok = (request.hostname == domain if host_only
               else domain_match(request.hostname, domain))
    return host_ok and path_match(request.path or "/", path)


setting = "https://app.example.com/account/login"
host_only = scoped_cookie(setting)
broad = scoped_cookie(setting, domain=".example.com", path="/")
assert host_only == ("app.example.com", True, "/account")
assert broad == scoped_cookie(setting, domain="example.com", path="/")
assert matches(host_only, "https://app.example.com/account/profile")
assert not matches(host_only, "https://app.example.com/dashboard")
assert not matches(host_only, "https://shop.example.com/account/profile")
assert matches(broad, "https://shop.example.com/")
assert not matches(broad, "https://example.com.attacker.invalid/")
assert matches(host_only, "https://app.example.com:8443/account/profile")
for path, expected in [("/admin", True), ("/admin/", True),
                       ("/admin/users", True), ("/adminx", False),
                       ("/administrator", False)]:
    assert path_match(path, "/admin") == expected
try:
    scoped_cookie(setting, domain="shop.example.com")
except ValueError:
    pass
else:
    raise AssertionError("unrelated Domain accepted")
print("PASS: default path, host-only, domain, path boundary, and port cases")

成功行确认了由请求计算的 /account 默认值、host-only 对兄弟域的排除、父域扩大后的匹配、斜杠边界,以及端口独立性。指定兄弟 Domain 会抛出 ValueError。这些都是明确列出的样本结果,并不是浏览器协议一致性认证,也不能据此宣称任意域名输入都得到正确处理。

部署验收时,应在浏览器开发工具中查看隐藏值后的属性,并用虚构账号测试预期路由矩阵。同时检查 Cookie 存储和实际出站请求,因为缺失 Cookie 头可能来自设置被拒绝、过期、作用域或其他浏览器政策。不要为了演示作用域太宽,就把生产会话值发往不可信主机;属性检查和安全测试账号已经可以提供所需证据。

来源