CORS 预检用于询问浏览器能否发送某种具体形态的跨源请求。它是一条 OPTIONS 请求,携带计划采用的方法及相关请求头名称,浏览器检查服务端答复后才决定是否发送实际请求。这与实际响应到达之后,检查应用脚本能否读取响应,是两个不同阶段。它们都可能失败,但对服务端是否执行过业务操作的影响不同,诊断时必须先确定错误发生在哪个边界。
触发预检的条件并不是简单的“带有凭据”或“会产生副作用”。在其他条件同时满足时,安全列表中的 GET、HEAD 或 POST 可以不预检。PUT、应用自己设置的 Authorization 等请求头通常需要预检,JSON 内容类型也不属于安全列表里的内容类型。因此,即使目标地址和业务操作没变,只改变请求形态,也可能新增一次 OPTIONS 交互。不能把这种变化误认为服务端突然多执行了一次业务请求。
把每项权限放到对应阶段理解
预检携带计划使用的方法,并在需要时携带计划设置的请求头名称。对于下面的 PUT 和 X-Lab 请求,预检响应必须同时允许该方法和自定义请求头。这个阶段成功之后,实际响应仍然要满足自己的 CORS 暴露条件。当获准的前端需要读取错误状态和正文时,错误响应也可能需要恰当的暴露头。后端处理函数可以被普通客户端成功调用,并不足以证明浏览器中的真实前端能够访问它。
凭据模式又增加了独立约束。使用 include 时,响应暴露需要匹配的明确来源,以及值为 true 的允许凭据头;即使本次测试根本没有 Cookie 可发送,允许来源通配符仍然不成立。浏览器对 Cookie 的其他限制也独立存在。允许凭据头不能替代 Cookie 配置、用户认证或来源允许列表,也不应为了回答浏览器的预检元数据查询,就要求预检先携带会话 Cookie。
坏掉的样子
应用路由可能允许 PUT,但预检响应遗漏了 PUT 或所需自定义请求头。浏览器此时只发送 OPTIONS,根本不会派发实际 PUT。重试业务处理器或修改数据库代码,并不能修复这个协议边界。反过来,一个采用凭据模式的简单 GET 可以在没有 OPTIONS 的情况下到达处理器,然后因为服务端返回通配符而无法向脚本暴露响应。仅凭前端的失败提示,无法确定哪一种情况发生了。
预检缓存还会让配置变更难以观察。最大缓存时长控制的是浏览器专门维护的许可缓存,并且受到浏览器自身限制;它不是业务接口正文缓存。判断修改是否生效之前,应查看真实请求交互及缓存影响。如果响应会根据请求来源选择不同的允许值,还要设置恰当的缓存变化规则,避免共享缓存把某个来源对应的许可响应直接复用于其他请求。不能靠反复修改接口正文来排查权限缓存。
快速验证
保存代码为 cors-preflight.py,执行 python3 cors-preflight.py --serve,打开打印出的页面地址,结束后按控制键加 C 关闭两个本地服务。两个来源位于不同的本机回环端口,所有响应内容均为合成数据。实验有意在不设置 Cookie、不实现账号认证的条件下检查凭据模式,所以它证明的是浏览器协议规则,而不是一套完整的凭据传输方案。
核验记录,2026 年 10 月 17 日:修订后的回环夹具使用 Python 3.14.4 通过了受监控的启动检查,并在使用全新私有配置的无头 Chrome 156.0.8078.36 中通过了五个顺序浏览器案例。通配符配合 include 时不可读,通配符配合 omit、明确来源配合 include 时可读。被拒绝的 PUT 停在预检阶段,获准的 PUT 到达了服务器。观察到的 204 响应均无 Content-Length。私有 Python opener 只处理启动阶段的 OPTIONS;连接对端检查由额外监控器提供,文章辅助函数不包含这些检查。浏览器正常执行 CORS。这些合成结果不证明真实认证或生产环境行为。针对字节完全相同的 API 处理器,另行进行的原始响应检查确认 204 无响应体,也没有 Content-Length 或 Transfer-Encoding;六类离线畸形响应控制均被拒绝。
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
events = []
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
raise ValueError("This fixture does not follow redirects")
def open_owned_preflight(request):
endpoint = api.socket.getsockname()
if (endpoint != ("127.0.0.1", api.server_port)
or type(endpoint[1]) is not int or not 1024 <= endpoint[1] <= 65535
or request.full_url not in {f"http://127.0.0.1:{endpoint[1]}/{p}" for p in ("deny", "permit")}
or request.get_method() != "OPTIONS" or request.data is not None
or request.has_proxy()):
raise ValueError("Only this fixture's owned loopback preflights are allowed")
return direct_opener.open(request, timeout=5)
class API(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def reply(self, preflight=False):
events.append({"method": self.command, "path": self.path,
"requested_method": self.headers.get("Access-Control-Request-Method"),
"requested_headers": self.headers.get("Access-Control-Request-Headers")})
body = b"" if preflight else b"readable result"
self.send_response(204 if preflight else 200)
if not preflight:
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*" if self.path == "/wildcard" else app_origin)
self.send_header("Access-Control-Allow-Credentials", "true")
self.send_header("Vary", "Origin")
if preflight and self.path == "/permit":
self.send_header("Access-Control-Allow-Methods", "PUT")
self.send_header("Access-Control-Allow-Headers", "X-Lab")
self.send_header("Access-Control-Max-Age", "0")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.reply(preflight=True)
def do_GET(self):
self.reply()
def do_PUT(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
self.reply()
class App(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/events":
body = json.dumps(events).encode()
kind = "application/json"
else:
body = ("""<!doctype html><meta charset="utf-8"><title>CORS preflight lab</title>
<h1>Credentials mode and preflight</h1><pre id="result">Running...</pre><script>
const api=""" + json.dumps(api_origin) + """;
async function run() {
const cases=[
['wildcardInclude','/wildcard',{credentials:'include'}],
['wildcardOmit','/wildcard',{credentials:'omit'}],
['exactInclude','/exact',{credentials:'include'}],
['deniedPut','/deny',{method:'PUT',headers:{'X-Lab':'demo'},body:'synthetic'}],
['allowedPut','/permit',{method:'PUT',headers:{'X-Lab':'demo'},body:'synthetic'}]
];
const results={};
for(const [name,path,options] of cases) {
try {const response=await fetch(api+path,options);results[name]={readable:true,status:response.status,body:await response.text()};}
catch(error) {results[name]={readable:false,error:error.name};}
}
results.serverEvents=await (await fetch('/events')).json();
window.labResult=results;
document.getElementById('result').textContent=JSON.stringify(results,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("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
api = HTTPServer(("127.0.0.1", 0), API)
app = HTTPServer(("127.0.0.1", 0), App)
api_origin = f"http://127.0.0.1:{api.server_port}"
app_origin = f"http://127.0.0.1:{app.server_port}"
# A process-local client for these two owned loopback requests only.
# It changes no system settings or global urllib opener.
direct_opener = build_opener(ProxyHandler({}), NoRedirect())
threads = [Thread(target=server.serve_forever, daemon=True) for server in (api, app)]
for thread in threads:
thread.start()
try:
headers = {"Origin": app_origin, "Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "x-lab"}
for path in ("deny", "permit"):
with open_owned_preflight(Request(api_origin + "/" + path, method="OPTIONS", headers=headers)) as response:
assert response.status == 204
assert response.headers.get("Content-Length") is None
assert response.headers.get("Access-Control-Allow-Methods") == ("PUT" if path == "permit" else None)
assert response.headers.get("Access-Control-Allow-Headers") == ("X-Lab" if path == "permit" else None)
events.clear()
print(json.dumps({"app_url": app_origin, "wire_preflight_checks": "PASS",
"real_authentication_or_cookies": False}), flush=True)
if "--serve" in sys.argv:
Event().wait()
except KeyboardInterrupt:
pass
finally:
for server in (api, app):
server.shutdown()
server.server_close()
for thread in threads:
thread.join(timeout=5)
页面依次执行五种 fetch:通配符配合 include 时不可读,相同通配符配合 omit 时可读,明确来源配合 include 时也可读。缺少许可信息的 PUT 无法通过预检,而明确允许方法与 X-Lab 请求头的 PUT 成功。页面同时展示服务端记录的事件,避免只根据脚本成功或失败标志来猜测实际请求有没有执行。每项观察都可以与浏览器网络面板里的具体交互对应起来。
预期服务端记录三个 GET、两个 OPTIONS,以及恰好一个发往 permit 路径的 PUT。两个预检都声明 PUT 方法和小写的 x-lab 请求头,deny 路径则没有收到 PUT。允许响应把预检缓存时长设为零,避免实验依赖之前缓存的许可。不带服务参数的 Python 执行只检查响应头,浏览器执行才验证真正的派发与暴露行为。用于实际前端时,应保留这两类证据,并测试真实采用的方法、内容类型、自定义头、凭据模式和响应路径。
延伸阅读
来源
- MDN CORS,预检条件与携带凭据模式的请求交互,已核验 2026-09-13。
- MDN Access-Control-Allow-Credentials,暴露凭据模式响应所需的条件,已核验 2026-09-13。
- WHATWG Fetch Standard,方法、请求头权限检查及预检缓存,已核验 2026-09-13。