开发技术分享

  • 文章分类
    • 技术
    • 工作
    • 相册
    • 杂谈
    • 未分类
  • 工具
    • AI 试衣
靡不有初,鲜克有终
[换一句]
  1. 首页
  2. 技术
  3. 正文

跨域与预检

2024年7月15日 206点热度

以前对“跨域”的含义只知道个大概,即浏览器出于安全考虑,不允许域名是 a 的网页,访问域名 b 的资源。

今天在 v2ex 上看了一篇关于跨域的帖子,对跨域和预检(preflight)有了更进一步的了解,打算自己做一个实验,来验证并加深自己的理解。

跨域

首先要澄清的是:跨域只会出现在浏览器环境中!,纯服务端之间不存在这个问题。

所谓的跨域,就是:网页地址的域名是 A ,但是接口请求的地址是 B 。这就是跨域,跨越了不同的域(名)想要去请求资源。它的严格定义和介绍可以看这里。

事实上,协议、域名、端口三者有任意一个不同,就算“跨域”了,例如:

  1. https://mofish.zone:3000 和 https://mofish.zone:9527 是跨域
  2. http://x 和 https://x 是跨域

发生跨域问题的时候,我们一般会在控制台看到下面的错误:

Access to fetch at 'http://backend.com/test/cors/ping' from origin 'http://frontend.com' 
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

解决跨域问题

那么如何解决跨域问题呢?

粗略来讲,核心就是后端借助请求头 Access-Control-Allow-Origin 告诉浏览器,允许下列的 xxx 域名,访问我后端的资源

关于跨域还有其他一系列相关的 header,这里就不细看了。

这里的后端是相对前端来说的,它可以是真实的 web 服务,也可以是 nginx,也可以是其他任何可以修改 response header 的七层代理或中间件。

我们在 java springboot 服务中,可以通过给 controller 中的方法签名上增加 @CrossOrigin 注解来实现,比如下面的例子

    @CrossOrigin(origins = "http://frontend.com", allowCredentials = "true")
    @GetMapping("/test/cors/ping")
    @ResponseBody
    public String handshake() {
        return "PONG";
    }

浏览器在收到 response 之后,会发现 response 中有这么一个 header:Access-Control-Allow-Origin = http://frontend.com,这时候浏览器就会放行这个请求。

如果想偷懒的话,也可以做一个全局配置:

@Configuration
public class WebCorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://frontend.com")  // 也可以直接写 * 表示任意 origin
            .allowedMethods("*")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

亦或者,我们也可以在 nginx 上配置,当处理发往后端 backend.com 的请求时,自动添加上面的这个 header:

server {
    listen 80;
    server_name backend.com;  # 你的前端域名,或用于代理的域名

    location / {
        proxy_pass http://real.backend.com/;  # 代理到真实的后端地址

        # 添加 CORS 头:

        # 星号表示支持任何源网址,也可以换成具体的域名。
        # 但这里不支持写多个域名,如果要添加多个域名,则需要换一种写法。
        # always 指在非 200 的请求也会返回跨域信息(如 4xx、5xx)
        add_header 'Access-Control-Allow-Origin' '*' always;

        # 允许的 http method
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;

        # 用于告诉浏览器,在跨域请求中除了默认允许的简单请求头外,还允许哪些额外的请求头。
        add_header 'Access-Control-Allow-Headers' 'Authorization, X-Requested-With' always;

        # 处理预检请求 (OPTIONS),下文会介绍
        if ($request_method = OPTIONS ) {
            # 预检请求缓存时间(单位:秒)
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

疑问:为什么本地开发环境没事?

前端开发的时候本地页面地址是 localhost ,接口地址肯定是其他的,为什么不跨域呢?

因为现在前端项目,开发用的脚手架通常会在本地起一个代理,统一将请求转发到后端,所以不会出现跨域问题。

动手实验

1. 修改本地 hosts

因为要在本地环境模拟,所以在 hosts 文件中将域名指向 127.0.0.1

127.0.0.1    frontend.com
127.0.0.1    backend.com

2. 创建前端页面

每次点击按钮,向后端发送一个请求,然后将 result 打印出来

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>跨域测试前端</title>
</head>
<body>
<h1>Frontend on frontend.com</h1>
<button onclick="callApi()">调用后端 API</button>
<pre id="result"></pre>

<script>
    function callApi() {
        fetch('http://backend.com/test/cors/ping', {})
            .then(response => response.text())
            .then(data => {
                document.getElementById('result').textContent = data;
            })
            .catch(err => {
                document.getElementById('result').textContent = 'Error: ' + err;
            });
    }
</script>
</body>
</html>

3. 创建 web 服务

@RestController
public class TestCorsController {

    @GetMapping("/test/cors/ping")
    @ResponseBody
    public String handshake() {
        return "PONG";
    }

}

4. 修改 nginx 配置

当浏览器访问 http://frontend.com/index.html 的时候,返回上面创建的 html 页面。

当页面请求 http://backend.com/test/* 的时候会转发到我部署在 http://localhost:9010 上的 spring web 服务

    # 前端页面
    server {
        listen 80;
        server_name frontend.com;
        location /index {
            root C:/idea_project/demo/src/main/resources/;
            index index.html;
        }
    }

    # 后端服务
    server {
        listen 80;
        server_name backend.com;
        location /test/ {
            proxy_pass http://localhost:9010;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }

5. 发起请求,触发跨域问题

点击按钮,发起请求,可以看到前端提示 CORS 错误:

触发CROS问题


在 console 打印了详细的信息:

触发跨域问题时,console上的报错

6. 配置 @CrossOrigin,解决跨域

我们修改 java 代码,加上 @CrossOrigin 注解:

@Slf4j
@RestController
public class TestCorsController {

    @CrossOrigin(origins = "http://frontend.com", allowCredentials = "true")
    @GetMapping("/test/cors/ping")
    @ResponseBody
    public String handshake() {
        log.info("Ping received, return PONG");
        return "PONG";
    }
}

前端再次访问,就可以访问成功了。我们可以看到在 response header 中,明确的指出了 http://frontend.com 这个地址:

成功跨域

预检请求

当我们访问网页时,经常会看到请求类型为 OPTIONS 的预检请求,这也是浏览器用来确保跨域安全的策略之一。

我们上面 html 代码中,构造了一个最简单的 ping-pong 请求,遇到这种简单请求,浏览器会直接发送,不会预检。

简单请求的准确定义比较复杂,简单来说,就是满足下面所有条件的请求:

  1. 请求方法 GET、HEAD 或 POST
  2. 只能使用这些“安全的请求头”:Accept、Accept-Language、Content-Language、DPR、Downlink、Save-Data、Viewport-Width、Width
  3. 对于POST请求,如果Content-Type头部的值是 application/x-www-form-urlencoded、multipart/form-data或text/plain,则不会触发预检请求
  4. 请求中不能使用自定义 header(包括凭证信息,如Cookies、HTTP认证信息等)

从最后一条我们可以知道,只要给请求带上任意自定义 header,就可以构造一个“非简单请求”

当遇到了非简单请求时,浏览器就会先发送一个 OPTIONS 预检请求,问问后端配置的跨域策略是什么,如果能满足,则继续请求,如果不满足,就会报跨域错误。

下面我修改 html 网页源码,给请求携带一个自定义的 header:

    function callApi() {
        fetch('http://backend.com/test/cors/ping', {
            headers: { "X-Custom-Header": "my_header" } // 自定义 header,会触发预检
        })
            .then(response => response.text())
            .then(data => {
                document.getElementById('result').textContent = data;
            })
            .catch(err => {
                document.getElementById('result').textContent = 'Error: ' + err;
            });
    }

这时候再去访问后端,就会触发预检请求:

触发预检请求

对于 spring web 项目,框架通常会帮我们处理预检请求,我们可以利用下面的 filter,来记录收到的预检请求:

@Slf4j
@Component
public class OptionRequestFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            log.info("Received OPTIONS request to: " + request.getRequestURI());
        }

        filterChain.doFilter(request, response);
    }
}
标签: 暂无
最后更新:2026年2月26日

zt52875287

这个人很懒,什么都没留下

点赞
< 上一篇
下一篇 >
文章目录
  • 跨域
  • 解决跨域问题
  • 动手实验
    • 1. 修改本地 hosts
    • 2. 创建前端页面
    • 3. 创建 web 服务
    • 4. 修改 nginx 配置
    • 5. 发起请求,触发跨域问题
    • 6. 配置 @CrossOrigin,解决跨域
  • 预检请求

Copyright © by zt52875287@gmail.com All Rights Reserved.

Theme Kratos Made By Seaton Jiang

陕ICP备2021009385号-1