两个重排列表包含相同 CSS 与 assets 正则;针对同一 assets CSS 路径,各列表第一条匹配行高亮。

nginx 选择第一个匹配正则,不会计算通用具体程度分数

nginx 不会给所有 location 表达式计算一份通用的具体程度分数。普通前缀按匹配长度选择,精确 location 可以结束搜索,竞争正则则按声明顺序求值。某个表达式在人看来更窄,也可能输给前面更宽的正则。把这些选择规则混在一起,是许多看似仔细的路由变更影响意外路径的原因,必须分别理解每类规则。 坏掉的样子 通用 .css 后缀正则,可以先于后面的 /assets/ 限定正则获胜。后者因为包含目录,看起来似乎更具体,但 nginx 不会比较两个模式在语义上谁更窄。在这里的平面结构中,只要两者都匹配,顺序就决定使用哪个配置。路径更长或正则文字更多,都不是正则之间的额外优先分。 调整普通前缀顺序是另一回事。把 /assets/ 移到 / 之前,不会让它“更优先”;在采用前缀结果时,更长匹配本来就决定它获胜。如果把所有 location 都当成同一个先匹配先使用列表,审阅可能做出无效调整,却漏掉真正控制故障路径的正则顺序。修改文本与修改行为之间,需要通过规则和请求证据连接。 较短的 ^~ 前缀,也不是整个子树的永久保护层。如果存在更长的普通前缀并被选中,较短的标记前缀就不再是抑制正则阶段的候选。因此,新增一个看似无害的更详细前缀,可能让原来停留在标记 location 的请求重新被后缀正则接管。这种变化不能仅凭新增块自身内容看出来。 nginx 1.28.0 平面 location 分别遵循精确终止、最长前缀与正则顺序规则;路由标记不验证下游策略。 快速验证 下文结果归属于历史文章报告,本轮没有重跑;保留的验证文件中未找到原始集成运行日志。运行需备好本地固定镜像并启用 Python 断言;清理仅尽力执行,不能据此保证所有资源已删除。 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建两份临时配置,分别执行 nginx -t,再通过宿主回环端口发送真实请求。每份配置都只使用平面 location,结束时尝试清理自己的容器、镜像标签和私有网络,不修改既有服务: ...

十二月 27, 2027 · 2 分钟 · XB Field Notes
父页面包含子响应。嵌入许可箭头从子响应的 frame-ancestors 策略指向获准的父来源。

框架策略明确谁可以嵌入当前页面

点击劫持利用用户对界面的误判,让一个动作落到并非其真实意图的控件上。恶意父页面可以把被嵌入的应用放在误导性视觉层后面,使用户点击实际进入该应用中的真实按钮。父页面不必能够读取子页面的文档结构,用户仍然可以与框架里的内容交互。因此,同源读取限制不能替代应用是否允许被嵌入的独立决定,也不能仅靠看不到对方文档就判断点击过程安全。 被嵌入的响应可以通过内容安全策略禁止任何祖先嵌入,或者明确列出允许的祖先来源。这里写的应当是获准包含当前文档的页面来源。假设商户页面嵌入支付页面,应由支付页面的策略允许商户来源;仅列出支付组件自己的来源,并不能表达商户有权嵌入它。配置之前先明确哪一份响应属于子页面、哪个来源是父页面,可以避免把允许列表方向写反。 把限制放到正确的响应上 嵌入祖先指令回答谁能嵌入当前文档,框架来源指令则回答当前文档自己可以加载哪些框架。给子页面设置严格的 frame-src,并不会阻止另一个父页面嵌入它。默认来源全部拒绝,也不会作为 frame-ancestors 的回退值。这些指令控制的是关系中的不同方向,不能仅因为名字都涉及框架,就把其中一个当成另一个的替代品。 嵌入祖先指令不能通过 CSP 的 meta 元素生效,必须通过响应头交付。遇到多层嵌套时,每一级祖先都需要符合策略,而不只是直接父级。某个组件即使可以合法放在指定应用中,当该应用本身又被放进未经许可的外层框架时,仍可能被阻止。因此制定允许列表时,要审阅整个预期嵌入链,不能只凭最内层组件和它紧邻的容器作判断。 坏掉的样子 一次部署可能只给容器页面增加了策略,却让真正敏感的子页面响应保持不受限制;另一次部署可能把策略写入 HTML 元数据,看到配置文字存在就误以为浏览器会执行。应检查实际被框架加载的响应,并验证浏览器是否渲染。仅收到二百状态,既不能证明嵌入成功,也不能证明没有框架防护,因为浏览器可以收到文档后,拒绝在当前祖先环境中显示它。 互相冲突的响应头也可能误导审阅者。在支持这套规则的现代浏览器里,强制执行的嵌入祖先策略优先于该响应的 X-Frame-Options。给允许某个父来源的策略再附加一个 DENY,并不必然得到期望中的更严格结果。如果确实需要保留多个响应头,应让它们与实际受众和浏览器支持约定一致,不能假定它们都像多份 CSP 那样自然取限制交集。 快速验证 将代码保存为 frame-policy.py,执行 python3 frame-policy.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.request import urlopen paths = ["open", "csp-block", "xfo-block", "csp-allow", "meta", "frame-src"] requests = [] class Child(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): name = self.path.lstrip("/") assert name in paths requests.append(name) meta = ('<meta http-equiv="Content-Security-Policy" content="frame-ancestors \'none\'">' if name == "meta" else "") body = ("<!doctype html><meta charset=utf-8>" + meta + "<p>Rendered: " + name + "</p>" "<script>parent.postMessage({frame:" + json.dumps(name) + "}," + json.dumps(app_origin) + ");</script>").encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") if name == "csp-block": self.send_header("Content-Security-Policy", "frame-ancestors 'none'") elif name == "xfo-block": self.send_header("X-Frame-Options", "DENY") elif name == "csp-allow": self.send_header("Content-Security-Policy", "frame-ancestors " + app_origin) self.send_header("X-Frame-Options", "DENY") elif name == "frame-src": self.send_header("Content-Security-Policy", "frame-src 'none'") self.end_headers() self.wfile.write(body) class Parent(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): if self.path == "/events": body = json.dumps(requests).encode() kind = "application/json" else: body = ("""<!doctype html><meta charset="utf-8"><title>Frame policy lab</title> <h1>Which child responses can this parent embed?</h1><pre id="result">Waiting for frame messages...</pre> <button id="check">Show server requests</button><div id="frames"></div><script> const child=""" + json.dumps(child_origin) + ";const names=" + json.dumps(paths) + """; window.received=[]; window.snapshot=async()=>{ const result={rendered:[...window.received].sort(),serverRequests:await (await fetch('/events')).json()}; document.getElementById('result').textContent=JSON.stringify(result,null,2);return result; }; addEventListener('message',event=>{ if(event.origin===child && names.includes(event.data?.frame)) { window.received.push(event.data.frame); document.getElementById('result').textContent=JSON.stringify(window.received,null,2); } }); document.getElementById('check').onclick=()=>window.snapshot(); for(const name of names) { const frame=document.createElement('iframe');frame.src=child+'/'+name;frame.title=name; frame.width=240;frame.height=100;document.getElementById('frames').append(frame); } </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.end_headers() self.wfile.write(body) child = HTTPServer(("127.0.0.1", 0), Child) app = HTTPServer(("127.0.0.1", 0), Parent) child_origin = f"http://127.0.0.1:{child.server_port}" app_origin = f"http://127.0.0.1:{app.server_port}" threads = [Thread(target=server.serve_forever, daemon=True) for server in (child, app)] for thread in threads: thread.start() try: for name in paths: with urlopen(child_origin + "/" + name, timeout=5) as response: assert response.status == 200 if name == "csp-allow": assert response.headers["Content-Security-Policy"] == "frame-ancestors " + app_origin assert response.headers["X-Frame-Options"] == "DENY" response.read() requests.clear() print(json.dumps({"app_url": app_origin, "child_origin": child_origin, "wire_responses": 6}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: for server in (child, app): server.shutdown() server.server_close() for thread in threads: thread.join(timeout=5) 开放子页面应正常渲染;csp-block 被禁止祖先嵌入策略拒绝,xfo-block 被 DENY 拒绝。csp-allow 虽然同时发送 DENY,但强制祖先策略明确允许当前父来源,因此正常渲染。meta 页面也会渲染,因为该交付方式不能执行嵌入祖先指令;frame-src 页面同样渲染,因为它限制的是子页面接下来自己加载的框架,而不是正在验证的父子嵌入关系。 ...

九月 23, 2027 · 2 分钟 · XB Field Notes
用户控制的 URL 进入服务端可达边界,经抓取器指向内部目标;此图表示网络位置委托,并非已验证的联系。

SSRF 让服务器替你去开门

服务端请求伪造会让服务器发出由攻击者选择或影响的请求。缩略图服务、导入器、Webhook 测试器或文档渲染器,可能到达请求用户无法直接访问的目标。服务器贡献的是自己的网络位置,以及这条具体抓取路径实际附带的凭据;取一个地址并不意味着进程可见的所有账号凭据都会自动加入请求。评估风险应从真实连接与认证方式开始。 先看清委托了什么权限 内部服务、回环管理端点和云元数据服务,在可达时都是重要目标。后果取决于认证、响应是否可见、允许的方法和网络策略。盲 SSRF 即使不返回目标正文,也可能触发动作或暴露时间信息。不能只凭功能名称给严重程度排位,而应确认它能联系什么,以及一次联系可以产生什么结果,尤其注意有副作用的内部接口。 在 EC2 上,实例附加了角色且相关访问被允许时,角色凭据可能通过元数据提供。角色名称列表与具体角色的凭据文档是不同请求。这些临时凭据代表该角色获得的权限,并不等于无限 AWS 访问,也不保证是实例上每个应用实际采用的凭据。目标没有角色、端点不可达或权限不足时,风险形态会不同,需要依照部署事实判断。 IMDSv2 要求先用 PUT 获取令牌,再在后续元数据请求中提供令牌。强制使用它可以挡住简单、没有认证头的单次 GET 路径,但不能解决能够控制必要方法和请求头的任意 SSRF。应结合最小权限角色与有效网络限制。元数据还可能通过 IPv4,以及启用并受支持时的 IPv6 到达;检查时不能只处理一种地址族。 解析、解析域名,并保留已检查的目标 允许列表应描述功能真正需要的目标。如果可以,接收资源标识而不是任意 URL。否则,应使用与 HTTP 客户端解释一致的解析器,限制协议、主机和端口。不需要的 userinfo 和歧义语法应拒绝,不能因为字符串某处出现了允许的域名,就认定目的地已经获准。解析函数有返回值,也不等于输入符合业务策略。 例如,位于 @ 前面的 trusted.example 是用户信息,不证明目标主机可信。旧式数字 IPv4 写法在不同解析器和解析库中可能有不同解释。IPv6 回环是 ::1,并不是 127.0.0.1,所以搜索一个固定字符串远远不够。一个运行时的解析结果,也不能直接成为另一个客户端行为的可移植保证。验证应围绕实际使用的组件版本建立。 解析允许的域名后,要按照预期网络策略检查返回的全部地址。只允许公网抓取时,应拒绝回环、私有、链路本地、多播以及其他禁止范围。地址库和登记规则会演进,因此要测试真正部署的版本。全球可路由也不代表目标是获准的、可信的或无害的服务;地址分类与业务允许列表解决的是不同问题,应同时保留。 坏掉的样子 先检查 DNS,再让客户端自行重新解析,会留下检查与使用之间的空隙。应通过受支持的传输机制固定经过验证的连接地址,同时保留原本的 TLS 主机名、证书校验和 HTTP authority。代理可能独立解析;因此须检查真实出口路径、重试与回退连接,不能假设一次本地预检控制了它们。固定地址也不能以关闭证书验证为代价。 ...

九月 5, 2027 · 2 分钟 · XB Field Notes
浏览器可能自动携带会话 Cookie;文章中的服务端关卡同时检查 POST、与会话绑定的 CSRF 令牌和可信来源。

CSRF 检查请求到底来自哪里

浏览器可能为攻击者触发的请求附带会话 Cookie。CSRF 防御检查请求上下文是否可信,却不能证明用户真的看过或有意批准某个动作。 本图描述文章的服务端夹具,不是浏览器实测。SameSite 只减少 Cookie 发送;可信上下文不证明用户意图,XSS 与认证仍需独立防护。 Cookie 无意识地跟随请求 浏览器判别是否带上 Cookie,依据的是目标域名、路径、Secure、SameSite 及其他浏览器规则,而不是「这次请求是由用户先前的什么意图触发的」。于是,来自攻击者网站的页面,也能驱动受害者的浏览器发出一条改变状态的请求——改密、改资料——并且这条请求会带上已登录的会话 Cookie。服务器看到这样一条请求,会以为它来自合法用户。这正是关键:请求带了凭据,并不能证明这是用户的本意;浏览器只是在执行它的 Cookie 规则,而不是用户的意志。 令牌把动作与来源绑定 对策是让「改变状态的动作」带一个攻击者无法预料的参数。服务端为每个会话生成一个不可预测的反 CSRF 令牌,在它自己的表单里带上,提交时再比对;攻击者的跨站页面既没有这个令牌,校验就会失败。配合严格的方法约束——不要让 GET 去改变状态——让「带令牌」成为改变状态的唯一途径。比对必须严格:缺失或不匹配就拒绝,而且绝不接受攻击者自带的 origin。 SameSite 是浏览器护栏 SameSite=Strict 不在跨站请求中发送该 Cookie;显式设置 Lax 时,使用安全方法的顶层导航仍可携带它。这些限制减少凭据被跨站运送的机会,但本设计仍要求服务端校验令牌。同站范围大于同源,浏览器支持和请求上下文也会影响结果。示例采用服务端保存的同步令牌,而非双提交 Cookie;两种模式不能仅因为都叫令牌,就混为同一种实现。源内脚本注入可以替用户发出合法上下文中的请求,所以还需单独防御 XSS。 把 Origin 当作来源信号 请求存在 Origin 时先检查它;缺失时,服务端可以检查 Referer 的来源部分。两个字段都可能缺失,Origin 也可能是字面值 null,不能把这些情况当作同源证据。本夹具有意同时要求有效令牌和可信来源信号,缺一即拒绝;实际兼容策略也应明确写出,不能默默跳过整个保护。应比较完整的协议、主机与端口,不能只匹配域名前缀,防止相似域名通过。 ...

九月 4, 2027 · 2 分钟 · XB Field Notes
原文 JSON 正文从 28015 字节压缩为 246 字节 gzip,解码还原为完全相同的 28015 字节;图形大小为示意。

显式启用 gzip,再验证协商与解码后的字节

gzip 是响应内容编码:nginx 可以发送压缩表示,由接受该编码的客户端解码。启用它需要 gzip on;gzip_types 扩展有资格压缩的 MIME 类型,却不会单独激活压缩。最终结果取决于响应、请求方能力和实际生效配置。只有压缩后仍能正确还原目标内容,较小正文才有价值,不能把“传输字节变少”与“功能已经正确”直接画等号。 坏掉的样子 在 gzip off 下列出 MIME 类型,不会让响应变成 gzip。另一个常见遗漏是 gzip_vary on。nginx 并不会仅因为发生压缩,就无条件添加 Vary: Accept-Encoding;该行为受对应指令控制。共享缓存需要知道哪些请求头维度影响表示选择,否则不同解码能力的客户端可能拿到不合适的已存响应。压缩成功和缓存选择正确是两个需要分别检查的结果。 最小长度设置的契约比名称暗示的更窄。gzip_min_length 根据响应 Content-Length 字段确定长度,不一定知道未指定长度响应的最终大小。因此,一个很短的动态流式响应仍可能被压缩,而同样短的静态文件会因为已知长度低于阈值而跳过压缩。不能从阈值配置推断所有小响应都不会进入压缩路径。 测量方法也可能误导。解压后输出文件的大小,不是压缩传输正文大小;压缩正文字节也不包含全部 HTTP 分帧、响应头和 TLS 流量。.gz 等文件扩展名本身不能证明实际编码。解释任何表面上的带宽节省之前,都应检查真实 Content-Encoding,并解码核对字节,否则可能只是把两种不同计量对象放在一起比较。 解码后应还原同一份字节。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 ...

八月 29, 2027 · 2 分钟 · XB Field Notes
对 /a 与 /b 的请求汇入同一 identity 区域与 client key。历史样例中,第一次返回 200,下一次被拒绝并返回 429。location 名称不会自动分出预算。

请求限流保护的是 key 所命名的预算

请求速率限制作用于 key 表达的身份,不会自动理解人、账户或端点。limit_req_zone 为这些 key 值保存共享状态,limit_req 则在处理请求时应用对应区域。两个 location 使用相同区域和 key,可以消耗同一份预算。因此,选择变量实际上是在定义公平性与执行边界,并不是只给配置找一个方便填写的字符串。 原文描述 nginx 1.28.0、两个 worker 与 rate=1r/m。结果来自历史记录,不是本次实测。所示 limit_req 配置没有突发或延迟实验;实际拒绝与 dry run 放行是不同结果。当前审核为已修订、执行受阻。本次未重跑,也未增加页面或发布验证。 坏掉的样子 客户端可控标识可能把一个表面上的限制变成许多独立额度。如果修改查询参数就能改变 key,不可信客户端就能选择另一份预算。空 key 更直接:nginx 不会统计 key 求值为空的请求。身份变量缺失时,请求可能无声地离开原本计划的限流范围。只测试正常携带身份的访问,会漏掉这条重要边界。 ...

八月 28, 2027 · 3 分钟 · XB Field Notes
不可信客户端声明到达替换边界;声明路径被交叉标记,随后使用直接客户端入口观察到的对端。

反向代理需要明确的请求头信任边界

反向代理接受客户端连接,再单独请求上游应用。因此,应用套接字看到的对端是代理,较早客户端的信息则可能通过请求头传入。这些头部只有在部署明确谁可以设置、如何验证时才有可信含义。请求经过代理,不会自动让所有转发身份或协议声明变得可靠;需要把网络连接事实与请求携带的文本声明区分开。 坏掉的样子 客户端到达代理前就可以提供 X-Forwarded-For。普通转发可能保留它,$proxy_add_x_forwarded_for 也只是追加当前对端地址,不会移除不可信前缀。如果应用盲信第一个地址,就可能把请求归属到客户端自行选择的值。正确解释取决于真实可信代理链,不能因为字段名包含 Forwarded 就赋予整条列表相同信任。 Host 处理是独立决策。nginx 默认上游 Host 基于代理目标,不会自动采用客户端访问的公共 Host。因此,构造绝对跳转或选择虚拟主机的应用,在代理后可能表现不同。应只转发预期主机上下文,并应用服务自己的可接受主机名策略;复制头部不等于验证其中名字,也不能替代虚拟服务器入口限制。 代理也不保证后端细节完全不暴露。响应头、跳转、正文、诊断信息或其他公开配置,都可能披露源站信息。限制直接访问和控制特定响应字段很有价值,但这不同于声称一条 proxy_pass 就能隐藏全部上游身份,或提供所需的所有传输保护。地址隐藏、访问控制和加密需要分别依据真实部署判断。 直接客户端实验区分套接字观察与客户端文本。替换选定字段不验证所有代理链、Host 值或传输安全。 快速验证 下文结果归属于历史文章报告,本轮没有重跑;保留的验证文件中未找到原始集成运行日志。运行需备好本地固定镜像并启用 Python 断言;清理仅尽力执行,不能据此保证所有资源已删除。 使用宿主 Python、Docker,以及固定 nginx 和 Python 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16、Docker 29.3.1。它创建私有 Docker 网络与真实回显后端,只把代理发布到宿主回环,验证配置并尝试清理自己的资源,不涉及生产端点或凭据。实验中的伪造值使用文档专用地址: from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory import http.client,json,socket,subprocess,time,uuid NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609' PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def wait(predicate): deadline=time.monotonic()+15 while time.monotonic()<deadline: try: if predicate():return except OSError:pass time.sleep(.1) raise AssertionError('fixture did not become ready') @contextmanager def lab(configuration,files=None,backend=None): name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend'] try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration) for path,content in (files or {}).items(): target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content) docker('network','create',name) if backend is not None: (root/'backend.py').write_text(backend) (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n') docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root)) docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1]) wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0) (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n') docker('build','--network','none','-t',tags[0],str(root)) docker('run','--rm','--network',name,tags[0],'nginx','-t') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0]) details=json.loads(docker('inspect',containers[0]).stdout)[0] binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort']) def ready(): connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True wait(ready) def request(path,method='GET',headers=None,body=None): connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5) try: connection.request(method,path,body=body,headers=headers or {}) response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read() finally:connection.close() yield containers[0],request finally: docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False) def config(locations,extra=''): return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n' backend=r'''from http.server import BaseHTTPRequestHandler,HTTPServer import json class Handler(BaseHTTPRequestHandler): def do_GET(self): body=self.rfile.read(int(self.headers.get('Content-Length','0'))) document={'path':self.path,'method':self.command,'body':body.decode(),'headers':{key:self.headers.get(key) for key in ['Host','X-Forwarded-For','X-Real-IP','X-Forwarded-Proto','X-Forwarded-Host']}} data=json.dumps(document).encode() self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.send_header('X-Backend-Info','fixture-node');self.end_headers();self.wfile.write(data) do_POST=do_GET def log_message(self,*args):pass HTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=r''' location /default { proxy_pass http://backend:8081; } location /append { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://backend:8081; } location /edge { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_hide_header X-Backend-Info; proxy_pass http://backend:8081; } ''' with lab(config(locations,'server_tokens off;'),backend=backend) as (container,request): forged={'Host':'public.example','X-Forwarded-For':'198.51.100.77','X-Forwarded-Proto':'forged-scheme','X-Real-IP':'198.51.100.88'} status,headers,body=request('/default',headers=forged);plain=json.loads(body) assert status==200 and plain['headers']['Host']=='backend:8081' assert plain['headers']['X-Forwarded-For']=='198.51.100.77' and plain['headers']['X-Forwarded-Proto']=='forged-scheme' assert headers['x-backend-info']=='fixture-node' status,headers,body=request('/append',headers=forged);appended=json.loads(body)['headers']['X-Forwarded-For'].split(', ') assert status==200 and len(appended)==2 and appended[0]=='198.51.100.77' status,headers,body=request('/edge',headers=forged);edge=json.loads(body)['headers'] assert status==200 and edge['Host']==edge['X-Forwarded-Host']=='public.example' assert edge['X-Forwarded-For']==edge['X-Real-IP']==appended[-1] assert edge['X-Forwarded-For'] not in ['198.51.100.77','198.51.100.88'] and edge['X-Forwarded-Proto']=='http' assert 'x-backend-info' not in headers and headers['server']=='nginx' print('ordinary forwarding preserves forged forwarding headers and defaults Host to the upstream authority') print('append retains the untrusted prefix; the direct-client edge configuration replaces it with the actual socket peer and scheme') print('explicit response-header suppression removes one backend field, while server_tokens off still exposes the nginx product name') 每个请求都把 Host 设为 public.example,并携带特意伪造的转发值。默认路由向后端发送 Host: backend:8081,同时保留伪造的 X-Forwarded-For 和 X-Forwarded-Proto。这直接证明普通代理转发不是身份清洗步骤,也不会自动保留公共主机名。后端回显让这些差异可见,不需要仅凭配置推理。 ...

八月 27, 2027 · 2 分钟 · XB Field Notes
原始请求 /a//b/../c?x=1 向下指向当前 /a/c,独立原文保留路径说明输入拼写仍保留。

nginx 匹配规范化路径,并可能再次选择 location

nginx location 匹配使用规范化后的 URI 路径,而不是完全未经处理的请求目标文本。百分号编码字符会被解码,相对路径组件会被消解,相邻斜杠也可能按配置合并。之后的处理还能再次改变当前 URI,并触发新的 location 搜索。当请求似乎进入了与原始拼写不同的处理器时,需要同时理解这两类转换,而不是只比较配置中的字面前缀。 坏掉的样子 只根据原始请求文本判断的路径过滤器,可能与 nginx 路由视图不一致。编码斜杠可以变成路径分隔符,点段可以移除前面的组件,后端还可能采用自己的解码规则。因此,安全与路由决策需要在每个边界约定解释方式,不能假定所有组件看到完全相同的路径字符串。否则同一请求可能在检查时属于一个家族,处理时却属于另一个。 只记录 $uri 也可能掩盖实际输入。它表示当前规范化 URI,并可能在请求处理期间改变;$request_uri 则保留完整原始 URI,包括原始查询字符串。两者不能互相完全替代:前者解释当前路由,后者帮助重建客户端实际请求。诊断时应按问题选择字段,而不是把一个名字相近的变量当成全部路径信息。 index 和 try_files 还会引入另一类转换。目录请求可以内部变成索引文件请求,文件查找失败可以内部跳到回退 URI,使不同 location 和参数生效。所以,最初的前缀匹配不一定就是产生最终响应的配置。若只验证入口匹配,而忽略后续内容处理,就可能漏掉访问控制、缓存或响应状态发生变化的位置。 内部重选可改变当前路径与参数,request_uri 保留输入 URI。本次绘图审阅未执行断言。 快速验证 下文结果归属于历史文章报告,本轮没有重跑;保留的验证文件中未找到原始集成运行日志。运行需备好本地固定镜像并启用 Python 断言;清理仅尽力执行,不能据此保证所有资源已删除。 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。历史文章报告的环境为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建并检查临时配置,只发布宿主回环端口,发送客户端未预先规范化的请求目标,最后尝试删除自身镜像、容器和网络。这样可以观察 nginx 收到明确文本之后的处理,而不被浏览器自动整理路径干扰: ...

八月 26, 2027 · 2 分钟 · XB Field Notes