同源策略与跨域

主要内容

  • 同源策略与跨域
    • 同源策略
    • 跨域Demo
  • 解决跨域
    • JSONP
      • 方案1:<script>标签+show.js
      • 方案2:动态<script>+Controller
      • 方案3:动态<script>+callback+Controller
      • 方案4:JQuery的JSONP
    • CORS
      • 方法上加@CrossOrigin
      • Controller上加@CrossOrigin
      • @Bean配置跨域Filter
      • WebMvcConfigurer添加跨域规则
      • CORS原理
        • 简单请求
        • 特殊请求

知乎一直没有自动生成目录的功能,慢慢被我嫌弃了。

不管实际工作还是学习过程中,我们都不可避免地会遇到跨域问题,而造成跨域的罪魁祸首就是浏览器的同源策略。所以要解决跨域,我们必须知道什么是浏览器的同源策略。

同源策略与跨域

同源策略

百度“同源策略”得到以下回答:

同源策略,它是由Netscape提出的一个著名的安全策略。
所有支持JavaScript 的浏览器都会使用这个策略。
所谓同源是指,域名,协议,端口相同。
当一个浏览器的两个tab页中分别打开来 百度和谷歌的页面
当浏览器的百度tab页执行一个脚本的时候会检查这个脚本是属于哪个页面的,
即检查是否同源,只有和百度同源的脚本才会被执行。 [1]
如果非同源,那么在请求数据时,浏览器会在控制台中报一个异常,提示拒绝访问。
同源策略是浏览器的行为,是为了保护本地数据不被JavaScript代码获取回来的数据污染,因此拦截的是客户端发出的请求回来的数据接收,即请求发送了,服务器响应了,但是无法被浏览器接收。

简单来说,就是:

需要注意的是,很多人以为同源策略是浏览器不让请求发出去、或者后端拒绝返回数据。实际情况是,请求正常发出,后端接口正常响应,只不过数据到了浏览器后被丢弃了。

同源策略限制内容有:

  • Cookie、LocalStorage、IndexedDB 等存储性内容
  • DOM节点
  • AJAX跨域请求的数据

以下情况都属于跨域:

如果域名和端口都相同,但是请求路径不同,不属于跨域,如:

www.jd.com/item

www.jd.com/goods

http和https也属于跨域。

而上面示意图中,从manage.leyou.com去访问api.leyou.com,这属于二级域名不同,跨域了。

简单来说,是否跨域的3个因素:协议+域名+端口。

跨域Demo

随手建一个SpringBoot项目后,把下面的文件拷过去

目录结构

UserController

@RestController
public class UserController {

    @GetMapping(value = "/getUser/{id}")
    public User getUser(@PathVariable("id") String id) {

        System.out.println("id:" + id);

        User user = new User();
        user.setName("bravo");
        user.setAge(18);
        user.setAddress("wenzhou");

        return user;
    }
}

index.htm

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS</title>
    <script type="text/javascript" src="/jquery/jquery-2.1.3.min.js"></script>
</head>
<body>
<h1>当前网页来自localhost:7070/index.html</h1>

<h3>页面加载时自动发送GET请求: http://localhost:8080/avatar.png</h3>
<img src="http://localhost:8080/avatar.png" width="100" height="100"><br><br>

<h3>点击发送GET请求: http://localhost:8080/getUser/1</h3>
<input type="text" id="result">
<input type="button" onclick="onButtonClick()" value="get_button">

</body>

<script>
    function onButtonClick() {
            $.get('http://localhost:8080/getUser/1', function (data) {
                console.log("data", data);
            });
    }
</script>

</html>

JQuery你们可以引用外站的:

<script src="https://code.jquery.com/jquery-3.1.1.min.js"></script> 


这里我们采用不同端口跨域的方式,我会把同一个项目启动两次。

第一次通过IDEA启动7070端口:

第二次通过java -jar指定8080端口启动项目:

# install 
mvn clean install -Dmaven.test.skip=true 
# 指定8080端口启动项目 
java -jar /Users/kevin/IdeaProjects/springboot-demo/target/springboot-demo-0.0.1-SNAPSHOT.jar --server.port=8080

请求结果

AJAX的GET请求其实已经拿到了数据(状态码200),只不过浏览器拒绝该数据

回顾一下上面的那张示意图:

本案例中,页面index.html来自localhost:7070,而<img>和AJAX的GET其实都跨域了:

  • <img src="http://localhost:8080/avatar.png"/>
  • $.get('http://localhost:8080/getUser/1', function (data)


但为什么只有AJAX被拒绝了?

因为浏览器允许。

是的,浏览器遵守同源策略,但是有若干个标签是允许跨域的,比如:

  • <img src="xxx"/>
  • <link href="xxx"/>
  • <script src="xxx"/>

所以html中引入外站的脚本不会跨域报错。

这些标签一般是加载静态资源的,和后端关系不大,我们应该关心如何解决AJAX跨域问题。

解决跨域

解决跨域的方式很多,比如Node中间件代理、Nginx反向代理等等。这里介绍两种最简单的方式:JSONP和CORS。

JSONP

JSONP可以算是利用同源策略的“漏洞”而创造出来的跨域解决方案,属于奇巧淫技。

还记得上面Demo中,<img>标签可以跨域得到图片吗?<script>标签也可以跨域。

这是我们突破跨域的第一个因素。

第二个因素,不知道大家能不能想起来:

我记得以前刚入行时,做的项目大多是前后端不分离的,我不仅要写接口,还要在JSP页面引入JQuery、BootStrap等一大堆JS组件。当引入的CSS和JS样式多了以后,就经常会出现页面渲染失败的情况。原因在于,比如BootStrap如果本身依赖于JQuery,那么JQuery的<script>脚本引入必须早于BootStrap。由于浏览器的加载是自上而下的,所以把<script src="jquery.js">放在<script src="bootstrap.js">上方即可。

方案1:<script>标签+show.js

改造index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS</title>
    <script type="text/javascript" src="/jquery/jquery-2.1.3.min.js"></script>
    <!--自定义的方法,等show.js调用-->
    <script>
        function printResponse(data) {
            console.log(data);
        }
    </script>
    <!--show.js的加载(调用)晚于上方printResponse()的定义,所以不会报错-->
    <script type="text/javascript" src="/show.js"></script>
</head>
<body>
<h1>当前网页来自localhost:7070/index.html</h1>

<h3>页面加载时自动发送GET请求: http://localhost:8080/avatar.png</h3>
<img src="http://localhost:8080/avatar.png" width="100" height="100"><br>


</body>

</html>

show.js

// 调用printResponse()方法,但是这个方法并没有在当前js文件中定义
printResponse("(" + "{\n" +
    "  \"username\": \"bravo\",\n" +
    "  \"age\": 18,\n" +
    "  \"address\": \"China\"\n" +
    "}" + ")");

请求结果

index.html的<script>标签加载show.js后执行了printResponse(),而index.html中刚好定义了printResponse(),所以本次调用成功打印了user。


方案2:动态<script>+Controller

方案1中利用<script>标签请求得到show.js,里面只有一段js代码片段。那么,能不能把这段代码交由Controller作为响应结果直接返回呢?

改造index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS</title>
    <script type="text/javascript" src="/jquery/jquery-2.1.3.min.js"></script>
    <script>
        // 1.定义printResponse()方法
        function printResponse(data) {
            console.log(data);
        }
    </script>
    <script>
        // 2.动态拼接<script>标签,请求js片段并调用printResponse()
        let url = "http://localhost:8080/getUser/1";
        // 平时都是手写<script src="">,现在我们在代码中动态构造script标签并设置src属性
        let script = document.createElement('script');
        script.type = "text/javascript";
        script.src = url;
        // 把script标签加入head(因为script标签本来也是在html的head中),并发起请求,后端响应后浏览器会自动执行返回的script片段
        document.getElementsByTagName('head')[0].appendChild(script);
    </script>
</head>
<body>
<h1>当前网页来自localhost:7070/index.html</h1>

<h3>页面加载时自动发送GET请求: http://localhost:8080/avatar.png</h3>
<img src="http://localhost:8080/avatar.png" width="100" height="100"><br>


</body>

</html>

Controller

@GetMapping(value = "/getUser/{id}")
public String getUser(@PathVariable("id") String id) {

    System.out.println("id:" + id);

    return "printResponse" + "(" + "{\n" +
            "  \"username\": \"bravo\",\n" +
            "  \"age\": 18,\n" +
            "  \"address\": \"China\"\n" +
            "}" + ")";

}

浏览器请求/getUser/1

请求index.html

完美。


方案3:动态<script>+callback+Controller

仔细想想,方案2有个最大的问题是:可扩展性差。

假设现在index.html得到user数据后不再是简单的打印输出,而是做另外的操作,那么前后端都要改动:

<script>
    // 1.做其他事情
    function doSomething(data) {
        console.log(data);
    }
</script>
<script>
    // 2.动态拼接<script>标签,请求js片段并调用printResponse()
    let url = "http://localhost:8080/getUser/1";
    // 平时都是手写<script src="">,现在我们在代码中动态构造script标签并设置src属性
    let script = document.createElement('script');
    script.type = "text/javascript";
    script.src = url;
    // 把script标签加入head(因为script标签本来也是在html的head中),并发起请求,后端响应后浏览器会自动执行返回的script片段
    document.getElementsByTagName('head')[0].appendChild(script);
</script>


@GetMapping(value = "/getUser/{id}")
public String getUser(@PathVariable("id") String id) {

    System.out.println("id:" + id);
	// 跟着前端一起修改方法名
    return "doSomething" + "(" + "{\n" +
            "  \"username\": \"bravo\",\n" +
            "  \"age\": 18,\n" +
            "  \"address\": \"China\"\n" +
            "}" + ")";

}

比较好的办法是,前端在动态拼接标签时把方法名拼接到URL上传给后端:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS</title>
    <script type="text/javascript" src="/jquery/jquery-2.1.3.min.js"></script>
    <script>
        // 1.定义方法
        function doSomething(data) {
            console.log(data);
        }
    </script>
    <script>
        // 2.动态拼接<script>标签,把需要调用的本地方法名传递给后端
        let url = "http://localhost:8080/getUser/1?callback=doSomething";
        // 平时都是手写<script src="">,现在我们在代码中动态构造script标签并设置src属性
        let script = document.createElement('script');
        script.type = "text/javascript";
        script.src = url;
        // 把script标签加入head(因为script标签本来也是在html的head中),并发起请求,后端响应后浏览器会自动执行返回的script片段
        document.getElementsByTagName('head')[0].appendChild(script);
    </script>
</head>
<body>
<h1>当前网页来自localhost:7070/index.html</h1>

<h3>页面加载时自动发送GET请求: http://localhost:8080/avatar.png</h3>
<img src="http://localhost:8080/avatar.png" width="100" height="100"><br>


</body>

</html>


/**
 * @author bravo
 * @date 2020-02-03 21:38:26
 */
@RestController
public class UserController {

    @GetMapping(value = "/getUser/{id}")
    public String getUser(@PathVariable("id") String id, String callback) {

        System.out.println("id:" + id);
        // 拼接方法名
        return callback + "(" + "{\n" +
                "  \"username\": \"bravo\",\n" +
                "  \"age\": 18,\n" +
                "  \"address\": \"China\"\n" +
                "}" + ")";

    }
}

方案4:JQuery的JSONP

上面说过了,JSONP其实算是一种对同源策略规则的投机取巧,实现方式可以多种多样。JQuery对JSONP也有实现:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS</title>
    <script type="text/javascript" src="/jquery/jquery-2.1.3.min.js"></script>
</head>
<body>
<h1>当前网页来自localhost:7070/index.html</h1>

<h3>页面加载时自动发送GET请求: http://localhost:8080/avatar.png</h3>
<img src="http://localhost:8080/avatar.png" width="100" height="100"><br><br>

<h3>点击发送GET请求: http://localhost:8080/getUser/1</h3>
<input type="text" id="result">
<input type="button" onclick="onButtonClick()" value="get_button">

</body>

<script>
    /**
     * 照常使用ajax,只要把dataType改为"jsonp"即可。
     * JQuery在前端替我们做了两件事:
     * 1.URL后拼接方法名(随机生成)
     * 2.解析从后端得到的js片段,把正确的值传入success:function()
     * 
     * 当然,后端还是要按原来的做
     */
    function onButtonClick() {
        $.ajax({
            type: "GET",
            url: "http://localhost:8080/getUser/1",
            dataType: "jsonp",
            success: function(data){
                $('#result').val(data.name);
            }
        });
    }
</script>

</html>


/**
 * @author bravo
 * @date 2020-02-03 22:01:49
 */
@RestController
public class UserController {

    /**
     * SpringBoot内置的Jackson
     */
    @Autowired
    private ObjectMapper objectMapper;


    @GetMapping(value = "/getUser/{id}")
    public String getUser(@PathVariable("id") String id, String callback) {

        System.out.println("id:" + id);

        User user = new User();
        user.setName("bravo");
        user.setAge(18);
        user.setAddress("China");

        // 拼接方法名
        try {
            return callback + "(" + objectMapper.writeValueAsString(user) +")";
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }

        return null;

    }
}

可以看到,JQuery自动帮我们在URL里加了callback参数。复制URL直接用浏览器请求:

JQuery JSONP的底层原理和方案3比较接近,只不过背地里做了些封装,让使用者感知不到本次请求为跨域请求。


CORS

JSONP的缺点其实挺多的,百度一下就能了解到。最显著的缺点其实是JSONP只支持GET请求,毕竟它的灵感来自于<script>标签,而标签都是GET请求。

从这个角度说,CORS要比JSONP强大且灵活。

CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。

CORS需要浏览器和服务器同时支持。在JSONP中,前端带来了callback方法名,需要后端配合返回JS片段。而在CORS中,浏览器会自动带来一些请求头,后端需要针对这些请求头做一些处理。

简单地说:JSONP是前端代码层面带一些东西给后端,CORS则是浏览器层面带一些东西给后端。本质上都需要前后端协商。

不过目前所有浏览器都支持该功能(会自动带上请求头),IE浏览器不能低于IE10。所以最终来看,CORS这种方案不需要前端做任何事情(绝大部分浏览器支持),只需后端配合即可。

对于服务端而言:
CORS通信与AJAX没有任何差别,因此我们不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否允许其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。

CORS简略示意图(简单请求):

方法上加@CrossOrigin

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>CORS</title>
    <script type="text/javascript" src="/jquery/jquery-2.1.3.min.js"></script>
</head>
<body>
<h1>当前网页来自localhost:7070/index.html</h1>

<h3>页面加载时自动发送GET请求: http://localhost:8080/avatar.png</h3>
<img src="http://localhost:8080/avatar.png" width="100" height="100"><br><br>

<h3>点击发送GET请求: http://localhost:8080/getUser/1</h3>
<input type="text" id="result">
<input type="button" onclick="onButtonClick()" value="get_button">

</body>

<script>
    /**
     * 普通AJAX,没有设置jsonp
     */
    function onButtonClick() {
        $.ajax({
            type: "GET",
            url: "http://localhost:8080/getUser/1",
            success: function(data){
                $('#result').val(data.name);
            }
        });
    }
</script>

</html>


/**
 * @author bravo
 * @date 2020-02-03 22:33:33
 */
@RestController
public class UserController {

    /**
     * 在跨域方法上加@CrossOrigin即可完美解决跨域问题
     * @param id
     * @return
     */
    @CrossOrigin("http://localhost:7070")
    @GetMapping(value = "/getUser/{id}")
    public User getUser(@PathVariable("id") String id) {

        System.out.println("id:" + id);

        User user = new User();
        user.setName("bravo");
        user.setAge(18);
        user.setAddress("China");

        return user;

    }
}


Controller上加@CrossOrigin

@CrossOrigin还可以加载Controller上,这样Controller的所有方法都支持跨域。

@RestController
@CrossOrigin("http://localhost:7070")
public class UserController {

    @GetMapping(value = "/getUser/{id}")
    public User getUser(@PathVariable("id") String id) {

        System.out.println("id:" + id);

        User user = new User();
        user.setName("bravo");
        user.setAge(18);
        user.setAddress("China");

        return user;

    }
}


@Bean配置跨域Filter

/**
 * @author bravo
 * @date 2020-02-03 22:40
 */
@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        //1.添加CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        //1) 允许的域,不要写*,否则cookie就无法使用了
        config.addAllowedOrigin("http://localhost:7070");
        //2) 是否发送Cookie信息
        config.setAllowCredentials(true);
        //3) 允许的请求方式
        config.addAllowedMethod("*");
        // 4)允许的头信息
        config.addAllowedHeader("*");
        // 5) 有效时长
        config.setMaxAge(3600L);

        //2.添加映射路径,我们拦截一切请求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}


WebMvcConfigurer添加跨域规则

/**
 * @author bravo
 * @date 2020-02-03 22:40
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:7070")
                .allowCredentials(true)
                .allowedHeaders("*")
                .allowedMethods("*")
                .maxAge(3600L);
    }
}

以上三种方式任选一种皆可。推荐最后两种。


CORS原理

回顾之前跨域的报错信息:

浏览器认为只要后端没返回CORS头(Access-Control-Allow-Origin),就认为后端不允许跨域,返回的数据不可靠。

所以只要后端能够返回浏览器需要的请求头,即可跨域(响应数据就不会被同源策略抛弃):

上面是表面原理,底层原理比较复杂。

浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。

简单请求

只要同时满足以下两大条件,就属于简单请求。:

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

当浏览器发现发起的ajax请求是简单请求时,会在请求头中携带一个字段:Origin.

刚才说过,CORS需要客户端和服务端同时支持。上面这个小操作,算是客户端的支持行为(IE10以下不行)。

Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。

如果服务器允许跨域,需要在返回的响应头中携带下面信息(算是服务端的支持):

Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8
  • Access-Control-Allow-Origin:可接受的域,是一个具体域名或者*(代表任意域名)
  • Access-Control-Allow-Credentials:是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是true


有关cookie:

要想操作cookie,需要满足3个条件:

  • 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
  • 浏览器发起ajax需要指定withCredentials 为true
  • 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名

这样一来,前后端都支持跨域了,那就跨吧。


特殊请求

不符合简单请求的条件,会被浏览器判定为特殊请求,,例如请求方式为PUT。

预检请求

特殊请求会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

一个“预检”请求的样板:

OPTIONS /cors HTTP/1.1
Origin: http://manage.leyou.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.leyou.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0

与简单请求相比,除了Origin以外,多了两个头:

  • Access-Control-Request-Method:接下来会用到的请求方式,比如PUT
  • Access-Control-Request-Headers:会额外用到的头信息


预检请求的响应

服务的收到预检请求,如果许可跨域,会发出响应:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

除了Access-Control-Allow-OriginAccess-Control-Allow-Credentials以外,这里又额外多出3个头:

  • Access-Control-Allow-Methods:允许访问的方式
  • Access-Control-Allow-Headers:允许携带的头
  • Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了

如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。

另外,需要注意CSRF攻击,也就是跨域伪造请求。即使有同源策略存在,如果不做另外防护,此类攻击仍然奏效。具体大家另外了解。

参考资料:

  • 百度
  • 黑马程序员乐优商城文档

2020-02-04

编辑于 2020-02-16 11:37