---
title: "curl 能通但浏览器报 CORS：用三次请求定位预检"
date: 2026-11-13
slug: cors-preflight-debugging
canonical: https://kernelranch.com/blog/2026/11/cors-preflight-debugging
markdown_url: https://kernelranch.com/blog/2026/11/cors-preflight-debugging.md
tags: ["http", "browser", "cors"]
---前端通过另一个域名提交 JSON，普通 curl POST 返回 200，浏览器网络面板却只有 OPTIONS。先查看预检有没有允许请求的来源、方法和头。通过预检后，再检查实际响应也带有相应的跨域响应头。

## 1. 对照允许、拒绝和实际请求

把下面保存为 cors_lab.mjs，运行 node cors_lab.mjs。服务只监听本机。它允许一个固定来源发送 JSON，并在响应随来源变化时添加 Vary。实验用 Node HTTP 客户端手动发送协议报文。

```javascript
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();
}
```

本地实测输出如下。

```text
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 报文。
