情景复现
某天正式环境有用户反馈某页面操作没有任何响应,SRE
接收到反馈后,对问题分析复现,复现步骤如下:
用户登录商家工作台后复制页签,开启了两个页签,其中一个页签退出登录,另一个页签点击操作
另外,SRE
还收集了控制台输出错误信息:
问题分析
根据报错信息来看,明显提示重定向后的请求跨域了。当时我认为设置了Loacation
标头的Http 302
重定向响应,浏览器地址栏会接着访问重定向后的链接,不应该存在同源策略的限制。但实际情况并不是想象中那般,为了解决自己的疑惑,结合场景重新分析一遍跨域问题。
什么情况下需要 CORS
?
这份 cross-origin sharing standard 允许在下列场景中使用跨站点 HTTP
请求:
- 出于安全性,浏览器限制脚本内发起的跨源 HTTP 请求。例如,
XMLHttpRequest
和 Fetch API 遵循同源策略(XHR
和fetch
请求类型)。这意味着使用这些API
的Web
应用程序只能从加载应用程序的同一个域请求HTTP
资源,除非响应报文包含了正确CORS
响应头。 Web
字体 (CSS
中通过@font-face
使用跨源字体资源),因此,网站就可以发布 TrueType 字体资源,并只允许已授权网站进行跨站调用。- WebGL 贴图
- 使用
drawImage
将Images/video
画面绘制到canvas
。 - 来自图像的 CSS 图形 (en-US)
Request Type
请求类型有Fetch/XHR
、JS
、CSS
、Img
、Media
、Font
、Doc
、WS (WebSocket)
、Wasm (WebAssembly)
、Manifest
或 other
(此处未列出的任何其他类型),从chrome
网络面板可以筛选查看。
根据 cross-origin sharing standard ,可知数以Doc
类型的地址栏请求、form
表单请求不会受同源策略限制,<script src="url"></script>
与<link href=""></link>
也不会受同源策略限制,但JavaScript
脚本内部发起的fetch/ajax
请求会受到同源策略的限制。
如果在地址栏直接请求js
、css
、png
资源,请求类型也是document
,同样不受同源策略影响。
用fetch/ajax
请求这类资源也不会跨域,因为CDN
服务一般会设置Access-Control-Allow-Origin: *
。
浏览器地址栏里面输入一个URL
重定向会发生什么?
- 当用户开始在地址栏中输入内容时,
UI
线程询问的第一件事是“您输入的字符串是搜索的关键词(search query
)还是一个URL
地址?”。因为对于Chrome
来说,地址栏的输入既可能是一个可以直接请求的URL
,还可能是用户想在搜索引擎(例如Google
)里面搜索的关键词信息,所以UI
线程需要解析并决定是将用户输入发送到搜索引擎还是直接请求你输入的站点资源。 - 当用户按下回车键的时候,
UI
线程会叫网络线程(network thread
)初始化一个网络请求来获取站点的内容。这时如果网络线程收到服务器的HTTP 301
重定向响应,它就会告知UI
线程进行重定向,然后它会再次发起一个新的网络请求。 - 网络线程在收到
HTTP
响应的主体(payload
)流(stream
)时,在必要的情况下它会先检查一下流的前几个字节以确定响应主体的具体媒体类型(MIME Type
)。如果响应的主体是一个HTML
文件,浏览器会将获取的响应数据交给渲染进程(renderer process
)来进行下一步的工作。如果拿到的响应数据是一个压缩文件(zip file
)或者其他类型的文件,响应数据就会交给下载管理器(download manager
)来处理。
注:上述流程删减了后续html
文件解析渲染流程,这部分跟本文内容无关
OAuth2.0
授权码登录重定向过程为什么不会出现跨域问题?
表单提交,页面跳转
登陆页面一般采用表单提交<form action="URL of page">...<form>
,将表单数据提交到指定URL
的服务程序处理,并跳转到指定URL
。如果想阻止表单提交,可以使用e.preventDefault();
或者return false
,一般在表单校验不通过时阻止提交(不会请求)。
<form action="URL of page" method="post" id="form">
<input value="登录" type="submit" onclick="handleSubmit(event)"/> // 注意type类型
</form>
function handleSubmit(e) {
e.preventDefault();
// return false;
}
以下方式不会阻止表单提交,因为document.getElementById('form').submit()
会触发表单提交,没法阻断。
<form action="URL of page" method="post" id="form">
<input value="登录" type="button" onclick="handleSubmit(event)"/>
</form>
function handleSubmit(e) {
e.preventDefault();
document.getElementById('form').submit();
// return false;
}
如果想要表单提交后不跳转(请求但不跳转),可以通过以下方式:
<form
action="https://at.alicdn.com/t/font_1353866_klyxwbettba.css"
method="get"
id="loginForm"
target="frameName"
>
<input type="submit" value="submit" />
</form>
<iframe src="" frameborder="0" name="frameName"></iframe>
跳转到iframe
窗口,不影响当前页签显示请求的样式内容
登录授权重定向过程
我司使用<form action="login" method="post"/>
表单实现登录授权,点击“登录”通过document.getElementById('fml').submit()
触发表单提交。表单提交后,渲染进程通过IPC
通信告知浏览器进程导航至指定/passport/login
(同源),网络线程初始化一个请求将表单数据发送给指定服务/passport/login
。服务端校验账户密码正确性,若账号密码正确,网络线程会接收到服务器的HTTP 302
重定向响应,它就会告知UI
线程进行重定向然后它会再次发起一个新的网络请求。后续无论是客户端再根据code
获取token
,还是客户端根据token
向受保护资源服务请求html
资源都是通过地址栏重定向完成的,地址栏请求类型是document
,根据 cross-origin sharing standard
规定,不存在跨域问题。
为什么服务端指定了响应标头允许cors
请求还是跨域了呢?
复制打开两个页签,其中一个页签退出登录,另一个页签触发ajax
请求时,由于接口没携带身份信息,网关服务返回Http 302
重定向响应体,客户端向重定向链接发起ajax
请求,发起的请求和当前页签不同源,虽然后端配置了响应标头Access-Control-Allow-Origin
指定了允许cors
请求的域名,但还是出现了跨域问题。
注:根据 cross-origin sharing standard
规定,页面脚本发起请求类型是XHR
,会有跨域限制。
具有有以下两种场景:
(1)点击”查询“,发起POST
请求
查询请求POST https://ec-hwbeta.casstime.com/inquiryWeb/quote/list
返回Http 302
响应体,客户端向重定向链接发起ajax
请求(GET https://ec-hwbeta.casstime.com/oauth2/authorization/cassmall
),请求同源不会出现跨域。服务端再次返回Http 302
响应体,客户端重复上面步骤,向重定向链接发起ajax
请求(GET https://passport-test.casstime.com/sso/oauth/authorize
),此次请求不同源,出现跨域问题。
该场景有两个疑问点:
- 后端服务
https://passport-test.casstime.com
配置了响应标头Access-Control-Allow-Origin
指定了允许cors
请求的域名,但还是出现了跨域问题。 - 向重定向链接
GET https://passport-test.casstime.com/sso/oauth/authorize
发起ajax
真实请求之前会发送一个preflight
预检请求。
(2)点击”立即报价“领取报价单,发起GET
请求
发起领取请求GET https://ec-hwbeta.casstime.com/agentBuy/seller/admin/supplierquotes/receiveinquiry
,服务端返回Http 302
响应体,客户端向重定向链接发起ajax
请求(GET https://ec-hwbeta.casstime.com/oauth2/authorization/cassmall
),请求同源不会出现跨域。服务端再次返回Http 302
响应体,客户端重复上面步骤,向重定向链接发起ajax
请求(GET https://passport-test.casstime.com/sso/oauth/authorize
),此次请求不同源,出现跨域问题。
该场景也有两个疑问点:
- 后端服务
https://passport-test.casstime.com
配置了响应标头Access-Control-Allow-Origin
指定了允许cors
请求的域名,但还是出现了跨域问题。 - 发送真实请求之前并没有像场景一一样发送
preflight
预检请求(与第一个场景的不同点)。
注:以上场景均在Chrome
浏览器验证,不同浏览器对重定向实现的标准不一样
两个场景唯一的不同点在于初始请求是POST
复杂请求(Content-Type: application/json
),还是GET
简单请求,那么复杂请求和简单请求重定向有什么区别呢?
复杂请求和简单请求重定向有什么区别?
非简单请求是 preflight 成功后才发送实际的请求。preflight
后的实际请求不允许重定向,否则会导致 CORS
跨域失败。
虽然在 Chrome
开发版中会对重定向后的地址再次发起 preflight
,但该行为并不标准。 W3C Recommendation 中提到真正的请求返回 301
, 302
, 303
, 307
, 308
都会判定为错误:
This is the actual request. Apply the make a request steps and observe the request rules below while making the request. If the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the cache and network error steps. – W3C CORS Recommendation
在 Chrome
中错误信息是 Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
:
Access to XMLHttpRequest at 'https://passport-test.casstime.com/sso/oauth/authorize?response_type=code&client_id=cassmall-alpha&state=AE5D6mbF-28uCXQekXaz3-UyauYiOfvG_e9BZH_U8NM%3D&redirect_uri=https://ec-alpha.casstime.com/login/oauth2/code/cassmall' (redirected from 'https://ec-alpha.casstime.com/inquiryWeb/quote/list') from origin 'https://ec-alpha.casstime.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
对于简单请求,浏览器会跳过 preflight
直接发送真正的请求。 该请求被重定向后浏览器会直接访问被重定向后的地址,也可以跟随多次重定向。 但重定向后请求头字段 origin
会被设为 "null"
(被认为是 privacy-sensitive context
)。 这意味着响应头中的 Access-Control-Allow-Origin
需要是 *
或者 null
字符串(该字段不允许多个值)。这就是为什么服务配置了指定了具体的Access-Control-Allow-Origin
还是跨域了。
在chrome
中错误信息是No 'Access-Control-Allow-Origin' header is present on the requested resource.
Access to XMLHttpRequest at 'https://passport-test.casstime.com/sso/oauth/authorize?response_type=code&client_id=cassmall&state=6OxlZhoSFAacnuOSapRCCjZhtM5nAlf3JLFZt5gP9P0%3D&redirect_uri=https://ec-hwbeta.casstime.com/login/oauth2/code/cassmall' (redirected from 'https://ec-hwbeta.casstime.com/agentBuy/seller/admin/supplierquotes/receiveinquiry?inquiryId=xxx&storeId=xxx&supplierCompanyId=xxx&neededClock=xxx&acceptPlace=xxx') from origin 'https://ec-hwbeta.casstime.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
即使浏览器给简单请求设置了非 简单头字段(如 DNT
)时,也应当继续跟随重定向且不校验响应头的 DNT
(因为它属于 User Agent Header
,浏览器应当对此知情)。 参考 W3C 对简单请求的处理要求:
If the manual redirect flag is unset and the response has an HTTP status code of 301, 302, 303, 307, or 308 Apply the redirect steps. – W3C CORS Recommendation
OSX
下 Chrome
的行为是合理的,即使设置了 DNT
也会直接跟随重定向。
后端服务拦截处理
后端服务通过判断请求标头Origin
是否在允许的白名单中,如果在,则设置Access-Control-Allow-Origin
的值为请求标头Origin
场景复现与分析
场景一复现:
本地搭建3001
端口服务
const http = require("http");
const whiteList = ["localhost:3000"]; // 白名单
const server = http.createServer((req, res) => {
const origin = req.headers.origin;
console.log(
`url: ${req.url}, origin: ${origin}, method: ${req.method.toLowerCase()}`
);
if (
whiteList.includes(
origin.slice(
origin.indexOf("://") + 3,
origin.endsWith("/") ? origin.length - 1 : origin.length
)
)
) {
res.setHeader("Access-Control-Allow-Origin", `${origin}`);
res.setHeader(
"Access-Control-Allow-Methods",
"PUT, GET, POST, DELETE, OPTIONS"
);
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
if (req.method.toLowerCase() === "options") {
res.statusCode = 200;
res.end();
}
if (req.url === "/order/detail" && req.method.toLowerCase() === "post") {
res
.writeHead(302, {
Location: "http://127.0.0.1:3001/order/id",
})
.end();
}
if (req.url === "/order/id" && req.method.toLowerCase() === "get") {
res
.writeHead(302, {
Location: "http://127.0.0.1:3002/order/user",
})
.end();
}
});
server.listen(3001, () => {
console.log("server is listening port 3001");
});
本地搭建3002
端口服务
const http = require("http");
const whiteList = ["localhost:3000"];
const server = http.createServer((req, res) => {
const origin = req.headers.origin;
console.log(
`url: ${req.url}, origin: ${origin}, method: ${req.method.toLowerCase()}`
);
if (
whiteList.includes(
origin.slice(
origin.indexOf("://") + 3,
origin.endsWith("/") ? origin.length - 1 : origin.length
)
)
) {
res.setHeader("Access-Control-Allow-Origin", `${origin}`);
res.setHeader(
"Access-Control-Allow-Methods",
"PUT, GET, POST, DELETE, OPTIONS"
);
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
if (req.method.toLowerCase() === "options") {
res.statusCode = 200;
res.end();
}
if (req.url === "/order/user" && req.method.toLowerCase() === "get") {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>hello world~</div>
</body>
</html>`);
}
});
server.listen(3002, () => {
console.log("server is listening port 3002");
});
页面执行调用:
// 域名为http://localhost:3000页面脚本访问
axios.post('http://127.0.0.1:3001', {});
日志打印:
结论:可以看到非简单请求后的重定向请求确实会发送preflight
预检请求,当从http://127.0.0.1:3001/order/id
重定向到http://127.0.0.1:3002/order/user
,请求标头Origin
为null
字符串,不在白名单中,响应不会携带Access-Control-Allow-Origin
,自然就跨域了。
注意:预检请求返回状态码为200并不意味着其通过了跨域检查,是否通过跨域检查主要看请求标头Origin
与Access-Control-Allow-Origin
是否匹配
场景二复现
本地搭建的3001
端口服务中/order/detail
接口改成get
请求类型
if (req.url === "/order/detail" && req.method.toLowerCase() === "get") {
res
.writeHead(302, {
Location: "http://127.0.0.1:3001/order/id",
})
.end();
}
日志打印
结论:可以看到简单请求后的重定向请求不会发送预检请求,当从http://127.0.0.1:3001/order/id
重定向到http://127.0.0.1:3002/order/user
,请求标头Origin
同样为null
字符串,跨域了。
解决方案
(1)将xhr/fetch
请求类型改成document
请求类型
form
表单请求属于document
请求类型,天生不会有跨域问题,刚好可以满足我们的需求。
改成document
请求类型后,业务接口响应的数据通过iframe
接收,形式如下:
<form
action="http://127.0.0.1:8080/order/detail"
method="post"
target="form"
>
<span style="margin-right: 20px">
<label for="name"></label>
<input type="text" name="name" id="name" />
</span>
<span style="margin-right: 20px">
<label for="password"></label>
<input type="password" name="password" id="password" />
</span>
<input type="submit" value="提交" />
</form>
<iframe
id="form"
name="form"
frameborder="0"
style="display: none"
></iframe>
<script>
// 获取表单请求的结果
$("#form").load(function () {
var text = $(this).contents().find("body").text(); //获取到的是json的字符串
// var j = $.parseJSON(text); //json字符串转换成json对象
console.log(text);
});
</script>
获取到请求响应后,判断其是否为登录页,如果是,则前端重定向到登陆页。在实际项目中需要将上述方式封装成一个请求方法。
但是,改成form
表单请求也存在一些不合理之处:
- 如果重定向过程中某个链接与当前页签不同源,使用
iframe
就会有跨域问题,因为iframe
的源必须跟当前页签的源一致; - 页面绝大多数业务请求都是采用
ajax
请求类型,如果全盘调整为form
表单请求显然不合理;
表单请求、地址栏请求时,属于document
请求类型,node
服务端接收到请求标头Origin: undefined
(不是字符串),Java
没有undefined
类型,不清楚服务端接收到的是什么
(2)将null
字符串加入白名单,前端拦截重定向到登录页
当跨域名重定向时,请求标头Origin
为null
字符串,可以将null
字符串加入到白名单中
const whiteList = ["localhost:3000", "null"]; // 白名单
if (
whiteList.includes(
origin.slice(
origin.indexOf("://") > -1 ? origin.indexOf("://") + 3 : 0,
origin.endsWith("/") ? origin.length - 1 : origin.length
)
)
) {
res.setHeader("Access-Control-Allow-Origin", `${origin}`);
res.setHeader(
"Access-Control-Allow-Methods",
"PUT, GET, POST, DELETE, OPTIONS"
);
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
或者
res.setHeader("Access-Control-Allow-Origin", "*");
虽然上述方式解决了跨域问题,但还存在后续问题,OAuth2.0
授权登录重定向过程最后一个请求会返回text/html
类型内容(登录页),但是fetch
或者xhr
请求接收到该类型内容并不会渲染到页签中(以下为模拟场景)。
可以将null
字符串加入到白名单中解决掉跨域问题,然后前端拦截处理,重定向到登录页:
axios.interceptors.response.use(
(response) => {
/** 判断重定向后的responseURL是否为登陆页面,如果是,则重定向到登录页 */
if (response.request.status === 200 && response.request.responseURL.includes('/order/user')) {
window.location.href = response.request.responseURL; // 重定向到登录页
}
return response;
},
(error) => {}
)
但是null
字符串加入到白名单,意味着弱化了同源策略的保护。
(3)不纠正跨域问题,前端直接拦截跨域响应
前端接收到跨域响应后,统一拦截重定向到登录页面。跨域会产生Network Error
告错信息,并且status = 0
和responseURL = ""
。
axios.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.message === 'Network Error' && error.request.status === 0 && error.request.responseURL === '') {
/** 跨域重定向到登录页 */
window.location.href = `/passport/login${window.location.hash}`;
}
}
)
但是,如果请求链接存在广告关键字(比如,adv-api/xxx
),并且浏览器启用了广告拦截插件,则该请求同样会产生Network Error
错误信息,并且status = 0
和responseURL = ""
。无法预料是否还存在其他场景产生同样的错误,所以该方案不成熟。
XMLHttpRequest.responseURL`
只读属性 XMLHttpRequest.responseURL
返回响应的序列化 URL
,如果 URL
为空则返回空字符串。如果 URL
有锚点,则位于 URL #
后面的内容会被删除。如果 URL
有重定向,responseURL
的值会是经过多次重定向后的最终 URL
。
- 场景一,接口返回
Http 200
响应,如果有锚点,则位于URL #
后面的内容会被删除
- 场景二,接口返回
Http 303
响应,但没有设置Location
标头
- 场景三,接口返回
Http 302
响应,有设置Location
标头,并且访问重定向地址成功了
- 场景四,接口返回
Http 302
响应体,有设置Location
,但访问重定向的地址跨域了
用原生XMLHttpRequest
请求,可以看到重定向跨域后reponseURL=""
axios
封装xhr
的请求会包装一个Network Error
错误
axios.get('http://127.0.0.1:3001/order/detail');
- 场景五,接口返回
Http 400
响应
axios
请求包装错误信息“Request failed with status code 400”
,与原生XMLHttpRequest
请求一样responseURL
返回响应序列化的URL
- 场景六,接口返回
Http 500
响应
场景七,请求链接含有广告关键字,浏览器启用广告拦截插件
// 含有广告关键字ad-api axios.get('/ad-api/validate/system-config/get?keyword=MIN_AD_EXPOSURE_TIME');
总结
回到文章开头提出的问题,另开一个页签,退出其中一个页签,在另一个页签访问服务,请求跨域的原因在于CORS
会对重定向后的XHR
请求进行同源检测,而重定向后的请求Origin
为字符串'null'
,不在白名单中,被认为是跨域了。
至于解决方案,在不大改的情况下,除了将字符串null
加入白名单,前端拦截响应判断是否跳转到登录页,还未想到其他优雅的方法,以后想到了再补充完善。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。