当事件发生时,服务向你的 URL 发出的回调请求。

Webhooks 推送事件而非轮询

由于回调来自外部,投递本质是至少一次,因此接收端既要容忍重试,也要验证来源,而不是默认它就安全。 事件一到,回调即发 Webhook 把请求的方向整个倒了过来:不再是客户端四处轮询新数据,而是当事件发生时,由提供方往你预先注册的那个地址发一条 HTTP 请求。请求体是一小段自描述的变更说明,直接送去一个经常完全不同的服务器上的接口。因为呼叫由发布方主动发起,你作为接收方就不必一遍遍地重新连接,只需安静等新东西主动送上门,递送因此几乎瞬间可达。 重试与幂等是接收方的事 「重试与幂等」是接收方自己的本分。提供方在没收到你的及时回执时会再发一次,而一旦递达,同一条事件就可能重复出现,于是端点必须具备幂等性。接收方要把已经处理过的事件 id 记下来、把重复当空操作处理,而较重的动作最好丢进队列或后台去做,别活活堵在这条回执里同步傻等。把确认做得快、把去重与清理做得明确,再吵的网络也能变得无害,而不是一转成故障。 验证调用方而不只验 TLS 验证真伪不能只靠 TLS 这一层。由于请求来自公网回调,你必须在收到时取出签名、用共享密钥自己重新算一遍,并借助时间戳把「多旧的请求还接受」收进一个窗口。同时把回调固定在一个明确已知的地址、密钥按计划轮换,因为一个伪造的 webhook 可以做出与真回调完全一样的动作。于是最稳妥的形状是:快速回执、幂等处理、每次都验签名——默认不信任任何请求。 坏掉的样子 投递失败往往是安静发生的。如果发布方只重试两次且没有退避等待,一次短暂发布或冷启动就可能让一条订单支付通知永远丢失,而接收方根本不知道自己错过了什么。更糟的是接收端太慢:当验签、解析与写库全部堵在请求处理函数里同步执行,每次投递都会超时,发送方把每次尝试都记为失败,重试风暴又把同一批积压反复重放。正确做法是先快速回执、再把载荷推进队列、由后台工作线程慢慢消化,这样流量高峰只会表现为队列变长,而不会变成悄无声息的丢失。重试没有指数退避、也没有死信队列时,偶发抖动会被放大成系统性漏单,事后只能靠对账才能发现。 信任断裂的方式更加隐蔽。密钥轮转时如果只更新了发送方、却漏掉某个验签节点,那么所有真回调都会被拒收,在监控上看起来和伪造洪水一模一样。如果没有时间戳窗口和已处理事件编号记录,攻击者可以把截获的支付回调在几天后重放一次,让发货流程重复执行。调试接口若记录完整请求体,还可能泄露个人信息;而先返回成功、后做校验的处理器,则会让投毒事件看起来一切正常。应当把重试不足、时钟偏差、处理太慢和重复到达都当成常态,而不是边角情况。固定回调地址、按计划轮换密钥、拒绝过期与重放请求,都是同一道防线。 快速验证 把下面程序保存到临时目录的 webhook_verify_demo.py,运行 python3 webhook_verify_demo.py。它只用标准库,用 HMAC-SHA256 对带时间戳的消息做签名,并用断言检查五种行为。不需要网络,也不依赖第三方包,不会读写真实文件,适合在本地快速理解验签、容差与去重的配合。 import hashlib import hmac import time SECRET = b"whsec_test_secret_1" OLD_SECRET = b"whsec_old_secret_0" TOLERANCE_SECONDS = 300 seen_ids: set[str] = set() def sign(event_id: str, timestamp: int, payload: bytes, key: bytes = SECRET) -> str: msg = f"{event_id}.{timestamp}.".encode() + payload return hmac.new(key, msg, hashlib.sha256).hexdigest() def verify(event_id: str, timestamp: int, payload: bytes, signature: str, now: int | None = None) -> bool: if now is None: now = int(time.time()) if abs(now - timestamp) > TOLERANCE_SECONDS: return False if event_id in seen_ids: return False expected = sign(event_id, timestamp, payload) if not hmac.compare_digest(expected, signature): return False seen_ids.add(event_id) return True def main() -> None: now = int(time.time()) payload = b'{"type":"order.paid","order":"42"}' fresh = sign("evt_1", now, payload) assert verify("evt_1", now, payload, fresh, now) is True tampered = b'{"type":"order.paid","order":"43"}' assert verify("evt_2", now, tampered, fresh, now) is False stale_sig = sign("evt_3", now - 3600, payload) assert verify("evt_3", now - 3600, payload, stale_sig, now) is False assert verify("evt_1", now, payload, fresh, now) is False rotated_sig = sign("evt_5", now, payload, key=OLD_SECRET) assert verify("evt_5", now, payload, rotated_sig, now) is False print("PASS: fresh accepted; tampered, stale, replay, rotated key rejected") if __name__ == "__main__": main() 程序只接受一次新鲜投递,其余四种坏情况一律拒绝:正文被篡改导致签名对不上、时间戳早了一小时而超出五分钟容差、事件编号重复使用之前已接受的签名、用已轮转掉的老密钥签发。每种拒绝都返回否且不记入已见集合,因此只有第一次投递会进入记录。同样,超出容差的未来时间戳也会被拒绝,这样截获的回调就不会长期有效,重放攻击也就失去立足之地。 ...

十一月 18, 2027 · 2 分钟 · XB Field Notes
被保持的连接会把新工作导向另一端,权重则改变节点之间的比较。

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

最少连接均衡根据活动连接数选择节点,并遵循具体实现的权重和平局规则。在 nginx 中,least_conn 考虑服务器权重,对于同样合适的节点使用加权轮询。这有助于处理占用连接时间不同的请求,但仍然只是连接维度的测量,不直接知道 CPU、内存压力、排队任务或数据库竞争。算法是否合适,取决于该信号与实际瓶颈有多大关联。 坏掉的样子 把选中的节点称为“最空的服务器”,夸大了信号含义。一条连接可能承载昂贵操作,另一条只是便宜的等待。HTTP 响应关闭后,后台工作还可能继续;多路复用协议也可能把多个操作放在一条连接上。因此,连接少并不能证明后端有足够余量接收下一个昂贵请求。需要保护哪种资源,必须先明确,而不能让算法名称替代容量分析。 计数还是均衡器观察到的上游状态,不是对后端全部客户端的全知统计。通过另一个代理或内部直连到来的工作,可能不在当前视野中。在 nginx 中,worker 范围和上游共享区域配置影响哪些运行状态共享;独立均衡器也不会自动合并观察,形成一个全局最少连接决定。忽略计数范围,会让局部正确的选择被误解成整个集群的最优分配。 权重使只比较原始计数不够准确。权重二的节点可以合理承载比权重一节点更多的活动连接。修改权重表达一种容量假设,并没有测量该假设是否真实。空闲 keepalive 缓存又是另一种数量,不应与这里参与选择的活动连接混淆。应分别识别正在占用的交换、为复用保留的空闲连接,以及真正消耗计算资源的工作。 快速验证 将脚本保存为 check-least-connections.py,准备 Python、Docker 和固定镜像后执行。记录环境使用单个 nginx 1.28.0 worker,没有配置上游 keepalive 缓存,并连接不同端口上的两个 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
观察、决策、行动三个方框,旁边强调逐段分帧,并在提交前核对输入总量。

multipart 边界把正文切成几段

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

十一月 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
把一条请求藏进另一条里、再由后端放出的攻击。

请求走私把代理链路带偏

一条 HTTP/1.1 请求并不会自己说明它在哪里结束。它的边界是算出来的——靠 Content-Length,或者靠 chunked 传输编码——而链路上的每一跳都在各自独立地算。请求走私,就是两跳从同一串字节里算出了不同答案时发生的事。 RFC 9112 后来把规则写明了:Transfer-Encoding 压过 Content-Length;服务端可以拒绝同时带两者的报文;无论怎么选,之后都必须关闭连接。这在纸面上堵住了缝。真实的链路里仍然有早于这条规则的一跳、下游只会说 HTTP/1.0 的一跳,以及一个在入口终结 HTTP/2、再向内重新按 HTTP/1.1 组帧的边缘层。攻击就住在这些分歧里。 两个解析器,一条消息边界 组帧规则是有优先级的。若存在 Transfer-Encoding 且 chunked 是最后一个编码,报文体一直读到那个长度为 0 的终止块为止;否则一个合法的 Content-Length 给出字节数;两者都没有的请求没有报文体。当两个头同时出现,规范说 Transfer-Encoding 胜出——但只读 Content-Length 的那一跳会提前停下、把余下的字节转发出去,而那些余下的字节就是攻击。 实验把两者放在真实的 socket 上:前端按 Content-Length 组帧、原样转发字节,后端按 Transfer-Encoding 组帧。攻击者的 POST 同时声明 Content-Length: 57 与 Transfer-Encoding: chunked,报文体是 0\r\n\r\n 再加一条完整的请求。对前端来说那是一个 57 字节的报文体;对后端来说,chunked 报文体是零字节,后面那条请求是一条新报文。一条请求变成了两条,而前端从未见过后一条。 ...

十月 19, 2027 · 1 分钟 · XB Field Notes
一个缓存先与原服务重新校验,再决定放行旧条目或替换。

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

源站文件改了,并不意味着每个已经保存响应的缓存都会收到通知。失效操作控制的是哪些已存副本还可以继续复用,而不只是更新磁盘上的内容。可靠的发布要区分到期、重新验证、定点移除以及使用新 URL:它们解决不同范围的问题,也没有哪一种能远程擦掉读者已经下载的所有字节。 到期不是广播通知 源站发生变化后,一份仍然新鲜的响应可能继续被合法使用。新鲜度由缓存寿命和当前年龄共同决定,年龄计算涉及缓存元数据与经过的时间,并非只从源站的 Date 字段开始倒计时。到期改变的是直接复用时必须满足的条件,既不保证存储介质立即删除条目,也不会让无人访问的缓存主动联系源站。 验证器同样不是内容变化的推送通道。请求需要重新验证时,正确的 ETag 才能帮助源站确认表示未变,或者返回替代正文。浏览器手上的响应还新鲜、没有理由回源时,新增验证器并不能强迫它询问。把源站策略改成 no-cache 或 no-store,也无法追溯修改浏览器之前已经收到的缓存指令。 先确定失效覆盖范围 RFC 9111 要求:缓存收到不安全方法对应的成功或重定向响应时,必须使目标 URI 的已存响应失效。这个义务只涉及该请求实际经过的缓存,不是向整个网络广播。通过另一条接口修改数据库,不会自动覆盖所有派生页面、查询参数变体、不同语言表示和搜索列表,应用需要识别这些真实依赖。 CDN 的清理接口也只管理服务商控制的副本,无法直接清空独立浏览器缓存。应按服务商的实际契约确认受影响的主机、路径、变体以及缓存标签。先让替代内容在源站可用,再发起清理、跟踪完成状态,并比较实际响应正文和缓存状态。接口已经接受清理请求,并不能单独证明它已传播,也不能证明源站已经能返回预期的新字节。 坏掉的样子 如果源站尚未更新就先清理边缘,下一次未命中可能立即把旧内容重新填回去。已经在途的回填也可能和失效操作竞争,具体结果取决于缓存实现。反复执行全站清理不能证明这些竞争已经解决,反而会让更多对象变冷、增加回源负担。应准确圈定受影响对象,并评估清理之后源站需要承担的请求量。 对于静态资产,应先发布带内容指纹的新文件名,再切换 HTML 或清单中的引用。在旧文档仍可能引用旧资产期间,旧文件也应继续可用。新 URL 可以避开旧缓存条目,但前提是读者先拿到更新后的引用文档。只生成一个新文件名,却不更新文档,或者文档本身仍被直接复用,都不会使现有读者自动转向新版本。 涉及机密信息删除时,边界还要更严格。删除源站文件或清理 CDN 不能撤销已经下载的字节,缓存策略也不等于访问授权。应从源头防止敏感响应进入不合适的缓存,而不是泄露后依赖清理挽救。对于紧急修复,要先定义“可见”的对象:新访问者、已经打开的标签页、所有曾下载的副本,不能共享同一种到达保证。 快速验证 准备 Python 3.10 或更新版本及 POSIX shell;实际运行使用 Python 3.14.4、CacheControl 0.14.4 和 Requests 2.34.2。安装下列固定版本后,用 "$invalidation_lab/venv/bin/python" 执行 Python 代码。程序在自动分配的回环端口启动一个 HTTP 服务,并建立两个互不共享存储的内存私有缓存;关闭环境代理,不访问外部服务器,最后关闭客户端、线程和服务。只有依赖安装需要联网。 ...

八月 5, 2027 · 2 分钟 · XB Field Notes
一项只放行可信跨源读者的头部约定。

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 页面诚实地作答

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