观察、决策、行动三个面板,旁边的标题说明Age 头估算响应年龄。

Age 头告诉我们缓存已存在多久

Age 是从源站生成或成功验证响应以来,已经经过多少秒的估计值,包含缓存驻留和网络传输时间。它不是对象的创建时间戳,也不是访问次数,更不只是最后一台代理在磁盘上保存副本的时长。要判断这个数字意味着什么,必须同时查看响应的新鲜度策略和实际请求上下文,不能把年龄直接等同于是否允许复用。 合并估计时不能重复计时 RFC 9111 定义了接收响应时的两种年龄估计。表观年龄用接收缓存的时钟减去 Date,如果得到负数,就按零处理。校正年龄从收到的 Age 数值出发,再加上发出请求到收到响应之间的时间。缺少 Age 时,这个输入按零参与计算,但并不意味着应当丢掉基于 Date 的另一种估计。 当缓存链满足相关 HTTP 行为要求时,RFC 允许直接使用校正年龄;更保守的计算则取表观年龄与校正年龄的较大值,再加本地驻留时间。下面的实验采用后者。把这两种估计直接相加,会重复计算互相重叠的时间;而每经过一个缓存就把 Age 清零,又会丢掉上游已经积累的历史。 请求、接收和本地驻留时间应使用一致的测量方法,Date 比较还依赖时钟同步。因此结果仍然是估计,而不是精确秒表。生产实现需要恰当处理时钟和输出整数值。下面只使用固定的整秒 UTC 时间与合法头字段,展示指定计算步骤,不构成一套完整的 HTTP 缓存实现。 新鲜度是另一个判断 当前年龄为四十五秒,寿命为六十秒时仍在新鲜期;同样的年龄放在三十秒寿命下,就已经陈旧。相等时也算陈旧,因为新鲜要求年龄严格小于寿命。不过,这个比较本身并不授权复用。请求限制、缓存键、Vary、存储规则和要求验证的指令,都可能让一份年龄很小的响应仍然不能直接返回。 对于共享缓存,s-maxage 覆盖 max-age,其数值既可能缩短,也可能延长适用寿命。stale-while-revalidate 并不是把旧响应重新变成新鲜,而是在策略与实现允许的条件下,一边验证一边有限使用陈旧副本。应把“允许服务旧内容”视为另一种状态,不要把所有宽限期都混进新鲜期的定义。 坏掉的样子 Age 很大,不自动证明发布出了问题。缓存寿命为一年的指纹资产,即使年龄很大也可能仍然新鲜。正文没有改变的表示,还可以在后来成功通过验证,只更新元数据而不替换字节。此后 Age 对应的是较新的验证历史,不一定反映这些字节最初被作者写出的时间,不能据此计算内容存在了多久。 同一 URL 的每次请求也不保证 Age 单调递增。请求可能到达不同边缘、选择不同表示、遇到条目淘汰,或经过成功的重新验证。Age 为零也可能来自刚缓存的响应;没有这个头,同样不能单独证明一定由源站直接应答。应对照匹配的请求、正文身份、验证器、Date、缓存策略和服务商诊断字段一起判断。 ...

十一月 6, 2026 · 2 分钟 · XB Field Notes
用于审查反向代理路由的观察、决策和执行面板。

反向代理只对真正经过它的流量执行入口策略

反向代理接收客户端请求,再与应用进行独立的上游交换。配置相应行为后,它可以集中路由、传输、请求头和准入策略,却不会自动隐藏所有后端接口、认证所有调用者或终止 TLS。真正有用的边界,是流量实际经过哪里、在那里执行什么策略,而不是代理在架构图上画在应用前面。描述能力时应对应配置和真实调用路径。 坏掉的样子 如果后端还能通过另一条路径到达,只放在代理上的控制就可能被绕过。不发布容器端口减少了一种暴露途径,但连接到相同网络的其他工作负载仍可能访问应用。如果后端授权完全依赖“所有流量已经经过公开代理”这个假设,内部直连请求就可能让假设失效。需要检查真正可达的调用者,而不是只检查公网端口列表。 URI 映射可能产生更隐蔽的绕过。代理阻止 /admin,并不代表宽泛的 /api/ 映射不会通过 /api/admin 暴露同一个后端处理器。应审阅最终上游 URI,而不只看客户端可见路径。公开一个明确端点,与在方便的前缀下公开所有处理器是不同决定,不能期待两个外部名称不同就天然对应不同权限边界。 转发元数据需要明确处理。客户端可能发送伪造地址或协议头,代理把它继续转发,并不会让它自动可信。应用需要知道可信一跳设置哪些字段,以及哪些调用者可以直接访问自己。浏览器响应头也不能替代请求授权或网络可达性控制。检查响应头存在,只能证明对应字段被发送,不能推断其他安全责任已经完成。 快速验证 将脚本保存为 check-proxy-entry.py,准备 Python、Docker 和固定镜像后运行。此前记录的环境使用 nginx 1.28.0,以及专用 Docker 网络里的可丢弃回显应用;本轮没有重跑。只有代理绑定宿主回环端口。实验使用 HTTP 检查路由与策略,并没有执行 TLS 部署;结束后清理自己的容器、镜像标签和网络,不改动已有服务: 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,extra_ports=()): 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_bytes(content if isinstance(content,bytes) else content.encode()) 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') published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))] docker('run','-d','--name',containers[0],'--network',name,*published,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() request.port=port;request.network=name request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value} 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,ThreadingHTTPServer import json class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def respond(self,body=b''): data=json.dumps({'path':self.path,'method':self.command,'body':body.decode(), 'host':self.headers.get('Host'),'forwarded_for':self.headers.get('X-Forwarded-For'), 'forwarded_proto':self.headers.get('X-Forwarded-Proto')}).encode() self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data))) self.end_headers();self.wfile.write(data) def do_GET(self):self.respond() def do_POST(self):self.respond(self.rfile.read(int(self.headers['Content-Length']))) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location = /api/echo { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://backend:8081/echo; } location /admin { deny all; } location / { return 404; } ''' extra='client_max_body_size 1k; add_header X-Content-Type-Options nosniff always;' bad='events {} http { server { listen not-a-port; } }' with lab(config(locations,extra),{'invalid.conf':bad},backend) as (proxy,request): status,h,b=request('/api/echo',method='POST',body=b'hello',headers={'Host':'edge.example', 'X-Forwarded-For':'192.0.2.123','X-Forwarded-Proto':'https'}) data=json.loads(b);assert status==200 and data['path']=='/echo' and data['body']=='hello' and data['method']=='POST' assert data['host']=='edge.example' and data['forwarded_for']!='192.0.2.123' and data['forwarded_proto']=='http' assert h['x-content-type-options']=='nosniff';results={'proxied_request':data} status,h,_=request('/admin');assert status==403 and h['x-content-type-options']=='nosniff';results['admin_at_proxy']=status assert request('/api/echo',method='POST',body=b'x'*2048)[0]==413 assert request('/unknown')[0]==404 and request('/api/admin')[0]==404 results['size_and_route_rejections']=[413,404];results['admin_alias_rejected']=404 details=json.loads(docker('inspect',request.network+'-backend').stdout)[0] assert not details['HostConfig']['PortBindings'] code='import json,urllib.request;print(json.dumps(json.load(urllib.request.urlopen("http://backend:8081/admin"))))' internal=docker('run','--rm','--network',request.network,PYTHON,'python','-c',code) direct=json.loads(internal.stdout);assert direct['path']=='/admin' results['backend_has_host_port_binding']=False;results['internal_direct_path']=direct['path'] probe=docker('exec',proxy,'nginx','-t','-c','/srv/invalid.conf',check=False);assert probe.returncode!=0 assert request('/api/echo')[0]==200;results['invalid_candidate_rejected_live_config_survives']=True print(json.dumps(results,sort_keys=True)) POST 到精确公开路径 /api/echo 后,以原方法和正文到达后端 /echo。提供的 Host 按明确配置转发;实验没有验证主机名允许名单,不能单凭该字段把请求认定为某个可信租户。伪造 X-Forwarded-For 被替换为 nginx 观察到的套接字对端,伪造的 https 协议被替换为这个监听实际使用的 http。响应揭示后端真正收到的字段,而不是只根据指令存在就假定效果正确。 ...

十一月 5, 2026 · 2 分钟 · 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
观察、决策与行动三块面板,旁边的标题说明限流键定义预算范围。

限流需要一个稳定的客户端标识

限流键定义了哪些请求共用一份预算。账号、API 凭据、地址、租户和已认证会话是不同身份,不是“客户端”的几种可互换写法。选错归组方式,可能让无关用户相互挤占额度,也可能让同一份工作只改一个字段,就拿到新的使用机会。 使用政策真正承诺的身份 如果规则是按账号限流,就应从认证结果中取得账号,而不是相信调用方提交的账号请求头或查询参数。仅仅看见一个凭据,并不能证明它有效;认证层必须先验证凭据、解析主体,再让账号级准入决定使用这个结果。限流键的可信程度不能高于它的来源。 按凭据限流也可以是合理的产品契约,但行为与按账号限流不同。轮换凭据或再创建一份凭据,可能得到新的凭据键,却仍属于同一个账号。应先决定这是否应当带来新额度,而不是等出现滥用或投诉后,才发现实现按的身份与产品描述并不一致。 一个账号不一定只对应一个人或一个应用,一个人也可以拥有多个账号。稳定账号键改善的是归组的一致性,并不证明公平,不阻止批量养号,也不限制整个服务的总需求。其他层级的限制应由明确需求支持,不能认为“已经按账号计数”就涵盖所有容量风险。 地址表示的是网络路径 企业 NAT、运营商网络或 VPN 后面的多个账号,可能共用一个公网地址;按 IP 的桶会把它们的流量合在一起。反过来,更换地址的调用方可能进入一个新桶。这两种结果并不说明地址解析错误,而是说明网络地址与业务主体本来就在表示不同对象。 没有已验证账号的公开接口或认证前入口,仍可以用地址级限制保护资源。但应按地址政策评估阈值,包括正常共享网络的流量,不能把它描述成精确的个人限制,也不能假设一个地址规则一定拦得住分散在许多网络上的抓取者。 地址在成为键之前需要语法规范化。同一个 IPv6 地址的不同合法写法,不应创建多份额度。是否按网段聚合,或把特定地址族形式视为同一个对象,是另外的政策选择,需要权衡误伤范围;规范化文本本身并不能替你回答这些业务问题。 转发身份必须有信任边界 随意提交的 Forwarded、X-Forwarded-For 或类似字段,不是客户端来源的证明。RFC 7239 明确指出,客户端和沿途节点都能改变转发信息。是否可信取决于直接连接的对端、配置的代理链,以及这条链如何移除或安全处理用户原先提交的字段。 NGINX 的真实 IP 模块需要明确配置可信来源地址和递归处理规则。“永远取转发字段里的第一个地址”不是适用于所有网络的正确方法。如果代理接收后原样转发了不可信声明,看似稳定的身份键就可能变成由调用者控制的新预算生成器。 下面的实验只模拟一个已经配置的可信代理,它会覆盖字段,并只发送一个 IP 字面值。其他对端提交的替换声明被忽略,来自这个代理的多地址列表则被拒绝。它不是通用代理链解析器,也不是已经部署并验收的 NGINX 配置,不能直接扩大其覆盖范围。 坏掉的样子 键里漏掉租户维度,会合并不同租户中标识相同的账号。按原始 URL 计数,又可能因为查询参数不同,把同一个操作拆成多个桶;如果政策按操作限流,应使用路由解析出的稳定操作身份。直接拼接字符串还可能产生歧义,应在编码中保留各组件边界。 不要把原始 bearer 秘密作为预算键或诊断标签。稳定的内部凭据标识或主体标识通常更合适,日志也仍需要隐私控制。无限创建身份会消耗限流器内存;评估过期或状态淘汰时,还应考虑桶被重置后产生的新额度,不能只看到内存回收这一面。 选定阈值前,应测试共享地址、漫游客户端、凭据轮换、租户隔离和伪造声明等真实身份组合。还要沿数据来源检查所谓“已验证主体”是否真的来自认证结果。如果它本身是从不可信请求直接复制出来的,后面再精巧的字典键也修复不了这个信任错误。 快速验证 保存为 identity_check.py,用 Python 3.10 以上版本执行 python3 identity_check.py。实验已在 Python 3.12.14 上运行,只使用标准库。记录代表已经完成认证和路由解析的测试请求,并不是认证实现;计数器只模拟一个测试窗口,不启动服务,不发送网络请求,也不写文件。 ...

十月 29, 2026 · 2 分钟 · XB Field Notes
文章标题旁排列着由箭头相连的 OBSERVE、DECIDE、ACT 方框,下方有圆形指针和引导线。

传输层均衡保持整条连接,HTTP 路由可以拆分其中的请求

传输层均衡与 HTTP 均衡的实用区别,在于什么位置可以作出路由决定。TCP 转发可以为一条连接选择后端,然后不解释 HTTP 请求,直接转发字节流。HTTP 代理则解析请求,并能根据路径或请求头选择上游。因此,同一个客户端连接里的多个请求,可能获得不同路由结果。选择策略前,应先明确需要按连接还是按请求决定目标。 坏掉的样子 如果期待 TCP 连接选择还能分别路由内部每个 URL,请求就可能到达错误应用。字节流已经转发到一个后端后,后续 HTTP 路径不会自动触发重新选节点。反过来,如果把 HTTP 路由当成永久连接亲和,也可能让应用误以为同一客户端连接的每个请求都会到达同一份本地状态。两种假设都需要通过实际连接复用案例验证。 层级名称也不能完整说明 TLS 行为。TLS 终止与 HTTP 解释是不同能力。stream 代理可以终止 TLS,却不成为 HTTP 路径路由器;也可以不解密应用数据,只查看部分 ClientHello 元数据。按 SNI 选择目标,并不等于能看到加密 URL、请求头或响应正文。应描述实际配置行为,而不是认为产品标签已经证明所有这些性质。 性能结论同样需要谨慎。用户态 TCP 转发与内核包转发具有不同资源路径,即使两者都被称为传输层均衡。HTTP 代理会执行解析和策略工作,但相对吞吐取决于实现与负载。仅选择 L4 或 L7 这两个术语,不能推出通用的线速能力或固定额外开销;需要以真实路径和代表性流量测量,而不是用层级名称代替基准。 快速验证 本轮复核没有重新运行这些 Docker 监听器;文中记录的路由结果仍属于此前观察。 ...

十月 28, 2026 · 3 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边强调选对方法并保留请求意图。

HTTP 方法在描述意图

URL 指认目标资源,请求方法描述客户端希望源站执行的操作。服务器、缓存、代理、爬虫和重试客户端都会读取这个动词,因此方法不是路径上的装饰,而是关于预期效果的一份承诺。只有当承诺诚实时,外围设施才能做出安全的缓存、预取、重试和监控决定。同一个 URL 配上不同方法,对下游来说就是不同请求。 安全与幂等不是一回事 安全方法的定义是“本质上只读”:GET 和 HEAD 都不应要求源站改变目标资源状态。服务器仍可能写访问日志、计费或更新观测计数,但这些附带动作不改变方法的安全语义。幂等则表示,把完全相同的请求发送多次,服务器的预期效果与发送一次相同。PUT 和 DELETE 在协议上具有幂等意图,可是第一次请求可能已经到达而响应在途中丢失,客户端仍需结合版本、条件头和业务结果判断是否重试。 POST 通常表示创建从属资源或执行一次动作;它不会因为当前处理器恰好没有重复副作用,就自动变成幂等。PATCH 表示部分修改,也不天然保证重放安全。OPTIONS 用来询问通信选项,TRACE 有专门的诊断用途。具体资源允许哪些方法由源站决定;已知但不支持的方法应返回 405,并用 Allow 说明可用集合。未知方法则可能得到 501,不能把两个状态码混成一个“失败”。 让动词帮助基础设施判断 GET 与 HEAD 是否可缓存,要结合响应的缓存规则,但它们可被预取、重复读取,正是因为请求意图是只读。若把登出、购买或删除藏在 GET 地址后面,浏览器预取、链接扫描器、爬虫或缓存都可能触发不该发生的动作。重试库可以优先考虑幂等请求,却仍需检查认证、期限、请求体能否重放、条件版本和服务端是否已经提交。幂等不是分布式事务,也不保证客户端永远只看见一份响应。 PUT 请求创建或替换指定目标的状态,重复请求保持相同预期效果;DELETE 移除目标 URI 与其当前功能的关联,不保证物理删除底层数据。本例中条目变成不存在,第一次可能返回 204,后续可能返回 404。若服务端要选择新 ID 并创建对象,用 POST 加幂等键或单独的占位记录更准确,不能仅为了方便重试就假装它是 PUT。API 文档应同时写清方法、状态码、前置条件、版本冲突和重试策略,不能只写一个“请求成功”。 幂等性约束请求的预期效果,不要求响应文本、访问日志或修订历史完全一致。附带记录中的时间戳变化,不会自动破坏幂等性;但重复执行请求明确要求的购买或递增,是另一回事。重试需保留正文与适用的 If-Match 条件。幂等也不防止并发覆盖:无条件重放旧值,仍可能覆盖其他写入者刚提交的更新,必要时应核对条件失败后的权威状态。 ...

十月 24, 2026 · 3 分钟 · XB Field Notes
标题卡配有三个相连面板,标注 OBSERVE、DECIDE 和 ACT。

HTTP/1.1 默认允许持久连接

持久连接允许多次请求和响应共享同一条连接,减少重复的 TCP 建立和 TLS 协商。但这是协议提供的能力,不是某个套接字永远可用的承诺。下一次请求能否真正复用,还取决于消息边界是否完整、客户端连接池策略、服务端行为和连接当前状态;单看请求成功或一次响应较快,都不足以证明走了复用路径。 默认允许,不代表永不关闭 HTTP/1.1 默认允许持久连接,客户端通常不需要发送 Connection: keep-alive 才能启用它。Connection: close 则表示当前交换之后不再继续使用这条连接。HTTP/1.0 的持久连接规则不同,不能把旧版本的协商方式复制到所有现代请求里,也不能把“没有 keep-alive 字段”直接判成配置错误。 复用首先需要明确的消息结束位置。准确的 Content-Length、分块编码,或无正文响应规则,都可以界定边界。若响应只能依靠关闭连接来表示结束,自然无法留下这条连接继续使用。接收方必须先消费完当前消息,再把后续字节解释成下一条;只收到 HTTP 200 状态,还不能证明响应正文已经完整到达。 已建立的 HTTPS 连接被复用时,不会重新进行 TLS 握手;使用缓存的 TLS 会话建立另一条连接,则仍然需要握手。TCP keepalive 又是另一回事,它的空闲探测不会刷新 HTTP 消息边界,也不能否定应用主动关闭连接的决定。HTTP/2 同样可以复用连接,但并不使用 HTTP/1.x 的 Connection 字段语义。 分别管理连接池与一次操作 空闲套接字仍占用描述符、缓冲区和实现相关的状态。Go 的 MaxIdleConnsPerHost 控制每个主机保留多少空闲连接,MaxConnsPerHost 则同时限制正在拨号、活动和空闲的连接总数。前者不是总并发上限。可复用连接池由共享的 Transport 持有,连续调用若各自建立无关的 transport,就会丢掉原本可以复用的连接资源。 ...

十月 24, 2026 · 3 分钟 · XB Field Notes
以观察、判断和行动三个面板区分单用户缓存与共享缓存的存储范围。

Cache-Control:private 还是 shared

存储范围不是身份系统。Cache-Control: private 要求遵守协议的共享缓存不存储这条响应,却不会教会浏览器识别正文属于哪个应用账户。当一个人退出登录,另一个账户在同一浏览器中登录,然后访问相同网址时,这个区别就会变成实际的数据隔离问题,不能只凭响应头里出现 private 就判定安全。 有资格被缓存不代表能共享 不带字段名参数的 private 允许单用户缓存保存响应,但仍受其他缓存条件约束。浏览器是常见实现,并不是唯一实现。public 则明确允许某些原本可能受到限制的响应进入缓存,例如满足条件的带授权请求响应。已经符合缓存条件的普通响应不一定需要 public;出现 public 也不意味着所有缓存都必须保存它,存储许可和实际是否存储是不同事实。 不带参数的 no-cache 与 no-store 回答的是另一个问题。前者允许存储,但每次复用前必须成功验证;后者禁止私有和共享 HTTP 缓存存储及随后复用该响应。它们都不是加密、访问控制检查,也不承诺清除应用日志或用户之前保存的副本。本文讨论不带字段名参数的形式,不把实现支持较弱的按字段限定形式混进实验。 新鲜度还需要单独判断。错误的个人响应如果按共享键保存,即使只给五秒钟有效期,也可能有五秒钟把数据交给另一个人。缩短 max-age 不能修复身份边界。反过来,s-maxage 只是控制共享缓存的新鲜度,并不是 private 的另一种写法。先决定响应可在哪一层保存,再讨论可直接复用多久,才能避免用性能参数代替安全条件。 坏掉的样子 HTTP 的通用规则不会因为请求里有 Cookie 就自动禁止共享缓存。Authorization 确实有专门的共享缓存限制,但 public 等指令能够明确允许存储。为了提高命中率而添加 public,可能恰好撤掉应用之前依赖的限制。修改指令前应检查实际响应体和认证路径,不能只查看页面看起来是不是一份普通文档。 Vary 细化的是请求匹配条件,不授予存储许可。增加 Vary: Cookie 不会让 private 响应变得可以进入共享缓存。在私有缓存内,它可以区分不同 Cookie 字段,但这仍然只是字段匹配,不是授权判断。假设令牌文本没有变化,而账户权限已经被撤销,仅凭请求字段仍匹配,不能证明当前用户继续有权读取旧内容。 ...

十月 23, 2026 · 2 分钟 · XB Field Notes