文章目录
同源政策 same-origin policy
背景
cookie 可以在不同源的网站之间共享。
A网站是一家银行,用户登录以后,没退出。打开新标签页去浏览其他网站。如果这个 其他网站是个黑客网站,因为 cookie 可以在浏览器各个标签页中共享的,
很显然,如果 Cookie 包含隐私(比如存款总额),这些信息就会泄漏。更可怕的是,Cookie 往往用来保存用户的登录状态,如果用户没有退出登录,黑客网站就可以冒充用户。加上浏览器同时还规定,提交表单不受同源政策的限制。所以黑客网站可以冒充该用户向银行网站提交转账的表单。
这一切显然是不能接受的,cookie 不应该能共享。因此浏览器提出了同源政策。
同源政策的目的:保证用户信息的安全,防止恶意的网站窃取数据。
同源判定
所谓"同源"指的是"三个相同":
- 协议相同
- 域名相同
- 端口相同
同源限制
如果非同源,共有三种行为受到限制:
- 无法读取其他源的 Cookie、LocalStorage 和 IndexDB。
- 无法获得其他源的 DOM
- 无法向其他源发起 AJAX 请求
规避同源限制
Cookie、LocalStorage、IndexDB
Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享 Cookie。
LocalStorage 和 IndexDB 需要使用 PostMessage API。
DOM
二如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain属性,就可以规避同源政策,拿到DOM。
完全不同源的办法:
- 片段识别符(fragment identifier)(URL的#号后面的部分)
- window.name
- 跨文档通信API(Cross-document messaging)PostMessage
AJAX
同源政策规定,AJAX请求只能发给同源的网址,否则就报错。
除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),还有三种方法规避这个限制:
- JSONP
- WebSocket
- CORS
Cookie 跨域共享
Cookie 是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享 Cookie。
举例来说,A网页是http://w1.example.com/a.html,B网页是http://w2.example.com/b.html,那么只要设置相同的document.domain,两个网页就可以共享Cookie。
document.domain = 'example.com';
现在,A网页通过脚本设置一个 Cookie。
document.cookie = "test1=hello";
B网页就可以读到这个 Cookie。
var allCookie = document.cookie;
注意,这种方法只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 无法通过这种方法,规避同源政策,而要使用下文介绍的PostMessage API。
另外,服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如.example.com。
Set-Cookie: key=value; domain=.example.com; path=/
这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。
iframe 数据通信
如果两个网页不同源,就无法拿到对方的 DOM。典型的例子是 iframe 窗口和window.open
方法打开的窗口,它们与父窗口无法通信。
其实与其说这里是拿到 iframe 中的 dom,不如说是与 iframe 之间的数据通信。
比如,父窗口运行下面的命令,如果iframe窗口不是同源,就会报错。
document.getElementById("myIFrame").contentWindow.document
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.
上面命令中,父窗口想获取子窗口的DOM,因为跨源导致报错。
反之亦然,子窗口获取主窗口的DOM也会报错。
window.parent.document.body
// 报错
如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain
属性,就可以规避同源政策,拿到DOM。
片段识别符
片段标识符(fragment identifier)指的是,URL的#号后面的部分,比如http://example.com/x.html#fragment的#fragment。如果只是改变片段标识符,页面不会重新刷新。
父窗口可以把 dom 信息,写入子窗口的片段标识符。
var src = originURL + '#' + data;
document.getElementById('myIFrame').src = src;
子窗口通过监听hashchange事件得到父窗口传递的数据。
window.onhashchange = checkMessage;
function checkMessage() {
var message = window.location.hash;
// ...
}
同样的,子窗口也可以改变父窗口的片段标识符。
parent.location.href= target + "#" + hash;
window.name
浏览器窗口有window.name
属性。这个属性的最大特点是,无论是否同源,只要在同一个窗口里,前一个网页设置了这个属性,后一个网页也可以读取它。
父窗口先打开一个子窗口,载入一个不同源的网页,将该网页将信息写入window.name
属性。
window.name = data;
接着,子窗口跳回一个与主窗口同域的网址。
location = 'http://parent.url.com/xxx.html';
然后,主窗口就可以读取子窗口的 window.name 了。
var data = document.getElementById('myFrame').contentWindow.name;
这种方法的优点是,window.name 容量很大,可以放置非常长的字符串;缺点是必须监听子窗口window.name 属性的变化,影响网页性能。
window.postMessage 跨页面通信
上面两种方法都属于找漏洞的奇淫巧技。
HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。
这个API为 window 对象新增了一个window.postMessage
方法,允许跨窗口通信,不论这两个窗口是否同源。
举例来说,父窗口http://aaa.com向子窗口http://bbb.com发消息,调用postMessage方法就可以了。
var popup = window.open('http://bbb.com', 'title');
popup.postMessage('Hello World!', 'http://bbb.com');
postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即"协议 + 域名 + 端口"。也可以设为*
,表示不限制域名,向所有窗口发送。
子窗口向父窗口发送消息的写法类似。
window.opener.postMessage('Nice to see you', 'http://aaa.com');
父窗口和子窗口都可以通过message
事件,监听对方的消息。
window.addEventListener('message', function(e) {
console.log(e.data);
},false);
message
事件的事件对象event
,提供以下三个属性。
- event.source:发送消息的窗口
- event.origin: 消息发向的网址
- event.data: 消息内容
下面的例子是,子窗口通过event.source
属性引用父窗口,然后发送消息。
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
event.source.postMessage('Nice to see you!', '*');
}
event.origin
属性可以过滤不是发给本窗口的消息。
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
if (event.origin !== 'http://aaa.com') return;
if (event.data === 'Hello World') {
event.source.postMessage('Hello', event.origin);
} else {
console.log(event.data);
}
}
LocalStorage
通过window.postMessage
,读写其他窗口的 LocalStorage 也成为了可能。
下面是一个例子,主窗口写入iframe子窗口的localStorage
。
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') {
return;
}
var payload = JSON.parse(e.data);
localStorage.setItem(payload.key, JSON.stringify(payload.data));
};
上面代码中,子窗口将父窗口发来的消息,写入自己的LocalStorage。
父窗口发送消息的代码如下。
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
win.postMessage(JSON.stringify({key: 'storage', data: obj}), 'http://bbb.com');
加强版的子窗口接收消息的代码如下。
window.onmessage = function(e) {
if (e.origin !== 'http://bbb.com') return;
var payload = JSON.parse(e.data);
switch (payload.method) {
case 'set':
localStorage.setItem(payload.key, JSON.stringify(payload.data));
break;
case 'get':
var parent = window.parent;
var data = localStorage.getItem(payload.key);
parent.postMessage(data, 'http://aaa.com');
break;
case 'remove':
localStorage.removeItem(payload.key);
break;
}
};
加强版的父窗口发送消息代码如下。
var win = document.getElementsByTagName('iframe')[0].contentWindow;
var obj = { name: 'Jack' };
// 存入对象
win.postMessage(JSON.stringify({key: 'storage', method: 'set', data: obj}), 'http://bbb.com');
// 读取对象
win.postMessage(JSON.stringify({key: 'storage', method: "get"}), "*");
window.onmessage = function(e) {
if (e.origin != 'http://aaa.com') return;
// "Jack"
console.log(JSON.parse(e.data).name);
};
AJAX
同源政策规定,AJAX请求只能发给同源的网址,否则就报错。
除了架设服务器代理(浏览器请求同源服务器,再由后者请求外部服务),有三种方法规避这个限制。
- JSONP
- WebSocket
- CORS
jsonp
WebSocket
WebSocket是一种通信协议,使用ws://
(非加密)和wss://
(加密)作为协议前缀。该协议不实行同源政策,只要服务器支持,就可以通过它进行跨源通信。
下面是一个例子,浏览器发出的WebSocket请求的头信息(摘自维基百科)。
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
上面代码中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。
正是因为有了Origin这个字段,所以WebSocket才没有实行同源政策。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
CORS
CORS 是跨源资源分享(Cross-Origin Resource Sharing)的缩写。它是 W3C 标准,是跨源 AJAX 请求的根本解决方法。相比JSONP只能发GET请求,CORS 允许任何类型的请求。
cors 相当于白名单机制。
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
请求方法是以下三种方法之一:
- HEAD
- GET
- POST
HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
凡是不同时满足上面两个条件,就属于非简单请求。
可见,我们最常用的虽然是 get post,但 Content-Type 字段的类型通常为 application/json
。所以属于非简单请求。
CORS —— 简单请求
浏览器发现这次跨源 AJAX 请求是简单请求,就自动在头信息之中,添加一个Origin
字段。Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。
服务器根据这个值,决定是否同意这次请求。
如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin
字段(详见下文),就知道出错了,从而抛出一个错误,被 XMLHttpRequest 的 onerror 回调函数捕获。
注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。
如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8
上面的头信息之中,有三个与CORS请求相关的字段,都以 Access-Control- 开头。
Access-Control-Allow-Origin
该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个*
,表示接受任意域名的请求。
Access-Control-Allow-Credentials
该字段可选。它的值是一个布尔值,表示是否允许浏览器发送 Cookie。默认情况下,跨域请求不会携带 cookie。
设为 true,即表示服务器明确许可,Cookie 可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送 Cookie,删除该字段即可。
Access-Control-Expose-Headers
该字段可选。表示暴露给客户端的响应头。
CORS 请求时,XMLHttpRequest 对象的 getResponseHeader()
方法默认只能拿到 6 个基本响应头:Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
。
如果想拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。
withCredentials & credentials
上面说到,跨域请求默认不发送 Cookie 和 HTTP 认证信息。如果要把 Cookie 发到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials
字段为 true。
另一方面,开发者必须在AJAX请求中打开withCredentials
属性:
// XMLHttpRequest
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
fetch 就是 credentials
配置项:
// fetch
fetch('http://another.com', {
credentials: "include"
});
否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。
但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials
需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin 就不能设为星号,必须指定明确的、与请求网页一致的域名。
同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。
CORS —— 非简单请求
预检请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。
非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。
浏览器发现该请求是一个跨域请求,就会先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。
只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest 请求,否则就报错。
"预检"请求用的请求方法是OPTIONS
,表示这个请求是用来询问的。头信息里面,关键字段是 Origin,表示请求来自哪个源。
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
除了Origin字段,"预检"请求的头信息包括两个特殊字段。
Access-Control-Request-Method
该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是 PUT。
Access-Control-Request-Headers
该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段。上例是X-Custom-Header。
预检请求的响应
服务器收到"预检"请求以后,检查了Origin
、Access-Control-Request-Method
和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://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
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
如果服务器返回了一个正常的HTTP回应,但是没有任何 CORS 相关的头信息字段。这就说明服务端否定了这次的跨域请求。
这时,浏览器也就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror 回调函数捕获。控制台会打印出跨域最常见的报错信息。
XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.
服务器回应的其他CORS相关字段如下:
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
Access-Control-Allow-Methods
该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。
注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。
Access-Control-Allow-Headers
如果浏览器请求头中包括了 Access-Control-Request-Headers 字段,则响应时, Access-Control-Allow-Headers 字段就是必需的。
它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。
Access-Control-Allow-Credentials
该字段与简单请求时的含义相同。
Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。
浏览器的正常请求和回应
一旦服务器通过了"预检"请求,以后每次浏览器正常的 CORS 请求,就都跟简单请求一样,会有一个Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。
下面是"预检"请求之后,浏览器的正常CORS请求。
PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
上面头信息的Origin字段是浏览器自动添加的。
下面是服务器正常的回应。
Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8
上面头信息中,**Access-Control-Allow-Origin**
** 字段是每次回应都必定包含的。**
解决 AJAX 跨域
同域部署
同域部署是一种简单有效的避免跨域问题的方式。其核心思想是将前端静态资源和后端服务部署在同一个域下,这样前端发送的请求都会在同一域名下进行,从而避免了浏览器的同源策略限制。
通过 location 判断 URL 判断是请求的前端还是接口。如果是接口,则反向代理到真正的服务端服务。
实现步骤:
- 准备前端代码:
- 前端代码通常包括HTML、CSS、JavaScript等静态资源。
- 使用构建工具(如Webpack、Gulp等)将前端代码打包,准备部署。
- 准备后端服务:
- 后端服务通常是API接口、数据库操作等。
- 确保后端服务能够处理前端发送的请求。
- 配置Web服务器:
- 选择一个Web服务器,如Apache、Nginx等。
- 配置服务器,使其能够处理前端静态资源和后端服务请求。
- 部署前端代码:
- 将打包好的前端代码部署到Web服务器的特定目录下,例如
/var/www/html
。
- 将打包好的前端代码部署到Web服务器的特定目录下,例如
- 部署后端服务:
- 将后端服务部署到同一台服务器上,可以选择不同的目录,如
/api
。
- 将后端服务部署到同一台服务器上,可以选择不同的目录,如
- 配置服务器路由:
- 修改Web服务器的配置,设置路由规则,使得不同的请求路径映射到不同的处理方式。
- 例如,对于Nginx,可以设置当请求为HTML、CSS、JS等静态资源时,直接从部署前端代码的目录中获取;当请求为API接口时,转发到后端服务的地址。
- 域名解析:
- 将域名解析到部署了前端和后端的服务器IP地址。
- 测试:
- 在浏览器中访问域名,测试前端页面是否能够正常加载,并且与后端服务的交互是否正常。
示例:Nginx配置
假设前端代码部署在/var/www/html
目录下,后端服务运行在http://localhost:3000
。
server {
listen 80;
server_name example.com;
location / {
root /var/www/html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
在这个配置中,当用户请求example.com
时,Nginx会检查/var/www/html
目录下是否有匹配的静态资源,如果有,则直接返回静态资源;如果没有,则返回index.html
。当请求路径以/api
开头时,Nginx会将请求代理到本地的后端服务http://localhost:3000
。
通过这种方式,前端和后端都在同一个域下,避免了跨域问题。
代理转发原始请求信息
在Nginx配置文件中,location /api/
块定义了当请求路径以 /api
开头时,Nginx 应该如何处理这些请求。在这个块中,proxy_pass
和相关的 proxy_set_header
指令用于配置反向代理的行为。下面是每个字段的含义:
proxy_pass http://localhost:3000;
:- 这个指令告诉 Nginx 将匹配
/api/
的请求代理到http://localhost:3000
这个地址。这意味着所有到/api/
的请求都会被转发到本地的3000端口上运行的服务。
- 这个指令告诉 Nginx 将匹配
proxy_set_header Host $host;
:- 这个指令设置了转发请求时的
Host
头部。$host
是一个 Nginx 变量,它包含了客户端请求的原始Host
头部值。这样,后端服务就会收到与原始请求相同的Host
头部,这对于后端服务的虚拟主机配置和请求路由非常重要。
- 这个指令设置了转发请求时的
proxy_set_header X-Real-IP $remote_addr;
:- 这个指令设置了
X-Real-IP
头部,将其值设置为$remote_addr
,即客户端的 IP 地址。这样,后端服务就可以知道请求实际上来自哪个 IP 地址,这在日志记录和访问控制等方面非常有用。
- 这个指令设置了
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
:- 这个指令设置了
X-Forwarded-For
头部,用于识别通过 HTTP 代理或负载均衡器传递过来的客户端原始 IP 地址。$proxy_add_x_forwarded_for
变量包含客户端请求的X-Forwarded-For
头部值,如果客户端请求没有这个头部,则变量值为空。Nginx 会将客户端的 IP 地址追加到这个头部值后面,这样后端服务就能获取到完整的客户端 IP 地址链。
- 这个指令设置了
这些指令共同作用,确保了代理转发的请求能够保留足够的原始请求信息,以便后端服务能够正确处理这些请求。同时,它们也帮助维护了服务器的安全性和请求的透明性。
代理
代理分为正向代理和反向代理。应用场景不一样,前者一般是前端开发时用,后者是正式环境使用。
正向代理 —— 开发服务器
构建工具代理服务
开发 SPA 应用时,往往会启动开发服务器,热更新,时时查看编辑效果。那开发服务必定和后端服务发生跨域。
我们知道同源策略只有浏览器才有,所以构建工具不仅开启了一个静态资源服务进行热更新,还可以通过插件开一个代理服务转发客户端的请求。
注意:代理服务不是又起了一个 node HTTP 服务,浏览器面对的只有一个 node HTTP 服务。代理服务是在原本的开发服务上添加了一个接口。所以热更新和代理服务对浏览器都是同源的,没有跨域。
构建工具搭建代理服务一般都使用了 http-proxy-middleware
。
自建代理服务
当然,我们也可以不使用构建工具提供的代理服务,自己搭建一个代理服务。
但要保证两点:
- 代理服务器和浏览器之间要能处理跨域情况,因为开发服务和自己搭建的代理服务肯定跨域了。
- 代理服务器要拿到浏览器的请求 url 进行重写,拼接出真正的 api 服务器 url,然后拿着这个 url 像目标服务器发起请求。
// include dependencies
var express = require('express');
var proxy = require('http-proxy-middleware');
// proxy middleware options
var options = {
target: 'http://www.example.org', // target host
changeOrigin: true, // needed for virtual hosted sites
ws: true, // proxy websockets
pathRewrite: {
'^/api/old-path' : '/api/new-path', // rewrite path
'^/api/remove/path' : '/path' // remove base path
},
router: {
// when request.headers.host == 'dev.localhost:3000',
// override target 'http://www.example.org' to 'http://localhost:8000'
'dev.localhost:3000' : 'http://localhost:8000'
}
};
// create the proxy (without context)
var exampleProxy = proxy(options);
// mount `exampleProxy` in web server
var app = express();
app.use('/api', exampleProxy);
app.listen(3000);
反向代理
nginx 反向代理服务器和 node 代理服务器本质是一样的,都是代理服务器,都需要设置为和浏览器同源或者对浏览器允许跨域,告诉浏览器不要限制。
具体操作已经在同源部署中讲过了。这里补充一下重写 URL rewrite。
server {
listen 1111;
server_name localhost;
location / {
root html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# path 匹配到 /api 就将请求转到 http://127.0.0.1:2222
location /api {
rewrite /api/(.*) /$1 break; # 重写 URL (可选)
proxy_pass http://127.0.0.1:2222;
}
}
上面这种反向代理后台服务的方式。以浏览器的视角来讲,它访问的都是一个源:http://127.0.0.1:1111。
只是请求发过去,返回的是静态 HTML 文件,还是后端接口的数据,都靠 location 字段设置的 URL 匹配规则来区分,比如接口服务一般匹配 path 为/api,所以前端中请求接口数据,url 必须带上/api。
注意:可能需要使用到rewrite
,它可以重写 url ,然后将重写后的 url 传递给后续服务。
rewrite <正则表达式匹配浏览器过来的url> <$1拿到匹配的结果,可重新组成新的url> break(结束);
什么情况下需要重写 url 呢?
我们知道浏览器获取到HTML,还是接口数据,靠的就是请求url中多了一个/api
。
- /user 表示请求 user 页面
- /api/user 表示请求 user 接口数据
此时浏览器向 /api/user 发起请求,请求被转发给了 http://127.0.0.1:2222 接口服务。如果后端的服务接口路由没有 /api 是/user
(@GetMapping("/user")
),那请求就匹配不上了,404 报错。这个时候就要重写 url,把 /api 给去掉。以 http://127.0.0.1:2222/user 的url去访问后台接口。
如果服务接口写的就是/api/user
(@GetMapping("/api/user")
),那就不要重写 url 了。
CORS 解决跨域
CORS 解决跨域核心就四个字:加响应头。
网关 nginx
网关中加响应头,如 NGINX。
server {
listen 80;
server_name example.com;
location / {
root /var/www/html;
try_files $uri $uri/ /index.html;
}
location ^~ /api/ {
# 配置代理转发到 localhost:3000
proxy_pass http://localhost:3000;
# 设置代理请求头,转发原始请求信息
proxy_set_header Host $http_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;
# 添加跨域请求头
add_header 'Access-Control-Allow-Origin' $http_origin;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' '*';
# 处理 OPTIONS 请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $http_origin;
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' '*';
add_header 'Access-Control-Max-Age' 1728000; # 缓存预检请求 20 days
add_header 'Content-Type' 'text/plain; charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
}
}
看到这可能会疑惑,这不是和同域部署是一样的吗?就是多加了一些跨域响应头。
是不一样的,在后端中感受不到区别。前端可以感受到。区别在于前端在写接口的时候,如果没有添加这些响应头,这前端请求接口的 baseURL 是固定的,必须是 NGINX 的 URL 加上 /api 。因为 NGINX 这个代理服务器没有支持对前端的跨域。
如果加了跨域响应头,这 NGINX 支持跨域了,任何前端只要都可以访问该后台服务的接口了。
同源部署的方式限制了能访问接口的前端,安全性更高,所以这种方式用的也很多。如果想 NGINX 开启了 cors 并且也限制能访问的前端。
除了写死 Access-Control-Allow-Origin
,还可以通过 if 正则判断。这是常用的做法。
http {
# 其他 HTTP 配置...
server {
listen 80;
server_name example.com;
# 允许跨域请求的配置
location ^~ /api/ {
# 只允许特定域名访问 example.com
if ($http_origin ~* (http(s)?://(www\.)?example.com(:[0-9]+)?) {
proxy_pass http://localhost:3000;
proxy_set_header Host $http_host;
...
} else {
return 403;
}
}
}
}
node.js
可以原生设置响应头和处理 options 请求,还可以通过 cors 库便捷设置。
const corsOptions = {
origin: 'https://example.com', // 允许来自 https://example.com 的请求
methods: 'GET, POST, PUT', // 允许的 HTTP 方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的请求头部
exposedHeaders: ['X-Total-Count'], // 暴露给客户端的响应头部
credentials: true, // 允许发送凭证信息(如 Cookie)
maxAge: 86400, // 预检请求结果的缓存时间(24小时)
preflightContinue: false, // 不发送非 CORS 相关的响应头部
optionsSuccessStatus: 200 // 预检请求的成功状态码
};
app.use(cors(corsOptions));
如果您想要允许所有来源和所有 HTTP 方法,可以简单地使用 app.use(cors())
而不传递任何配置对象。
spring boot
其他
fetch - mode 配置
fetch 的 mode 选项是一种安全措施,可以防止偶发的跨源请求:
- “cors” —— 默认值,允许跨源请求,如 Fetch:跨源请求 一章所述,
- “same-origin” —— 禁止跨源请求,
- “no-cors” —— 只允许跨域请求中的简单请求,什么是简单请求看上文。
- 这种模式通常用于加载跨域资源,如图片或脚本。
当 fetch 的 URL 来自于第三方,并且我们想要一个“断电开关”来限制跨源能力时,此选项可能很有用。
另外再次强调
- fetch 默认不会发送跨域 cookie,除非你使用了 credentials 的初始化选项。(自 2018 年 8 月以后,默认的 credentials 政策变更为 same-origin。Firefox 也在 61.0b13 版本中进行了修改)
<a> 会跨域吗
由于<a>标签只是触发浏览器导航行为,它不涉及读取或写入另一个源的数据,因此不受同源策略的限制。
当你点击一个<a>标签时,浏览器会直接加载新的页面,如果新页面与当前页面不同源,浏览器会创建一个新的页面实例,并且不会将当前页面的任何敏感数据(如Cookie、LocalStorage等)带到新页面。
a 标签的请求是同步请求,不是 ajax。
script 标签引入脚本是异步的,为什么它没被同源限制,因为这是浏览器开后门故意设计的。
为什么<script>
可以跨域
<script>
标签加载的 JavaScript 脚本不受同源策略的限制,这主要是由于浏览器实现和历史原因造成的。同源策略(Same-Origin Policy)是一种约定,它是浏览器最核心也最基本的安全功能,如果网页违反了同源策略,那么浏览器会限制从脚本中发起的跨域HTTP请求。
以下是几个关键点解释为什么 <script>
标签可以跨域加载资源:
- 历史原因:早期浏览器并没有严格执行同源策略。在互联网早期,跨域请求的需求并不像现在这么普遍,因此
<script>
标签被允许从不同来源加载资源,以便于网站可以包含来自不同域的 JavaScript 库或广告等。 - 动态执行:
<script>
标签可以动态生成并加载远程脚本。这种方式在早期的网页中很常见,比如动态插入广告脚本。如果<script>
受同源策略限制,这些用例将无法实现。 - CORS的引入:随着网络技术的发展,为了安全地支持跨域请求,CORS(Cross-Origin Resource Sharing)标准被引入。CORS允许服务器声明允许哪些网站的脚本访问其资源。但
<script>
标签的跨域行为已经成为既定事实,为了向后兼容,它被保留了下来。 - 只执行不读取:虽然
<script>
标签可以跨域加载脚本,但这些脚本执行时仍受同源策略的限制。这意味着脚本可以执行,但不能读取跨域资源。例如,一个跨域加载的脚本不能读取不同源的文档内容或发起跨域的 AJAX 请求。
因此,尽管 <script>
标签可以跨域加载脚本,但为了安全起见,当涉及到敏感数据或需要跨域访问资源时,应当使用更安全的方法,如 JSONP、CORS 配置、代理服务器等。