自定义 HMAC 格式按顺序连接 event_id、点号、timestamp、点号及不变的原始正文字节。

Webhooks 推送事件而非轮询

Webhook 让提供方在状态变化时通知已注册的接收端。它改变了谁主动发起 HTTP 请求,但单凭这个方向,不能保证必达、即时、有序或只处理一次。真正的运维契约来自提供方的投递规则,以及接收方如何确认和保存工作。理解这层区别,才能知道故障后应该重投、补查还是人工处理。 投递策略由提供方定义 不同服务并不相同。GitHub 不会自动重新投递失败的 webhook,需要合适的重投或对账流程。Stripe 在线上模式下会按指数退避重试最长三天,沙箱的规则则不同。两者都不意味着接收端可以无限期离线而不丢工作。应记录真实重试窗口,以及投递耗尽后由谁发现、如何补回,不能仅凭“用了 webhook”就宣称至少一次保证已经成立。 事件也未必包含完整的当前对象。载荷可能是历史快照,或只提供需要另行查询的标识。事件可能乱序,不同事件编号也可能涉及同一个业务变化。执行不可逆操作之前,应决定是直接应用事件、获取当前状态,还是核对版本后再处理。投递去重与业务操作幂等解决相关但不同的问题,保存一个事件编号不自动等于发货只能发生一次。 先认证、可靠保存,再确认 验签必须遵循提供方的准确格式;要求原始正文时,就使用收到的原始字节。把 JSON 解析后重新编码,可能改变空白或字段顺序,让真实签名失效。TLS 保护到端点的传输,普通服务端认证的 HTTPS 并不证明请求一定来自指定提供方。签名密钥不能写入日志,比较应采用适当方式,轮换也必须遵守提供方流程。 时间戳保护同样是特定协议能力。Stripe 会签入投递时间戳,其库可以检查容差;GitHub 文档定义的正文 HMAC 没有同样的已签名时间戳,因此不能要求所有服务都提供对应头。一次重试可能生成新的投递签名,却仍然代表同一个旧事件。新鲜度按协议检查,重复性按稳定事件身份判断,两者不能相互替代。 新工作返回成功之前,应先可靠地保存到队列或事务性收件表。如果先确认再入队,中间崩溃就会造成发送方停止重试、接收方却丢掉事件的空窗。请求路径要短,但不能为追求短而跳过认证或可靠交接。对于已经保存的合法重复事件,可以返回提供方认可的成功响应,并避免重复副作用;它不应该仅因重复就被当作恶意请求。 坏掉的样子 内存里的已见集合会在重启后消失,也不能协调多个 worker。先记录编号再执行副作用,如果没有可恢复的待处理状态,可能丢工作;先执行副作用再记编号,则可能在崩溃后重复。能使用数据库唯一约束和事务的地方应使用它们,下游操作也需要稳定操作标识支持安全重试。一笔数据库事务本身,不能原子覆盖无关外部系统的付款或发货请求。 密钥轮换只更新部分验签实例,会让真请求间歇性失败。只在计划中的过渡期允许新旧密钥重叠,监控拒绝原因,并按政策移除退役密钥。还要限制正文大小和队列增长。事件虽然都快速收到二百响应,却一直积压不处理,同样是业务故障;投递健康与业务完成必须分别观察,不能让好看的 HTTP 成功率掩盖停滞。 Webhook 签名投递:机制与证据边界。 快速验证 保存为 webhook_verify_demo.py,运行 python3 webhook_verify_demo.py。程序使用明确自定义的 HMAC 格式、公开测试密钥和固定时钟,只依赖标准库与内存状态。它不会连接支付服务,也不是可以直接上线的接收器。这里故意把签名、时效与重复判定的分支隔离,方便检查每一步实际证明了什么。 import hashlib import hmac import re SECRET = b"public-demo-key-not-a-real-credential" OLD_SECRET = b"public-retired-demo-key" TOLERANCE_SECONDS = 300 seen_ids = set() def sign(event_id, timestamp, payload, key=SECRET): message = f"{event_id}.{timestamp}.".encode("ascii") + payload return hmac.new(key, message, hashlib.sha256).hexdigest() def accept(event_id, timestamp, payload, signature, now): # This is an invented wire format, not a Stripe or GitHub verifier. if not re.fullmatch(r"evt_[0-9]+", event_id): return "rejected" if abs(now - timestamp) > TOLERANCE_SECONDS: return "rejected" if not re.fullmatch(r"[0-9a-f]{64}", signature): return "rejected" expected = sign(event_id, timestamp, payload) if not hmac.compare_digest(expected, signature): return "rejected" if event_id in seen_ids: return "duplicate" seen_ids.add(event_id) # Process-local demonstration, NOT durable acceptance. return "accepted" now = 1_800_000_000 payload = b'{"type":"order.paid","order":"42"}' fresh = sign("evt_1", now, payload) assert accept("evt_1", now, payload, fresh, now) == "accepted" assert accept("evt_1", now, payload, fresh, now) == "duplicate" # Authenticate even a claimed duplicate before calling it harmless. assert accept("evt_1", now, payload, "0" * 64, now) == "rejected" # Change only the body: id and timestamp still match the signed message. second = sign("evt_2", now, payload) assert accept("evt_2", now, payload + b" ", second, now) == "rejected" # Change only the id, leaving body and timestamp intact. assert accept("evt_3", now, payload, second, now) == "rejected" for timestamp in (now - 301, now + 301): assert accept("evt_4", timestamp, payload, sign("evt_4", timestamp, payload), now) == "rejected" assert accept("evt_5", now, payload, sign("evt_5", now, payload, OLD_SECRET), now) == "rejected" assert accept("evt_6", now, payload, "not-a-digest", now) == "rejected" assert seen_ids == {"evt_1"} assert accept("evt_2", now, payload, second, now) == "accepted" assert seen_ids == {"evt_1", "evt_2"} print("PASS: authenticated duplicate, independent body/id tampering, stale/future, retired key, malformed digest") 2026-10-19,两份完整中英文围栏均在 Python 3.12.14 下原样运行,断言启用,退出码为零,打印上述 PASS 行且标准错误为空。不要使用 -O 或 PYTHONOPTIMIZE;否则 accept 调用被省略,却仍打印 PASS。原样围栏覆盖真实重复、伪造重复、分别篡改正文与编号、正负三百零一秒、退役密钥及格式错误摘要。另行补充的测试接受包含边界的正负三百秒,并拒绝七种非法事件编号;这些补充用例不在上面的围栏里。每种语言另有五份故意破坏签名、正文、编号、新鲜度或去重保护的变体,均在打印 PASS 前触发断言失败。这些负对照说明测试会发现指定破坏,并不是完整安全证明。 ...

十一月 18, 2027 · 2 分钟 · XB Field Notes
权重二节点含两个活动连接圆,权重一节点含一个;相同卡片大小不暗示硬件或 CPU 容量。

最少连接衡量连接占用,不代表所有负载

最少连接均衡根据活动连接数选择节点,并遵循具体实现的权重和平局规则。在 nginx 中,least_conn 考虑服务器权重,对于同样合适的节点使用加权轮询。这有助于处理占用连接时间不同的请求,但仍然只是连接维度的测量,不直接知道 CPU、内存压力、排队任务或数据库竞争。算法是否合适,取决于该信号与实际瓶颈有多大关联。 坏掉的样子 把选中的节点称为“最空的服务器”,夸大了信号含义。一条连接可能承载昂贵操作,另一条只是便宜的等待。HTTP 响应关闭后,后台工作还可能继续;多路复用协议也可能把多个操作放在一条连接上。因此,连接少并不能证明后端有足够余量接收下一个昂贵请求。需要保护哪种资源,必须先明确,而不能让算法名称替代容量分析。 计数还是均衡器观察到的上游状态,不是对后端全部客户端的全知统计。通过另一个代理或内部直连到来的工作,可能不在当前视野中。在 nginx 中,worker 范围和上游共享区域配置影响哪些运行状态共享;独立均衡器也不会自动合并观察,形成一个全局最少连接决定。忽略计数范围,会让局部正确的选择被误解成整个集群的最优分配。 权重使只比较原始计数不够准确。权重二的节点可以合理承载比权重一节点更多的活动连接。修改权重表达一种容量假设,并没有测量该假设是否真实。空闲 keepalive 缓存又是另一种数量,不应与这里参与选择的活动连接混淆。应分别识别正在占用的交换、为复用保留的空闲连接,以及真正消耗计算资源的工作。 least_conn 观察加权活动连接,不代表全局资源负载。保持请求须在自身五秒读取截止前释放,当前执行仍待验证。 快速验证 下文结果归属于历史文章报告,本轮没有重跑;保留的验证文件中未找到原始集成运行日志。运行需备好本地固定镜像并启用 Python 断言;清理仅尽力执行,不能据此保证所有资源已删除。 将脚本保存为 check-least-connections.py,准备 Python、Docker 和固定镜像后执行。记录环境使用单个 nginx 1.28.0 worker,没有配置上游 keepalive 缓存,并连接不同端口上的两个 HTTP 节点。后端提供受控响应开关与活动计数,让保持中的工作可以直接观察,不依赖猜测某个睡眠时间是否足够长;不过每个保持中的请求仍须在自身五秒 HTTP 读取超时前获得释放: 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 from urllib.parse import urlsplit,parse_qs import itertools,json,threading lock=threading.Lock();ids=itertools.count(1);counts={'A':0,'B':0};active={'A':0,'B':0};faults={'A':200,'B':200};gates={} class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def setup(self):super().setup();self.ident=next(ids) def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path);label=self.server.label;code=200 if label=='control': query=parse_qs(parsed.query) with lock: if parsed.path=='/fail':faults[query['peer'][0]]=int(query['status'][0]) if parsed.path=='/release':gates[query['path'][0]].set() data={'counts':dict(counts),'active':dict(active),'faults':dict(faults),'held':list(gates)} else: with lock: counts[label]+=1;active[label]+=1;code=faults[label] if parsed.path.startswith('/hold/'):gates[parsed.path]=threading.Event() if parsed.path.startswith('/hold/'):assert gates[parsed.path].wait(20) data={'peer':label,'path':self.path,'connection':self.ident} body=json.dumps(data,sort_keys=True).encode();self.send_response(code) self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body))) self.end_headers();self.wfile.write(body) if label!='control': with lock:active[label]-=1 for port,label in [(8081,'A'),(8082,'B'),(8083,'control')]: server=ThreadingHTTPServer(('0.0.0.0',port),Handler);server.label=label threading.Thread(target=server.serve_forever,daemon=True).start() threading.Event().wait() ''' from concurrent.futures import ThreadPoolExecutor extra=''' upstream least { least_conn; server backend:8081; server backend:8082; } upstream weighted_least { least_conn; server backend:8081 weight=2; server backend:8082; } ''' locations=''' location /least/ { proxy_pass http://least/; } location /weighted/ { proxy_pass http://weighted_least/; } location /control/ { proxy_pass http://backend:8083/; } ''' with lab(config(locations,extra),backend=backend) as (_,request): def get(path): status,_,body=request(path);assert status==200;return json.loads(body) def state():return get('/control/state') results={} with ThreadPoolExecutor(max_workers=3) as pool: held=pool.submit(get,'/least/hold/first');wait(lambda:sum(state()['active'].values())==1) occupied=next(peer for peer,value in state()['active'].items() if value==1) quick=[get('/least/quick')['peer'] for _ in range(6)] assert all(peer!=occupied for peer in quick),quick get('/control/release?path=/hold/first');assert held.result()['peer']==occupied wait(lambda:sum(state()['active'].values())==0) results['held_peer']=occupied;results['quick_while_held']=quick futures=[] for index in range(3): futures.append(pool.submit(get,'/weighted/hold/w'+str(index))) wait(lambda:sum(state()['active'].values())==index+1) snapshot=state()['active'];assert snapshot=={'A':2,'B':1},snapshot for index in range(3):get('/control/release?path=/hold/w'+str(index)) results['weighted_active']=snapshot;results['weighted_completed']=[f.result()['peer'] for f in futures] wait(lambda:sum(state()['active'].values())==0) print(json.dumps(results,sort_keys=True)) 第一个请求进入同权最少连接组,并在某个后端处理器中等待。控制端先观察恰好一个活动请求,并识别被占用节点,然后才发送其他请求。初始平局究竟由哪台节点胜出不是主要断言;实验从后端真实状态发现该身份,不把某个偶然的初始顺序写成算法必须遵守的规则。 ...

十一月 11, 2027 · 2 分钟 · XB Field Notes
单个正文依次包含 tag 的 first、second 和二进制文件,由边界线分隔;双结束线之后还有尾声。分框与验证是独立步骤。

multipart 边界把正文切成几段

multipart/form-data 是一种由多个独立 part 组成的 HTTP 表示。一条请求可以同时携带文本字段和二进制文件,但每一段仍要有自己的头部和明确的分隔。客户端选择 boundary 参数,把它写入 Content-Type,再在正文中写出匹配的分隔行。边界只负责分框,不负责判断文件是否安全,也不会替应用决定哪些字段可以提交。 保留线序与原始字节。尾声计入 HTTP 正文预算;完整验证之后再提交。 边界负责把各段框起来 每条分隔线以两个连字符和声明的边界值开头,结束分隔线还要再加两个连字符。multipart 的 boundary 参数是必需的,长度必须为一到七十个允许字符,末尾不能有空白;必要时参数要用引号包住。RFC 7578 要求分隔符不能出现在任何封装 part 中。使用较长、不可预测的值可以把偶然撞值的概率降得很低,但随机并不等于数学上绝对不相撞,生成器仍要遵守语法。 第一个分隔符之前是 preamble,结束分隔符之后是 epilogue。MIME 读取器会忽略这两处内容,因此应用不能把尾声误当成另一个表单字段;不过它们仍是 HTTP 请求正文的字节,应计入整条请求的预算。multipart 解析器返回 EOF,不代表 HTTP 正文已经全部消费完,服务器还要决定怎样处理剩余内容。 ...

十一月 10, 2027 · 4 分钟 · XB Field Notes
单向响应携带完整事件块;另一次请求可以发送保留游标,续传仍依赖获授权且保留的历史。

Server-Sent Events 单向推送

Server-Sent Events 在一条持续的 HTTP 响应中传递服务端到客户端的更新。它不把 HTTP 变成双向套接字,自动重连也不会让投递自动具备持久性。事件分帧、连接寿命、历史保留和业务确认,是四个需要分别定义的契约,不能因为都发生在一条流里,就用一个“推送成功”概括。 一份响应包含有边界的文本事件 SSE 响应使用 text/event-stream 类型和 UTF-8 编码。字段按行组织,空行结束一个块;同一事件中的多行 data 会用换行符拼接。以冒号开头的注释行不作为事件数据处理,因此可以承载心跳,而不触发一次业务消息。缺少正确的空行边界,字节到达也可能暂时看不到事件。 event 字段指定事件类型;没有该字段时,默认类型是 message。命名为 update 的事件需要注册 update 监听器,不能只依赖 onmessage。流在事件尚未结束时到达 EOF,也不会仅仅因为连接关闭就派发这份不完整事件。因此,网络层确实收到字节,不等于事件分帧和监听器选择已经正确。 id 字段是字符串,不是协议强制要求单调递增的整数。应用可以选择序号,但排序方式与保留策略仍要自己定义。空 id 会重置保留的编号,而不是确认此前所有业务已经完成。不要把一个用于续传定位的字段,直接解释成数据库提交记录或可靠消息队列的消费确认。 重连仍然依赖历史记录 浏览器重连时可以通过 Last-Event-ID 发送保留的事件编号。这个值是游标提示,不是事务确认。服务端需要一份经过权限约束、仍然保留的历史,以及“从游标之后继续”的明确规则。重新建立连接,无法恢复从未记录的事件,也无法凭空找回已经过期删除的内容。 应定义游标失效后的行为,例如重新获取快照、拒绝续传,或遵循其他明确业务约定。悄悄从最新事件继续,会隐藏中间的缺口。消费者可能还需要去重;收到一个事件,并不能证明后续动作已经恰好执行一次,也不能保证重试后不会再次触发相同副作用。 EventSource 会对可恢复的断连执行重连,HTTP 204 则明确要求停止重连;其他终止条件也存在,并非每种错误都会无限重试。retry 字段表达以毫秒计的重连间隔,但重连调度不是业务投递截止时间,不能拿它充当消息在多少毫秒内必达的承诺。 坏掉的样子 处理函数或中间代理可能缓冲输出,让应用已经生成的事件迟迟不到客户端。源站需要刷新输出,还要沿真实代理路径观察交付。心跳能够帮助维持某些空闲计时边界,却不能推翻固定的请求总期限。订阅队列也要有界,并规定消费者跟不上时是断开、丢弃还是要求重新同步。 认证和 CORS 规则仍然适用。原生 EventSource 提供凭据模式,而不是任意请求头配置,不能假设它接受与 fetch 完全相同的认证参数。多个页面同时订阅时,还要考虑 HTTP/1.x 连接上限与 HTTP/2 流上限;多路复用不会取消连接级资源预算,也不会替服务端解决慢消费者问题。 ...

十一月 9, 2027 · 3 分钟 · XB Field Notes
两个相同正文条有不同红色结束标记:CL 位于完整正文后,TE 位于零块段后并留下合成后续请求。

请求走私把代理链路带偏

请求走私发生在不同组件对 HTTP 消息边界理解不一致,而且攻击者利用了这种分歧的时候。在代理链中,被前端当作一个请求正文的字节,可能被后端当成另一个请求。关键证据是解析和转发结果不一致,而不只是抓包中出现两条 HTTP 状态行。理解问题时要把字节归属、连接复用和实际影响分开,不能直接用响应数量代替边界分析。 同一字节流,不相容的边界 HTTP/1.1 的组帧有明确规则。合法 Content-Length 可以指定正文长度;chunked 传输编码通过终止块和尾部字段结束。两种机制都没有的请求不包含正文。两种字段同时存在时,Transfer-Encoding 优先,但这是一种可疑组合,需要专门处理,而不是随意接受后继续复用连接。 RFC 9112 允许服务器拒绝同时携带两个字段的请求,或者仅按 Transfer-Encoding 处理;无论如何,响应后都必须关闭连接。中间组件若选择继续转发,必须先移除收到的 Content-Length,并在转发前处理 Transfer-Encoding。入口直接拒绝双重组帧是有用的严格策略,但不能把它说成标准描述的唯一合法行为。 CL.TE 表示前端使用 Content-Length、后端使用 Transfer-Encoding;TE.CL 则相反。其他分歧可能来自冲突长度,或对传输编码语法的不同处理。这些名称描述的是解析器实际行为,不代表报文有两种同样正确的解释。面对不合法输入,不能让每一跳各猜一次,再期待最终边界碰巧一致。 失步可以产生不同影响 完整的隐藏请求可能马上被后端处理,并不必然等待下一位受害者。另一些攻击留下不完整前缀,再与后续流量组合。响应队列污染只是可能后果之一;绕过路由过滤、缓存污染和未授权的后端请求,也可能在没有相同响应错配步骤时造成影响。实际后果取决于连接复用、路由、认证和应用行为,不能用一个实验情节概括全部攻击。 发现额外的后端请求时,应追溯具体字节和连接。仅靠数量不足,因为重试、健康检查、缓存和异步日志都可能让各层记录合理地不一致。后端出现没有对应边缘记录的请求,是调查线索,不是自动确证。应结合关联数据,并在可控实验中使用可辨认的合成请求,排除正常机制之后再判断边界是否被突破。 坏掉的样子 表面的请求头过滤可能漏掉协议转换时产生的歧义。HTTP/2 使用帧,不采用 HTTP/1.1 的 chunked 分界,但降级到 HTTP/1.1 又引入一次必须正确完成的序列化。对 HTTP/2 请求,Content-Length 必须与内容长度一致;无内容响应另有规则,因此不能说换成 HTTP/2 就不再存在任何相关长度检查。 端到端 HTTP/2 能避免部分降级特有的问题,却不是普遍免疫证明。应检查协议转换、统一拒绝畸形消息,并避免在不可恢复的组帧错误后继续复用连接。严格入口可以挡住某条具体攻击路径,即使另一组件仍然宽松;应按真实可达路径评估,不能断言只修一跳永远没有作用,也不能因为某一入口表现正确就忽略其他入口。 ...

十月 19, 2027 · 2 分钟 · XB Field Notes
源站 /manual.txt 已改为 B。缓存 0 的精确条目被移除,下一次请求获取 B;独立的缓存 1 仍保留新鲜的 A 并直接复用。一次定点移除不会让两个缓存同步。

缓存失效是主动推进,不是祈祷

源站文件改了,并不意味着每个已经保存响应的缓存都会收到通知。失效操作控制的是哪些已存副本还可以继续复用,而不只是更新磁盘上的内容。可靠的发布要区分到期、重新验证、定点移除以及使用新 URL:它们解决不同范围的问题,也没有哪一种能远程擦掉读者已经下载的所有字节。 两个独立私有缓存让移除范围变得具体:改变一个条目,另一个仍可复用。发布并请求新 URL 会建立独立条目,不会擦除旧 URL,也不会自动更新所有读者。 到期不是广播通知 源站发生变化后,一份仍然新鲜的响应可能继续被合法使用。新鲜度由缓存寿命和当前年龄共同决定,年龄计算涉及缓存元数据与经过的时间,并非只从源站的 Date 字段开始倒计时。到期改变的是直接复用时必须满足的条件,既不保证存储介质立即删除条目,也不会让无人访问的缓存主动联系源站。 验证器同样不是内容变化的推送通道。请求需要重新验证时,正确的 ETag 才能帮助源站确认表示未变,或者返回替代正文。浏览器手上的响应还新鲜、没有理由回源时,新增验证器并不能强迫它询问。把源站策略改成 no-cache 或 no-store,也无法追溯修改浏览器之前已经收到的缓存指令。 先确定失效覆盖范围 RFC 9111 要求:缓存收到不安全方法对应的成功或重定向响应时,必须使目标 URI 的已存响应失效。这个义务只涉及该请求实际经过的缓存,不是向整个网络广播。通过另一条接口修改数据库,不会自动覆盖所有派生页面、查询参数变体、不同语言表示和搜索列表,应用需要识别这些真实依赖。 ...

八月 5, 2027 · 2 分钟 · XB Field Notes
两条 POST 路径:JSON 预检被拒绝后不发送 POST;符合条件的 text/plain POST 可以发出,但响应仍可能不可读。

CORS 预检在浏览器发送请求之前检查许可

CORS 预检询问浏览器是否可以发送某个具体的跨源请求,内容包括发起页面的源、计划使用的方法和相关请求头名称。浏览器会先判断预检响应是否允许,再发送实际请求;已有有效预检缓存时,则可能省去这次询问。CORS 同时约束脚本能否读取实际响应。发送许可与响应读取是相互关联的两次检查,但任何一次都不能代替业务身份认证和权限控制。 预检由请求形式触发 源由协议、主机和端口组成,任一部分变化都可能使请求跨源。浏览器不会猜测服务器是否修改数据,再据此决定要不要预检。符合条件的 text/plain 类型 POST 可以直接发送;反而一个仅仅读取数据、却带有 Authorization 头的请求通常需要预检。方法、头部取值以及 Fetch 定义的其他条件共同决定请求是否属于安全列出的范围,“读取不用、写入必用”不是正确规则。 JSON POST 通常需要预检,因为 application/json 不是安全列出的内容类型。浏览器先向目标发出 OPTIONS,携带 Origin、值为 POST 的 Access-Control-Request-Method,以及包含 content-type 的 Access-Control-Request-Headers。这里发送的是未来头部的名称,不是其中可能包含的凭据值。服务器需要允许对应源、方法和相关字段,浏览器才会继续发送这一次实际请求。 两个响应都需要正确配置 受限接口应按完整源进行精确白名单匹配,并在 Access-Control-Allow-Origin 中返回一个允许的源。不能返回逗号分隔的源列表,也不能无条件照抄任意输入。公开给所有来源、且请求不带凭据的资源可以使用星号;携带凭据的访问则需要明确的源和 Access-Control-Allow-Credentials: true。浏览器自己的 Cookie 限制仍然独立生效,CORS 放行不会自动突破这些限制。 实际响应也必须包含适用的 CORS 字段,希望脚本能读取的错误响应同样如此。预检通过,并不能授权脚本读取一个后来缺少许可字段的响应。若返回内容或头部依赖 Origin,还应添加 Vary: Origin,让共享缓存区分不同来源的变体。Access-Control-Max-Age 控制专用预检缓存,并受到浏览器上限约束;不能仅靠普通 HTTP 缓存规则解释为什么某次请求没看到 OPTIONS。 ...

七月 23, 2027 · 2 分钟 · XB Field Notes
一份响应并列展示 404 状态、页面未找到说明与首页链接;相同正文搭配 200 仍然错误。

404 页面诚实地作答

404 响应有两类读者:软件需要准确的状态码,访客需要一条能继续前进的路径。错误页即使设计精美,也不能用正文弥补错误的 200;状态码正确,也不代表页面可以难以阅读或没有导航。应当同时保证协议和内容诚实,而不是把一个缺失地址误写成成功,或者把正常的缺失当成整个业务故障。 在渲染正文前确定状态 RFC 9110 对 404 的定义是:源站没有找到目标资源的当前表示,或者不愿透露它确实存在。这并不说明缺失是暂时还是永久。如果服务端知道资源很可能已永久删除,可以使用 410;如果地址确实迁移,就把它重定向到对应的新资源。不要把所有未知地址都送往首页,因为这样既掩盖坏链,也会把访客带到与原意无关的地方。 依赖故障和资源缺失不是一回事。数据库连接失败,导致应用无法判断记录是否存在时,应保留对应的服务端错误语义,不能把所有异常统一变成 404。另一方面,权限策略有时会故意用 404 隐藏资源存在性;这种策略要在契约中说明,而不能在错误正文里泄露对象编号、账号归属或权限细节。相同状态码的选择,必须能由业务事实和安全策略解释。 Google 可能把带错误内容的成功响应识别为 soft 404。返回 200 不保证页面被收录,返回 404 也不保证搜索结果立刻消失。工程上可以承诺的是准确状态、明确内容和正确链接,不能把搜索引擎的后续抓取与索引处理说成即时、确定的操作。修复坏链以后,还需要观察实际抓取和访问结果,而不是仅凭模板名称宣布问题消失。 让错误正文独立且安全 页面用一句简短说明、一个真实首页或目录链接帮助读者恢复方向即可。静态站没有必要为了错误页新增搜索表单、第三方组件或数据库查询;本地静态资源和简单模板更容易在故障时继续工作。API 则应返回已经约定的机器可读错误格式,并保留 404,不能返回一份状态为成功的 HTML 让调用方误判。 状态必须先于正文写入。在 Go 中,如果还没有调用 WriteHeader,第一次写正文会隐式选择 200,后面再试图设置 404 就已经太晚。HEAD 应保持与 GET 相同的状态和适用的表示元数据,但不能传输正文。直接打开某个错误模板文件,只证明那个文件能显示,并不证明真实的缺失请求会进入正确处理器。 坏掉的样子 把所有路径重写到首页,会让缺失文章、脚本和样式表都得到假成功;反向代理也可能把源站的 404 改成自己的 200 错误页。另一个常见问题是处理器先输出页面框架,随后才发现文件不存在。排查时应在实际入口请求一个确定不存在的地址,先禁止自动跟随重定向,再对照正常地址的第一份响应,而不是只看浏览器最终显示了什么。 ...

七月 3, 2027 · 3 分钟 · XB Field Notes