curl 能通但浏览器报 CORS:用三次请求定位预检
前端通过另一个域名提交 JSON,普通 curl POST 返回 200,浏览器网络面板却只有 OPTIONS。先查看预检有没有允许请求的来源、方法和头。通过预检后,再检查实际响应也带有相应的跨域响应头。
1. 对照允许、拒绝和实际请求
把下面保存为 cors_lab.mjs,运行 node cors_lab.mjs。服务只监听本机。它允许一个固定来源发送 JSON,并在响应随来源变化时添加 Vary。实验用 Node HTTP 客户端手动发送协议报文。
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { createServer, request } from 'node:http';
const origin = 'https://app.example.com';
const server = createServer((req, res) => {
res.setHeader('Vary', 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers');
if (req.url !== '/orders') {
res.writeHead(404).end();
return;
}
if (req.headers.origin !== origin) {
res.writeHead(403).end();
return;
}
res.setHeader('Access-Control-Allow-Origin', origin);
if (req.method === 'OPTIONS') {
const headers = (req.headers['access-control-request-headers'] ?? '')
.split(',').map(name => name.trim().toLowerCase()).filter(Boolean);
if (req.headers['access-control-request-method'] !== 'POST' ||
headers.some(name => name !== 'content-type')) {
res.writeHead(403).end();
return;
}
res.writeHead(204, {
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'Content-Type',
}).end();
return;
}
req.resume();
res.writeHead(req.method === 'POST' ? 200 : 405).end();
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const url = 'http://127.0.0.1:' + server.address().port + '/orders';
function send(method, headers, body) {
return new Promise((resolve, reject) => {
const req = request(url, { method, headers }, res => {
res.resume();
res.on('end', () => resolve(res));
res.on('error', reject);
});
req.on('error', reject);
req.end(body);
});
}
try {
const preflight = {
Origin: origin,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
};
const allowed = await send('OPTIONS', preflight);
assert.equal(allowed.statusCode, 204);
assert.equal(allowed.headers['access-control-allow-origin'], origin);
assert.equal(allowed.headers['access-control-allow-methods'], 'POST');
assert.equal(allowed.headers['access-control-allow-headers'], 'Content-Type');
const denied = await send('OPTIONS', {
...preflight, 'Access-Control-Request-Headers': 'content-type, x-custom',
});
assert.equal(denied.statusCode, 403);
const actual = await send('POST', { Origin: origin, 'Content-Type': 'application/json' }, '{}');
assert.equal(actual.statusCode, 200);
assert.equal(actual.headers['access-control-allow-origin'], origin);
console.log('preflight allowed:', allowed.statusCode);
console.log('unexpected header:', denied.statusCode);
console.log('actual response:', actual.statusCode);
} finally {
server.close();
}
本地实测输出如下。
preflight allowed: 204
unexpected header: 403
actual response: 200
2. 在网关日志里看请求走到了哪一步
| 网络面板 | 优先检查 |
|---|---|
| OPTIONS 返回 401 或重定向到登录 | 预检有没有被鉴权或重定向规则拦住 |
| OPTIONS 返回 204,POST 没出现 | 允许的 origin、方法或请求头是否匹配 |
| POST 已出现,页面仍报 CORS | 实际响应及其错误分支是否带允许头 |
| 只有某个页面域名失败 | 比较协议、主机名、端口,并检查缓存是否混用了响应 |
带凭据的跨域请求还需要明确的允许来源和 Access-Control-Allow-Credentials。浏览器的跨域读取策略和服务器的鉴权、CSRF 防护分别执行。上线时用真实页面在浏览器里检查失败分支,尤其是网关自己生成的 401 和 5xx。
小结
保留原始 Origin、申请的方法和头清单,先复现 OPTIONS,再检查实际请求。状态码正常时也要逐项检查允许头。
数据来源与检索时间
检索时间 2026-11-13。
| 来源 | URL | 对应内容 |
|---|---|---|
| WHATWG Fetch | https://fetch.spec.whatwg.org/#http-cors-protocol | CORS 与预检流程 |
| MDN | https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS | 凭据、允许头和 Vary |
核验说明
- Node.js v24.19.0 实测三种 HTTP 请求。浏览器执行策略依据 Fetch 标准,实验范围为本地 HTTP 报文。