两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

Cookie 与会话守住边界

Cookie 是浏览器的存储与传输机制。服务端会话则是一种应用设计,用 Cookie 的值查找服务器保存的状态。分开这两种责任,登录、过期和退出才容易验证。本文沿着不透明会话标识的生命周期展开,不再重复主机、路径或 SameSite 匹配规则;这些发送条件不能回答旧会话是否已经真正失效。 持有标识仍可能获得权限 在这种设计中,服务器生成不可预测的标识,并把它关联到一条会话记录。客户端不需要理解该值,但持有它仍可能获得访问资格:窃取有效 bearer 标识的人,可以尝试直接重放。称它“只是指针”不能让读者以为它无害。不要把它放入 URL、日志、诊断截图或支持材料中,随机字符串也可能是实际凭据。 不能直接信任客户端控制的明文角色字段。不过,Cookie 中含有有意义的数据,也不等于必然可以伪造:经过正确认证和签名的会话格式属于另一种设计。签名保护完整性,不提供保密性;无状态方案仍须明确过期、密钥管理与撤销策略。应选择维护良好的会话框架,不要仅为了省一次服务端查询就自行发明格式。 不透明方案需要使用密码学安全随机生成器和足够熵值。未知标识应被拒绝,而不能用客户端任意指定的值建立特权状态。服务器解析会话后,还须根据当前权限授权每项操作。某个会话已经认证,不代表它可以读取每一个对象、操作每一个账号,或自动获得所有管理功能;会话存在与具体动作获准是两项检查。 两种寿命需要两处控制 Cookie 的 Max-Age 与 Expires 管理客户端存储,不会自动使服务端会话记录过期。非浏览器客户端可以保留这个值,并在浏览器本该删除后继续重放。浏览器会话 Cookie 也可能通过会话恢复机制保留。因此必须在服务端强制执行绝对期限与空闲期限,使用明确的边界比较和一致的时间来源,而不能靠标签页里的倒计时作为安全判断。 退出时既要作废服务端状态,也要按匹配的作用域清除浏览器 Cookie,否则复制出来的令牌仍可能有效。认证后与权限变化后轮换标识,有助于防止会话固定,但前提是旧标识确实失去权限。并发请求和分布式存储需要额外协调,避免一个较晚完成的旧请求悄悄恢复已经撤销的会话;只覆盖单进程正常路径远远不够。 Secure 与 HttpOnly 分别约束传输和脚本读取;HttpOnly 不会阻止注入脚本发起认证请求。SameSite 是浏览器语境防御,不是服务端过期或权限检查。Cookie 与请求头限制也因浏览器和服务器而异,并不存在普遍适用的八千字节请求头上限。保持标识紧凑可以减少开销,但不能把体积当成判断会话是否安全的唯一依据。 坏掉的样子 登录时升级了认证前记录,却保留攻击者已经知道的标识;退出时只删除 Cookie,却让复制的值继续有效;空闲超时只由 JavaScript 检查,直接客户端仍能访问。这些都是服务端生命周期故障,即使开发工具中的 Cookie 标记完全正确,也不能弥补这些逻辑问题。排查时要重放旧的虚构标识,而不只是确认响应发出了哪些属性。 不要假定定期活动就应该无限延长访问。空闲计时可以随接受的活动滑动,而同一个会话的绝对截止时间仍然固定。是否在重新认证或提权时开始新的生命周期,应由明确策略决定。尤其要检查令牌刷新是否意外不断重置原本的硬期限,否则名义上的绝对过期可能在活跃客户端面前永远不会到来。 快速验证 将代码保存为 session-lifecycle.py,使用 Python 3.11 或更新版本执行 python3 session-lifecycle.py。它使用虚构用户、可预测的夹具标识与传入的时间值,不打开套接字,也不提供任何真实认证服务。绝不能把这个标识生成器或内存模型用于真实应用;可预测取值只是为了让本地断言清楚可复现。 ...

一月 12, 2027 · 2 分钟 · XB Field Notes
观察、决策与行动三块面板,旁边是关于只读根文件系统边界的标题。

只读根在其范围内限制文件系统修改

只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。这能减少意外修改和部分持久化机会,但边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。 坏掉的样子 应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 EROFS 暴露。只测健康检查端点,可能漏掉生成报表、刷新缓存或关停清理时才经过的写入路径。因此必须沿真实工作流程检查需求,不能把进程成功启动当成全部功能可用。 即使容器根层只读,可写挂载仍然可写,其中状态仍可能被故障或受损进程改坏。把大块可写目录覆盖到打包路径,也可能抵消这些路径原本需要的保护。应检查最终挂载布局,而不是根据一个根层选项推断整棵路径树的行为。路径看起来位于只读根之下,并不证明中间没有另一个可写挂载。 只读与保密也是不同属性。进程仍可能读取打包文件、执行已有程序,并通过允许的网络路径发送数据。该设置也不保证容器逃逸之后仍受相同保护,更不约束能够改变挂载安排的权限。应结合凭据、能力、隔离和应用行为理解它的贡献,避免把一项文件系统限制描述成完整安全边界。 快速验证 在一次性 Linux 容器内以 root 运行下面 Python 脚本。容器应使用只读根,在 /tmp 挂可写 tmpfs,在 /work 挂独立可写卷,镜像内预先安装 Python 3。实测使用 Linux 6.12.76 和 Python 3.11.2,没有宿主机绑定挂载、特权模式或外部网络: from pathlib import Path from tempfile import TemporaryDirectory import errno,os,socket,subprocess,sys,uuid assert os.geteuid()==0,'run inside a disposable read-only-root container as root' assert os.statvfs('/').f_flag&os.ST_RDONLY name='xbcatm-readonly-'+uuid.uuid4().hex for base in [Path('/'),Path('/usr'),Path('/etc')]: path=base/name try:fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) except OSError as error:assert error.errno==errno.EROFS,error else: os.close(fd);path.unlink();raise AssertionError('root filesystem accepted a new file') print('even this root process receives EROFS on paths in the read-only root, usr and etc tree') for base in ['/tmp','/work']: assert not os.statvfs(base).f_flag&os.ST_RDONLY with TemporaryDirectory(prefix='xbcatm-write-',dir=base) as directory: path=Path(directory)/'data';path.write_bytes(b'allowed');assert path.read_bytes()==b'allowed' path.write_bytes(b'changed');assert path.read_bytes()==b'changed' print('explicit tmpfs and data-volume mounts remain writable, including overwriting their contents') assert Path('/etc/os-release').read_text() result=subprocess.run([sys.executable,'-c','print("executed")'],capture_output=True,text=True,check=True) assert result.stdout.strip()=='executed' with socket.socket() as listener,socket.socket() as client: listener.bind(('127.0.0.1',0));listener.listen(1);client.connect(listener.getsockname()) connection,_=listener.accept() with connection: client.sendall(b'local');assert connection.recv(5)==b'local' print('reading packaged files, executing an existing program, and loopback network I/O still work') 脚本先检查根文件系统报告的只读标志,再尝试在根、usr 和 etc 下排他创建唯一名字的文件。即使采用 root 身份,三次都得到 EROFS。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。 ...

十二月 23, 2026 · 1 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

单点登录委托认证,应用仍需验证

单点登录让应用依赖身份提供方的认证结果,用户不必把提供方的密码交给每一个应用。应用仍要负责自己的授权决定、本地会话和接入配置;机密客户端还可能持有客户端凭据。委托认证不会消除应用的安全边界,也不保证以后每次登录都无需用户交互。是否需要重新认证,仍然受提供方策略、当前会话和应用要求影响。 先分清协议与令牌 SAML 与 OpenID Connect 都可支持联合身份,但它们是不同协议,不能简化成可以互换的 XML 与 JSON 编码。本文聚焦 OIDC:它在 OAuth 2.0 之上定义身份层,通过 ID Token 表达认证信息。访问令牌用于访问资源,格式也可能不同。一个看起来像 JWT 的字符串并不自动成为 ID Token;资源服务器更不能因为认识签发者,就把任意一种令牌当作访问许可。 对于 OIDC 身份令牌,应按所选流程的规则校验预期签发者、客户端受众、过期时间,以及请求时发送的 nonce,同时执行适用的授权方和认证上下文检查。签名正确但发给另一个客户端的令牌,仍然不适用。验签密钥要属于已信任的签发者,不能让未验证的签发者字符串驱动程序去任意元数据服务器取得“可信”密钥。 允许的算法和密钥类型,应由验证器的配置确定,而不是由不可信输入自由选择。头部声明必须符合该策略,并与实际密码学操作一致。HS256 本身并非错误:OIDC 定义了使用适当共享秘密的协商式 MAC 签名。把 RSA 公钥当作这个共享秘密,才属于算法混淆。陌生的密钥编号也可能来自合法轮换,但无论如何都不能成为跳过验证的理由。 坏掉的样子 最容易误导人的成功是解码成功。Base64url 与 JSON 解析可以显示一组貌似可信的声明,却没有证明是谁签发了它。管理员身份、正确受众和未来过期时间,都可以被随意编造。不要把真实令牌粘贴到在线解码网站或事故日志;其中可能含有个人信息和仍可使用的凭据。诊断应经过批准的路径,并且只查看确有必要的字段。 登录请求的绑定与令牌验证同样重要。应采用持续维护的 OIDC 接入组件,并按客户端类型与流程使用重定向校验、请求关联及 PKCE 等保护。RFC 9700 给出了 OAuth 安全指导。只检查几个 JWT 字段,不能替代完整协议处理;如果接收了属于另一次请求的令牌,即使签名是真的,也仍可能产生安全问题。 ...

十一月 17, 2026 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调先验证 TLS,再检查响应。

检查 HTTPS,而不只是那把锁

HTTPS 保护一条连接,并按照客户端的信任规则认证对端。但它不会证明网站意图、业务正确性或服务可用性。地址栏的安全指示既不只是“字节已加密”的标签,也不是完整健康报告。有效检查应分别判断 TLS 连接、HTTP 响应,以及服务实际应该提供的行为,不能把三个问题合成一个模糊的绿灯。 分别提出具体问题 首先确认目标与身份。连接预期入口的同时,应按照证书 SAN 和可接受的信任链验证预期主机名。IP 地址正确,或者能够打印证书主题,都不足以完成身份验证。为了让探针通过而关闭验证,只会移除原本想取得的证据,并不能证明对端已经变得正确。 接着检查有效时间和协商协议。叶证书的到期日期仍在未来,不代表整条证书链可用;一次 TLS 协商成功,也只说明这个客户端实际选中了哪个版本和套件,并没有枚举服务端可能接受的全部配置。配置或库升级之后,仍应另外验证明确的允许和拒绝策略,不能用一次成功连接概括全部安全条件。 最后定义应用成功的条件。HTTP 状态、预期内容、重定向和相关依赖各自回答不同问题。静态站也可能提供有效证书,却返回不存在的页面、旧版本内容或上游错误。即使应用只负责发送文件,TLS 也不是整段访问的全部,更不能替代页面和发布结果的检查。 坏掉的样子 HTTP 库收到一个可正常读取的 503 响应时,未必会把它当成传输错误。若探针只检查 err 是否为空,就可能把不可用服务判断为健康。同样,HTTP 200 也可能来自错误虚拟主机、登录页面或被缓存的错误文档,因此应核验稳定、属于目标服务自身的响应约定。 自动跟随重定向可能隐藏真正被检查的入口。最初的 HTTPS 响应可能指向 HTTP 或非预期地址,最终只看到一个 200 就会丢失中途发生的变化。应依据明确策略检查跳转链,不能为了把请求跑通,就顺手把凭据转发到新的目标地址。 TLS 终止位置同样重要。CDN 证书验证通过,认证的是客户端到 CDN 的这一跳,并不自动认证之后到源站的每一跳。公共访问路径和相关上游路径需要分别监控。诊断应保留目标及观察时间,区分证书故障与 HTTP 故障,同时避免把凭据和私有请求数据写进输出。 快速验证 保存为 health_probe.go,使用 Go 1.23 或更新版本运行 go run health_probe.go。本地 TLS 1.3 服务提供四种应用响应,另一个明文服务充当重定向目标。客户端使用 httptest 提供的配置来信任测试服务器证书,不改变全局信任库,也不访问任何公共入口。 ...

十一月 9, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调 SNI 选择证书、客户端验证名称。

SNI 根据请求主机名选择 TLS 证书

服务器名称指示 SNI 在 TLS 服务器选择握手参数和证书时,提供客户端希望访问的主机名。本文讨论的是证书认证且没有 early data 的路径,因此选择发生在普通 HTTP 字节发送之前,不是网络上任何字节都还没出现的时候。SNI 只是帮助选择,客户端仍然必须验证返回的证书身份。 同一个地址上的不同决策 客户端把 server_name 放在 ClientHello 中,托管多个名称的服务器可以据此选择证书或安全配置。TLS 1.3 在 ServerHello 之后加密证书消息,所以“选证书和传证书都在加密开始之前完成”不是准确描述。应区分先收到选择提示,和随后如何保护证书及其他握手消息。 SNI 中的 DNS 主机名不等于建立连接时使用的目标 IP,填写名称也不要求实验域名真的经过公共 DNS 解析。RFC 6066 不允许在 HostName 字段直接放入 IPv4、IPv6 字面地址,并规定 DNS 主机名比较不区分大小写。客户端直接按 IP 访问时,可以不发送 DNS SNI,转而验证对应的 IP 身份。 ...

十一月 8, 2026 · 4 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

让可复用访问令牌远离请求地址

可复用访问令牌会把某种约定访问能力交给持有它的人。把令牌放进地址,会让这项能力进入平时就会复制地址的系统,例如访问日志、监控记录、浏览器导航历史、书签和分享链接。具体暴露范围取决于请求及策略,但查询参数的名称看起来像秘密,并不会自动让它受到保护。传输位置本身就是凭据披露边界的一部分,应当结合它会经过哪些记录和分享环节判断。 对于 OAuth 持有者令牌请求,通常应采用授权请求头机制。HTTPS 仍然不可缺少,因为把值从查询移动到请求头并不会加密它;TLS 保护的是到终止位置为止的传输数据。传输结束后,应用和中间组件仍然能够检查字段。请求头的直接收益,是让令牌离开请求地址,而不是保证它永远不会被日志或诊断工具捕获。不能把两种不同的保护作用混为一谈。 分清减少暴露与绝对保密 某些访问日志只记录方法、请求目标和状态。在这种格式里,请求头令牌不会出现在目标字段中,而查询令牌会出现。但其他系统可能采集请求头,因此授权字段仍需在日志、链路和错误报告配置中被明确处理。应验证真实管线究竟保存了什么,不能假定请求头可以绕过全部日志层。检查时使用无效的合成值即可,不应为了演示而把真正凭据再次写入更多记录。 引用来源策略能够减少源地址向后续目标披露,却不会删除已经发给目的地的查询内容,也不会抹去浏览器记录或改写已复制的链接。同样,禁止缓存响应并不是要求清除全部访问日志和导航历史的通用指令。把令牌移到片段,会改变 HTTP 请求发送哪些内容,但值仍可被页面脚本和浏览器地址状态访问,也不是适合所有场景的凭据存储方案。 坏掉的样子 团队可能把接口认证从查询参数改成请求头,却让旧的带令牌地址继续留在报告和共享书签中。传输方式改变并不会撤销此前暴露,需要调查受影响的副本,并在系统支持时使已经暴露的可复用凭据失效。另一种误判是把有效期很短的链接称为泄露也无所谓;在有效期间,任何取得这项能力的人仍然可能使用它。缩短时间窗口只能减少风险,不能取消披露的后果。 当实际客户端流程需要时,签名地址可以是有意设计的、范围很窄的资源访问方式,但允许访问的资源、操作、期限和处理规则都应明确审阅。到期失效与只能使用一次并不相同,要阻止到期之前重复使用,需要真正强制执行消费状态的机制。简单的回调接口或浏览器导航,也不会自动构成把无限制可复用接口凭据放进查询的理由,应根据真实客户端选择合适的认证流程。 快速验证 保存代码为 token-placement.py,执行 python3 token-placement.py –serve,打开打印出的页面。本次使用 Python 3.14.4 和 Chrome 152,全部演示值都是明确无效的固定标记,不能替换为真实凭据。回环服务只是观察实验,不是认证服务;它记录请求目标以及示例授权头是否存在,不记录该请求头的值。结束后按控制键加 C 关闭服务,临时事件仅留在本次进程的内存中。 import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from urllib.parse import urlsplit from urllib.request import Request, urlopen # Synthetic demonstration value only; this server is not an authentication service. demo = "DEMO-NOT-A-SECRET" events = [] class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): path = urlsplit(self.path).path if path == "/events": body = json.dumps(events).encode() kind = "application/json" elif path == "/echo": entry = {"request_target": self.path, "authorization_present": self.headers.get("Authorization") == "Bearer " + demo, "referer": self.headers.get("Referer")} events.append(entry) body = json.dumps(entry).encode() kind = "application/json" else: events.append({"request_target": self.path, "document": True}) body = ("""<!doctype html><meta charset="utf-8"><title>Token placement lab</title> <h1>A clean referrer does not erase the request URL</h1><pre id="result">Running...</pre><script> const demo=""" + json.dumps(demo) + """; async function run() { const query=await (await fetch('/echo?access_token='+demo)).json(); const header=await (await fetch('/echo',{headers:{Authorization:'Bearer '+demo}})).json(); await new Promise(resolve=>requestAnimationFrame(resolve)); const resourceUrls=performance.getEntriesByType('resource').map(entry=>entry.name).filter(name=>name.includes('/echo')); window.labResult={page:location.href,query,header,resourceUrls}; document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2); } run().catch(error=>{window.labError=String(error);}); </script>""").encode() kind = "text/html; charset=utf-8" self.send_response(200) self.send_header("Content-Type", kind) self.send_header("Content-Length", str(len(body))) self.send_header("Referrer-Policy", "no-referrer") self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) server = HTTPServer(("127.0.0.1", 0), Handler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() origin = f"http://127.0.0.1:{server.server_port}" try: with urlopen(origin + "/echo?access_token=" + demo, timeout=5) as response: query = json.load(response) with urlopen(Request(origin + "/echo", headers={"Authorization": "Bearer " + demo}), timeout=5) as response: header = json.load(response) assert demo in query["request_target"] and query["authorization_present"] is False assert demo not in header["request_target"] and header["authorization_present"] is True events.clear() print(json.dumps({"app_url": origin + "/?access_token=" + demo, "wire_placement_checks": "PASS", "real_credentials_used": False}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: server.shutdown() thread.join(timeout=5) server.server_close() 页面先通过 access_token 查询发出一次请求,再通过授权请求头发出另一次请求。因为页面设置了不发送引用来源,两次接收结果中的 Referer 都为空。然而查询请求的目标仍然包含演示标记,请求头方案的目标则只是 echo 路径。浏览器资源计时条目中的地址也体现同样差异。页面自身的地址同样带有演示标记,因此当前导航记录仍保留这条完整地址,不能因为后续请求没有来源头就认为它消失了。 ...

十一月 1, 2026 · 2 分钟 · XB Field Notes
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

Cookie 作用域让会话留在该待的地方

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 也不等于完整的同源限制。 ...

十月 25, 2026 · 2 分钟 · XB Field Notes
DNSSEC 验证文章封面,包含依次相连的 OBSERVE、DECIDE 与 ACT 面板。

DNSSEC 验证签名数据链,不只检查签名是否存在

DNSSEC 为 DNS 数据增加来源认证和完整性检查。RRSIG 对记录集签名,DNSKEY 发布验证密钥,父区 DS 则通过摘要标识一个子区 DNSKEY。父区签名的是自己的 DS 记录集,不是简单直接签署子区公钥。验证从配置的信任锚出发,沿这些关系抵达所请求数据。理解链中每类记录的责任,才能准确定位发布与验证之间的差异。 坏掉的样子 如果委派没有建立预期信任路径,仅发布签名并不足够。缺失 DS 可能让子区处于不安全状态,错误 DS 则可能让原本应安全的子区验证失败;这是不同状态。已经安全证明不存在 DS,并不要求把每个未签名或独立签名的子区回答都当成无效数据拒绝。不能把“没有形成认证链”和“已有安全链却证明失败”混成一种结果。 签名具有独立于普通缓存 TTL 的生效与到期时间。签名过期后,权威区域可能继续回答记录,验证器却会拒绝。错误时钟、不完整密钥轮换和父区旧 DS,都可能中断一个普通查询看似仍正常的区域。只监控 DNSKEY 或 RRSIG 是否存在,会漏掉真正验证边界;应关注当前数据能否在有效时间和正确委派下被实际验证接受。 AD 标志也需要通过受信任解析路径解读。任意未受保护响应里出现该位,不会使回答自动可信,攻击者可以修改没有认证保护的消息。同样,使用 DO 请求 DNSSEC 记录,并不等于执行验证。应先确认哪个组件验证,以及应用如何信任它的回答,再把显示的标志作为证据。不能只凭工具输出里出现两个字母,就扩大为端到端安全保证。 快速验证 将脚本保存为 check-dnssec.py,准备 Docker 后执行 python3 check-dnssec.py。固定 BIND 9.18.49 环境创建一次性密钥和离线签名父子区域。独立验证解析器信任本地 lab.test 锚,只把这个测试命名空间转发给临时权威。私钥留在容器内部,不更改宿主信任配置和系统时间;不同案例使用独立进程,避免此前缓存影响结果: ...

十月 21, 2026 · 3 分钟 · XB Field Notes