欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

由同源策略到前端跨域

程序员文章站 2024-02-18 16:24:22
...


由同源策略到前端跨域

由同源策略到前端跨域

同源策略 (Same-Origin Policy) 最早由 Netscape 公司提出, 作为著名的安全策略, 虽然它只是一个规范, 并不强制要求, 但现在所有支持 javaScript 的浏览器都会使用这个策略. 以至于该策略成为浏览器最核心最基本的安全功能, 如果缺少了同源策略, web的安全将无从谈起.

“同源政策”是浏览器安全的基石,其设计目的是为了保证信息安全,防止恶意的网站窃取数据。所谓“同源”必须满足以下三个方面:

  • 协议相同
  • 域名相同
  • 端口相同(默认端口是80,可以省略)

如果是非同源的,以下行为会受到限制:

  • Cookie、LocalStorage和IndexDB无法读取
  • DOM无法获取
  • AJAX请求不能发送

同源策略的限制

同源策略下的web世界, 域的壁垒高筑, 从而保证各个网页相互独立, 互相之间不能直接访问, iframe, ajax 均受其限制, 而script标签不受此限制.

注: 以下如非特别说明, 均指非CORS的, 普通跨域请求.

iframe限制

可以访问同域资源, 可读写;
访问跨域页面时, 只读.

Ajax限制

Ajax 的限制比 iframe 限制更严.

同域资源可读写;
跨域请求会直接被浏览器拦截.(chrome下跨域请求不会发起, 其他浏览器一般是可发送跨域请求, 但响应被浏览器拦截)

Script限制

script并无跨域限制, 这是因为script标签引入的文件不能够被客户端的 js 获取到, 不会影响到原页面的安全, 因此script标签引入的文件没必要遵循浏览器的同源策略. 相反, ajax 加载的文件内容可被客户端 js 获取到, 引入的文件内容可能会泄漏或者影响原页面安全, 故, ajax必须遵循同源策略.

注意:同源策略要求三同, 即: 同域, 同协议, 同端口.

  • 同域即host相同, *域名, 一级域名, 二级域名, 三级域名等必须相同, 且域名不能与 ip 对应;
  • 同协议要求, http与https协议必须保持一致;
  • 同端口要求, 端口号必须相同.

IE有些例外, 它仅仅只是验证主机名以及访问协议,而忽略了端口号.

这里需要澄清一个概念, 所谓的域, 跟 js 等资源的存放服务器没有关系, 比如你到baidu.com使用 script 标签请求了google.com 下的js, 那么该 js 所在域是 baidu.com, 而不是 google.com. 换言之, 它能操作baidu.com的页面对象, 却不能操作google.com的页面对象.

跨域访问

实际上, 我们又不可避免地需要做一些跨域的请求, 下面提供几种方案去绕过同源策略:

使用代理

虽然ajax和iframe受同源策略限制, 但服务器端代码请求, 却不受此限制, 我们可以基于此去伪造一个同源请求, 实现跨域的访问. 如下便是实现思路:

  • 请求同域下的web服务器;
  • web服务器像代理一样去请求真正的第三方服务器;
  • 代理拿到数据过后, 直接返回给客户端ajax.

这样, 我们便拿到了跨域数据.

JSONP

由上, script标签并不受同源策略约束, 基于script 标签可做 jsonp 形式的访问, 可以通过第三方服务器生成动态的js代码来回调本地的js方法,而方法中的参数则由第三方服务器在后台获取,并以JSON的形式填充到JS方法当中. 即 JSON with Padding. 具体如下:

  1. 可用js生成以下html 代码, 去做jsonp的请求.

    <script type="text/javascript" src="https://www.targetDomain.com/jsonp?callback=callbackName"></script>

    使用 jquery, 即

    jQuery.getJSON(
      "https://www.yourdomain.com/jsonp?callback=?",
      function(data) {
          console.log("name: " + data.name);
      }
    );
    

    其中回调函数名 “callback” 为 “?”, 即不需要用户指定,而是由jquery生成.

  2. 服务器端,以 java 为例, 参考如下:

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
      //获取JSON数据
      String jsonData = "{\"name\":\"jsonp\"}";
      //获取回调函数名
      String callback = req.getParameter("callback");  
      //拼接动态JS代码
      String output = callback + "(" + jsonData + ");
        resp.setContentType("text/javascript");
      PrintWriter out = resp.getWriter();
      out.println(output);
      // 响应为 callbackName({"name":"jsonp"});
    }
    

postMessage

ES5新增的 postMessage() 方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递.

  • 语法: postMessage(data,origin)

    • data: 要传递的数据,html5规范中提到该参数可以是JavaScript的任意基本类型或可复制的对象,然而并不是所有浏览器都做到了这点儿,部分浏览器只能处理字符串参数,所以我们在传递参数的时候建议使用JSON.stringify()方法对对象参数序列化,在低版本IE中引用json2.js可以实现类似效果.

    • origin:字符串参数,指明目标窗口的源,协议+主机+端口号[+URL],URL会被忽略,所以可以不写,这个参数是为了安全考虑,postMessage()方法只会将message传递给指定窗口,当然如果愿意也可以将参数设置为"*",这样可以传递给任意窗口,如果要指定和当前窗口同源的话设置为"/"。

父页面发送消息:

window.frames[0].postMessage('message', origin)

iframe接受消息

window.addEventListener('message',function(e){
    if(e.source!=window.parent) return;//若消息源不是父页面则退出
      //TODO ...
});

其中 e 对象有三个重要的属性

  • data, 表示父页面传递过来的message
  • source, 表示发送消息的窗口对象
  • origin, 表示发送消息窗口的源(协议+主机+端口号)
实例
/*
 * A窗口的域名是`<http://example.com:8080>`,以下是A窗口的`script`标签下的代码:
 */
 
var popup = window.open(...popup details...);

// 如果弹出框没有被阻止且加载完成

// 这行语句没有发送信息出去,即使假设当前页面没有改变location(因为targetOrigin设置不对)
popup.postMessage("The user is 'bob' and the password is 'secret'",
                  "https://secure.example.net");

// 假设当前页面没有改变location,这条语句会成功添加message到发送队列中去(targetOrigin设置对了)
popup.postMessage("hello there!", "http://example.org");

function receiveMessage(event)
{
  // 我们能相信信息的发送者吗?  (也许这个发送者和我们最初打开的不是同一个页面).
  if (event.origin !== "http://example.org")
    return;

  // event.source 是我们通过window.open打开的弹出页面 popup
  // event.data 是 popup发送给当前页面的消息 "hi there yourself!  the secret response is: rheeeeet!"
}
window.addEventListener("message", receiveMessage, false);
/*
 * 弹出页 popup 域名是<http://example.org>,以下是script标签中的代码:
 */

//当A页面postMessage被调用后,这个function被addEventListenner调用
function receiveMessage(event)
{
  // 我们能信任信息来源吗?
  if (event.origin !== "http://example.com:8080")
    return;

  // event.source 就当前弹出页的来源页面
  // event.data 是 "hello there!"

  // 假设你已经验证了所受到信息的origin (任何时候你都应该这样做), 一个很方便的方式就是把event.source
  // 作为回信的对象,并且把event.origin作为targetOrigin
  event.source.postMessage("hi there yourself!  the secret response " +
                           "is: rheeeeet!",
                           event.origin);
}

window.addEventListener("message", receiveMessage, false);

CORS 跨域访问

HTML5带来了一种新的跨域请求的方式 — CORS, 即 Cross-origin resource sharing. 它更加安全, 上述的 JSONP, postMessage等, 资源本身没有能力保证自己不被滥用. CORS的目标是保护资源只被可信的访问源以正确的方式访问.

目前, 主流的浏览器都支持此协议, 可以在caniuse.com 中查到caniuse.com/#search=cor….

简而言之, 浏览器不再一味禁止跨域访问, 而是检查目的站点的响应头域, 进而判断是否允许当前站点访问. 通常, 服务器使用以下的这些响应头域用来通知浏览器:

Response headers[edit]
Access-Control-Allow-Origin
Access-Control-Allow-Credentials
Access-Control-Allow-Methods
Access-Control-Allow-Headers
Access-Control-Expose-Headers
Access-Control-Max-Age

CORS的解决办法是在服务端ResponseHTTP头域加入资源的访问权限信息. 如: A站只需要在response头中加一个字段就能让B站跨站访问.

access-control-allow-origin:*

其中* 表示通配, 所有的域都能访问此资源, 如果严谨一些只允许B站访问:

access-control-allow-origin:<B-DOMAIN>

这样B站就可以直接访问此资源, 不需要JSONP 也不需要iframe了.

CORS需要指定METHOD访问, 对于GETPOST请求, 至少要指定以下三种methods, 如下:

Access-Control-Allow-Methods: POST, GET, OPTIONS

如果是POST请求, 且提交的数据类型是json, 那么, CORS需要指定headers.

Access-Control-Allow-Headers: Content-Type

CORS默认是不带cookie的, 设置以下字段将允许浏览器发送cookie.

Access-Control-Allow-Credentials: true

除此之外, 为了跨站发送cookie等验证信息, access-control-allow-origin 字段将不允许设置为*, 它需要明确指定与请求网页一致的域名.

同时, 请求网页中需要做如下显式设置才能真正发送cookie.

xhr.withCredentials = true;

详细请看:CORS详解

实例
//客户端

function test() {
   $.ajax({
         url: "http://localhost:8080/AdsServer/manage/role/get",
         type: "get",
         async: false,
         data:{
            "id":1 
         },
         dataType:"json",
         withCredentials:true,
         success: function(data){
             alert(data);
             alert(data.code);
         },
         error: function(){
             alert('fail');
         }
   })
}
//服务器端响应

@RequestMapping("/manage/role/get")
@ResponseBody
public String get(HttpServletRequest request, HttpServletResponse response) {
    BaseOutput outPut = new BaseOutput();
    try {
        QueryFilter filter = new QueryFilter(request);
        logger.info(filter.toString());
        String id = filter.getParam().get(MainConst.KEY_ID);
        if(!StringUtil.isEmpty(id)) {
            ImRole role = roleService.getByPk(filter);
            outPut.setData(role);
        }
        else {
            outPut.setCode(OutputCodeConst.INPUT_PARAM_IS_NOT_FULL);
            outPut.setMsg("The get id is needed.");
        }
    } catch (Exception e) {
        logger.error("获取角色数据异常!", e);
        outPut.setCode(OutputCodeConst.UNKNOWN_ERROR);
        outPut.setMsg("获取角色数据异常! " + e.getMessage());
    }
    return JsonUtil.objectToJson(outPut);
}
//服务器端拦截器

import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.luwei.console.mg.constant.ApplicationConfiConst;


public class CrossDomainFilter implements Filter {
    private Logger logger = LoggerFactory.getLogger(getClass());

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        ApplicationConfiConst confiConst = (ApplicationConfiConst) ContextUtil.getBean("applicationConfiConst");
        HttpServletResponse response = (HttpServletResponse) res;
        HttpServletRequest request = (HttpServletRequest) req;
        String referer = request.getHeader("referer");
        String origin = null;
        if (null != referer) {
            String[] domains = confiConst.getCanAccessDomain().split(",");
            for (String domain : domains) {
                if (StringUtils.isNotEmpty(domain) && referer.startsWith(domain)) {
                    origin = domain;
                    break;
                }
            }
        }
        response.setHeader("Access-Control-Allow-Origin", origin);
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE,PATCH");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with");
        // 是否支持cookie跨域
        response.addHeader("Access-Control-Allow-Credentials", "true");

        String requestURI = ((HttpServletRequest) req).getRequestURI();
        long begin = System.currentTimeMillis();
        chain.doFilter(req, res);
        if (logger.isDebugEnabled()) {
            logger.debug("[Request URI: " + requestURI + "], Cost Time:" + (System.currentTimeMillis() - begin) + "ms");
        }
    }
}

document.domain

通过修改document的domain属性,我们可以在域和子域或者不同的子域之间通信(即它们必须在同一个一级域名下). 同域策略认为域和子域隶属于不同的域,比如a.com和 script.a.com是不同的域,这时,我们无法在a.com下的页面中调用script.a.com中定义的JavaScript方法。但是当我们把它们document 的 domain 属性都修改为 a.com,浏览器就会认为它们处于同一个域下,那么我们就可以互相获取对方数据或者操作对方DOM了。

比如, 我们在www.a.com/a.html 下, 现在想获取 www.script.a.com/b.html, 即主域名相同, 二级域名不同. 那么可以这么做:

document.domain = 'a.com';
var iframe = document.createElement('iframe');
iframe.src = 'http://www.script.a.com/b.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.addEventListener('load',function(){
    //TODO 载入完成时做的事情
    //var _document = iframe.contentWindow.document;
     //...
},false);

注意:

  • 2个页面都要设置, 哪怕 a.html 页已处于 a.com域名下, 也必须显式设置.
  • document.domain只能设置为一级域名,比如这里a页不能设置为www.a.com(二级域名).

利用domain属性跨域具有以下局限性:

  • 两个页面要在同一个一级域名下, 且必须同协议, 同端口, 即子域互跨;
  • 只适用于iframe.
Internet Explorer同源策略绕过

Internet Explorer8以及前面的版本很容易通过document.domain实现同源策略绕过, 通过重写文档对象, 域属性这个问题可以十分轻松的被利用.

var document;
document = {};
document.domain = 'http://www.a.com';
console.log(document.domain);

如果你在最新的浏览器中运行这段代码, 可能在JavaScript控制台会显示一个同源策略绕过错误.

window .name

window 对象的name属性是一个很特别的属性, 当该windowlocation变化, 然后重新加载, 它的name属性可以依然保持不变. 那么我们可以在页面 A中用iframe加载其他域的页面B, 而页面B中用 JavaScript把需要传递的数据赋值给 window.name, iframe加载完成之后(iframe.onload), 页面A修改iframe的地址, 将其变成同域的一个地址, 然后就可以读出iframewindow.name的值了(因为A中的window.nameiframe中的window.name互相独立的, 所以不能直接在A中获取window.name, 而要通过iframe获取其window.name). 这个方式非常适合单向的数据请求,而且协议简单、安全. 不会像JSONP那样不做限制地执行外部脚本.

比如:我们在任意一个页面输入

window.name = "My window's name";
setTimeout(function(){
    window.location.href = "http://damonare.cn/";
},1000)

进入damonare.cn页面后我们再检测再检测window.name :

window.name; // My window's name

可以看到,如果在一个标签里面跳转网页的话,我们的 window.name是不会改变的。基于这个思想,我们可以在某个页面设置好 window.name的值,然后跳转到另外一个页面。在这个页面中就可以获取到我们刚刚设置的 window.name了。

由于安全原因,浏览器始终会保持window.namestring类型。

同样这个方法也可以应用到和iframe的交互来:比如:

//我的页面(damonare.cn/index.html)

<iframe id="iframe" src="http://www.google.com/iframe.html"></iframe>

在 iframe.html 中设置好了 window.name 为我们要传递的字符串。我们在 index.html 中写了下面的代码:

var iframe = document.getElementById('iframe');
var data = '';

iframe.onload = function() {
    data = iframe.contentWindow.name;
};

Boom!报错!肯定的,因为两个页面不同源嘛,想要解决这个问题可以这样干:

var iframe = document.getElementById('iframe');
var data = '';

iframe.onload = function() {
    iframe.src = 'about:blank';
    iframe.onload = function(){
        data = iframe.contentWindow.name;
    }
   
};

或者将里面的 about:blank替换成某个同源页面(about:blankjavascript:data: 中的内容,继承了载入他们的页面的源。)

这种方法与document.domain方法相比,放宽了域名后缀要相同的限制,可以从任意页面获取string类型的数据。

location.hash

location.hash(两个iframe之间), 又称FIM, Fragment Identitier Messaging的简写.

因为父窗口可以对iframe进行URL读写, iframe也可以读写父窗口的URL, URL有一部分被称为hash, 就是#号及其后面的字符, 它一般用于浏览器锚点定位, Server端并不关心这部分, 所以这部分的修改不会产生HTTP请求, 但是会产生浏览器历史记录. 此方法的原理就是改变URL的hash部分来进行双向通信. 每个window通过改变其他 window的location来发送消息(由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于父窗口域名下的一个代理iframe),并通过监听自己的URL的变化来接收消息. 这个方式的通信会造成一些不必要的浏览器历史记录, 而且有些浏览器不支持onhashchange事件, 需要轮询来获知URL的改变, 最后, 这样做也存在缺点, 比如数据直接暴露在了url中, 数据容量和类型都有限等.

假如父页面是baidu.com/a.html,iframe嵌入的页面为google.com/b.html(此处省略了域名等url属性),要实现此两个页面间的通信可以通过以下方法。

  • a.html传送数据到b.html

    • a.html下修改iframe的src为google.com/b.html#paco
    • b.html监听到url发生变化,触发相应操作
  • b.html传送数据到a.html,由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于父窗口域名下的一个代理iframe

    • b.html下创建一个隐藏的iframe,此iframe的src是baidu.com域下的,并挂上要传送的hash数据,如src=“www.baidu.com/proxy.html#…”
    • proxy.html监听到url发生变化,修改a.html的url(因为a.html和proxy.html同域,所以proxy.html可修改a.html的url hash)
    • a.html监听到url发生变化,触发相应操作

    b.html页面的关键代码如下:

    try {  
        parent.location.hash = 'data';  
    } catch (e) {  
        // ie、chrome的安全机制无法修改parent.location.hash,  
        var ifrproxy = document.createElement('iframe');  
        ifrproxy.style.display = 'none';  
        ifrproxy.src = "http://www.baidu.com/proxy.html#data";  
        document.body.appendChild(ifrproxy);  
    }
    

    proxy.html页面的关键代码如下 :

    //因为parent.parent(即baidu.com/a.html)和baidu.com/proxy.html属于同一个域,所以可以改变其location.hash的值  
    parent.parent.location.hash = self.location.hash.substring(1);
    

Access Control

此跨域方法目前只在很少的浏览器中得以支持, 这些浏览器可以发送一个跨域的HTTP请求(Firefox, Google Chrome等通过XMLHTTPRequest实现, IE8下通过XDomainRequest实现), 请求的响应必须包含一个Access- Control-Allow-Origin的HTTP响应头, 该响应头声明了请求域的可访问权限. 例如baidu.comgoogle.com下的getUsers.php发送了一个跨域的HTTP请求(通过ajax), 那么getUsers.php必须加入如下的响应头:

header("Access-Control-Allow-Origin: http://www.baidu.com");//表示允许baidu.com跨域请求本文件

flash URLLoder

flash有自己的一套安全策略, 服务器可以通过crossdomain.xml文件来声明能被哪些域的SWF文件访问,SWF也可以通过API来确定自身能被哪些域的SWF加载. 当跨域访问资源时, 例如从域a.com请求域 b.com上的数据, 我们可以借助flash来发送HTTP请求.

  • 首先, 修改域 b.com上的crossdomain.xml(一般存放在根目录, 如果没有需要手动创建) , 把a.com加入到白名单;
    <?xml version="1.0"?>
    <cross-domain-policy>
    <site-control permitted-cross-domain-policies="by-content-type"/>
    <allow-access-from domain="a.com" />
    </cross-domain-policy>
    
  • 其次, 通过Flash URLLoader发送HTTP请求, 拿到请求后并返回;
  • 最后, 通过Flash API把响应结果传递给JavaScript.
xmlLoader.dataFormat=URLLoaderDataFormat.TEXT;
xmlLoader.addEventListener(Event.COMPLETE,xmlLoaded);
private function xmlLoaded(event:Event){
	try {
		myXML = XML(event.target.data);
		area.text=myXML;
	}
	catch (e:TypeError) {
		area.text="Load faild:n"+e.message;
	}
} 

Flash URLLoader是一种很普遍的跨域解决方案,不过需要支持iOS的话,这个方案就不可行了.

WebSocket

在WebSocket出现之前, 很多网站为了实现实时推送技术, 通常采用的方案是轮询(Polling)Comet技术, Comet又可细分为两种实现方式, 一种是长轮询机制, 一种称为流技术, 这两种方式实际上是对轮询技术的改进, 这些方案带来很明显的缺点, 需要由浏览器对服务器发出HTTP request, 大量消耗服务器带宽和资源. 面对这种状况, HTML5定义了WebSocket协议, 能更好的节省服务器资源和带宽并实现真正意义上的实时推送.

WebSocket本质上是一个基于TCP的协议, 它的目标是在一个单独的持久链接上提供全双工(full-duplex), 双向通信, 以基于事件的方式, 赋予浏览器实时通信能力. 既然是双向通信, 就意味着服务器端和客户端可以同时发送并响应请求, 而不再像HTTP的请求和响应. (同源策略对 web sockets 不适用)

原理: 为了建立一个WebSocket连接,客户端浏览器首先要向服务器发起一个HTTP请求, 这个请求和通常的HTTP请求不同, 包含了一些附加头信息, 其中附加头信息”Upgrade: WebSocket”表明这是一个申请协议升级的HTTP请求, 服务器端解析这些附加的头信息然后产生应答信息返回给客户端, 客户端和服务器端的WebSocket连接就建立起来了, 双方就可以通过这个连接通道*的传递信息, 并且这个连接会持续存在直到客户端或者服务器端的某一方主动的关闭连接.

一个典型WebSocket客户端请求头:
由同源策略到前端跨域
参考:
由同源策略到前端跨域
前端跨域总结

相关标签: 同源策略