KernelRanch
返回文章列表

curl 能通但浏览器报 CORS:用三次请求定位预检

2026-11-13 #http#browser#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 Fetchhttps://fetch.spec.whatwg.org/#http-cors-protocolCORS 与预检流程
MDNhttps://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS凭据、允许头和 Vary

核验说明

  • Node.js v24.19.0 实测三种 HTTP 请求。浏览器执行策略依据 Fetch 标准,实验范围为本地 HTTP 报文。