日常开发中,对于一些新项目的api工程,会有专门的安全工程师对齐进行安全漏洞扫描,扫描出来的漏洞会被要求限期修复;本篇讲解CORS漏洞的基本概念、原理、示例,以及修复漏洞的代码示例;
1. 什么是CORS
跨域资源共享 (CORS) 是一种浏览器机制,可以对位于给定域之外的资源进行受控访问。它扩展并增加了同源策略 ( SOP ) 的灵活性。但是,如果网站的 CORS 策略配置和实施不当,它也会为基于跨域的攻击提供可能性。
根据以上的解释,知道:CORS不是漏洞,而是浏览器的一种机制;它是对跨域资源的受控访问,而不是禁止访问;由于概念涉及同源策略和跨域,因此先简单介绍下相关概念;
2. 同源策略
SOP,同源策略 (Same Origin Policy),该策略是浏览器的一个安全基石;同源策略是浏览器的一个安全功能,不同源的数据禁止访问;如 lilnong.top 下的 ajax 访问 51vv.com 数据是会报错。(network 可以看到 response,证明限制是浏览器方的限制);
如果没有同源策略,那么,你打开了一个合法网站,又打开了一个恶意网站。恶意网站的脚本能够随意的操作合法网站的任何可操作资源,没有任何限制。
浏览器的同源策略规定:不同域的客户端脚本在没有明确授权的情况下,不能读写对方的资源。那么何为同源呢,即两个站点需要满足同协议,同域名,同端口这三个条件。
可以理解为同源策略的制定是从安全层面出发的,是对浏览器脚本的一种限制,目的是在用户使用浏览器时保护用户网络安全;但是,尽管同源策略SOP带来安全,同时也会带来一定程度的麻烦,因为有时候就是有跨域的需求,如随着Web应用的发展,网站由于自身业务的需求,需要实现一些跨域的功能,能够让不同域的页面之间能够相互访问各自页面的内容(如广告、物流等需要请求第三方的域名接口),即需要对一些指定的域名“跨域”;
之所以会遇到跨域问题,正是因为 SOP 的各种限制。但是具体来说限制了什么呢?
其实 SOP 不是单一的定义,而是在不同情况下有不同的解释:
- 限制 cookies、DOM 和 Javascript 的命名区域
- 限制 iframe、图片等各种资源的内容操作
- 限制 ajax 请求,准确来说是限制操作 ajax 响应结果,本质上跟上一条是一样的
下面是 3 个在实际应用中会遇到的例子:
- 使用 ajax 请求其他跨域 API,最常见的情况,前端新手噩梦
- iframe 与父页面交流,出现率比较低,而且解决方法也好懂
- 对跨域图片(例如来源于
<img>
)进行操作,在 canvas 操作图片的时候会遇到这个问题
SOP有多重要?如果没有了 SOP会发生什么:
- 一个浏览器打开几个 tab,数据就泄露了
- 你用 iframe 打开一个银行网站,你可以肆意读取网站的内容,就能获取用户输入的内容
- 更加肆意地进行 CSRF
SOP同源策略是否总能起到限制跨域的作用呢?——不是!
表单提交、链接 这些项等同于切换页面 script标签的src、link标签的href、img标签的src、iframe标签的src 上述的资源可以引用,但是不可获取内容;
img 可以显示出来,但是你无法放入canvas二次使用,会把canvas的源污染。 iframe 可以显示,不可以获取DOM script 不可获取报错代码位置;
3. 跨域的示例
跨域原因说明 | 示例 |
---|---|
域名不同 | http://www.jd.com 与 http://www.taobao.com |
域名相同,端口不同 | http://www.jd.com:8080 与 http://www.jd.com:8081 |
二级域名不同 | http://item.jd.com 与 http://miaosha.jd.com |
如果域名和端口都相同,但是请求路径不同,不属于跨域,如:
跨域访问的一些场景
(1)在前后端分离的模式下,前后端的域名不一定是一致的,此时就会发生跨域访问的问题;
(2)电商网站想通过用户浏览器,通过发送ajax请求,加载第三方快递网站的物流信息;
(3)一级域名相同的子站域名希望调用主站域名的用户资料接口,并将数据显示出来;
通过上述的场景,我们发现有时候跨域不一定会有跨域问题,而是业务所需;因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是于当前页同域名的路径,这能有效的阻止跨站攻击。
因此,跨域问题,某种程度上可以理解为是针对ajax的一种限制;
但是这却给我们的开发带来了不变,而且在实际生产环境中,肯定会有很多台不同域名的服务器之间数据交互,域名地址和端口都可能不同,怎么办?下面会给出几种方式;在说明解决跨域问题的方案前,我们先做个小结:
同源策略SOP/跨域的总结:
(1) SOP是浏览器的一种安全机制,不满足同源的域名之间的资源访问属于"跨域访问",跨域访问会受到SOP的限制,称之为"跨域问题";
(2) 正因为SOP是浏览器的一种安全机制,而不受到服务端控制,即同源策略会阻止响应,但依然会发出请求。因为执行响应拦截的是浏览器而不是后端程序。事实上你的请求已经发到服务器并返回了结果,但是迫于安全策略,浏览器不允许你继续进行 js 操作,所以报出你熟悉的"blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.",因此——同源策略不能作为防范 CSRF 的方法,因为请求已经发出去了!
4. 如何绕过跨域限制的方案
- 1. nginx反向代理
思路是:利用nginx反向代理把跨域为不跨域,支持各种请求方式缺点是:需要在nginx进行额外配置,语义不清晰;
- 2. CORS 规范化的跨域请求解决方案
优势:可在服务端进行控制是否允许跨域,可自定义规则,非常灵活;且支持各种请求方式;
CORS如何解决同源策略的限制?
CORS,跨域资源共享(Cross-origin resource sharing),是浏览器提供的一种机制,它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。
CORS需要浏览器和服务器同时支持;目前,所有浏览器都支持该功能,整个CORS通信过程,都是浏览器自动完成;而对于服务端,CORS通信与AJAX没有任何差别,因此不需要改变以前的业务逻辑,只不过,浏览器会在请求中携带一些头信息,而服务端需要以此判断是否运行其跨域,然后在响应头中加入一些信息即可,一般通过写个Filter实现;下面介绍下CORS原理,通过原理我们就可以来写出一个CorsFilter了;
5. CORS原理
浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求;这2种请求达到跨域条件的请求和服务端响应存在一些差异,这里分别介绍;
只要同时满足以下两大条件,就属于简单请求:
1. 请求方法是以下三种方法之一:HEAD/GET/POST
2. HTTP的头信息不超出以下几种字段:Accept/Accept-Language/Content-Language/Last-Event-ID/Content-Type(只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain)
不符合简单请求的条件,会被浏览器判定为特殊请求,例如请求方式为PUT;
(1)简单请求
当浏览器发现ajax请求是简单请求时,会在请求头中携带一个字段:Origin;Origin中会指出当前请求属于哪个域(协议+域名+端口),服务器会根据这个值决定是否允许其跨域;注意,这一步是浏览器自动完成的,在Header中自动添加Origin是HTTP协议,而实际中,还存在一个字段Referer,表示请求链接的来源,二者有类似的作用但也存在一些区别,具体可参考下面这篇文章《HTTP中Origin和Referer的区别》,在服务端做获取链接的来源操作时,二者建议都尝试获取,可参考文末的代码示例;
那么服务端要怎么处理才能让浏览器知晓当前请求允许跨域呢?简单来说,就是返回Response时,在Header中给几个特殊参数赋值:
Access-Control-Allow-Origin: http://xxxx.com
Access-Control-Allow-Credentials: true
- Access-Control-Allow-Origin:可接受的域,是一个具体域名或者*,*代表任意;
- Access-Control-Allow-Credentials:是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是true;
注意,如果跨域请求要想操作cookie,需要满足3个条件:
- 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true;
- 浏览器发起ajax需要指定withCredentials 为true;
- 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名;
(2)特殊请求
特殊请求,对于浏览器的操作,多了一个步骤:特殊请求会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight);浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错;
对于服务端需要做的操作,没有太大区别,除了Access-Control-Allow-Origin和Access-Control-Allow-Credentials以外,Header中额外多出3个参数:
- Access-Control-Allow-Methods:允许访问的方式;
- Access-Control-Allow-Headers:允许携带的头;
- Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了;
如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了;
CORS原理的总结:
(1)浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求,不同类型请求下,浏览器的操作和服务端告知浏览器允许跨域的操作略有不同;
(2)浏览器通过Header中的Origin参数和Referer参数,告知服务端请求链接的源站点信息;服务端取Header中的该信息来判断是否允许当前请求跨域;服务端通过向Header中的几个"Access-Control-Allow"前缀的参数赋值,来告知浏览器当前请求是否允许跨域;
其他的知识可参考这边文章《Web漏洞之CORS跨域资源共享漏洞 - 知乎》,里面讲述了关于CORS的更为详细的知识,包括:
1. CORS漏洞案例演示:操作过程、前端代码;
2. 为什么将Access-Control-Allow-Origin设置为*即允许任意源,为何还是会产生跨域问题?
3. CORS漏洞挖掘思考:查看响应头、判断请求源、漏洞危害及漏洞修复;
6. CORS漏洞示例
漏洞产生的原因一般是:服务端将Access-Control-Allow-Origin设置的值,匹配了恶意网站,如设置了通配符*;
- 假设用户登录一个含有CORS配置网站http://vnlu.com,同时又访问了攻击者提供的一个链接http://evil.com;
- http://evil.com的网站向http://vuln.com这个网站发起请求获取敏感数据,浏览器能否接收信息取决于http://vuln.com的配置;
- 如果http://vuln.com配置了Access-Control-Allow-Origin头且为预期,那么允许接收,否则浏览器会因为同源策略而不接收;
7. 如何修复CORS漏洞——CorsFilter
根据上述原理,从Header中获取请求的源站点信息,判断是否白名单,若不满足则不在reponse的Header中填充对应的标识允许跨域的参数;反之,满足条件,则在response的Header中填充几个前缀为"Access-Control-Allow"的参数标识,告知浏览器允许跨域;
import com.AA.ConfigManager;
import org.springframework.stereotype.Component;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author AA
* @description CorsFilter
* @date 2021/3/15 14:58
*/
@Component
@WebFilter(filterName = "CorsFilter", urlPatterns = "/*")
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
HttpServletRequest request = (HttpServletRequest) req;
// 打印请求信息
// printRequestInfo(request);
// 获取请求源站
String origin = request.getHeader("origin");
String referer = request.getHeader("referer");
// 如果源站域名通过域名检查,则返回浏览器该域名可以进行跨域访问(referer返回*)
// 如果不通过域名检查,不返回allow-origin,走默认逻辑,即不可进行跨域访问
if (origin != null && check(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin);
} else if (referer != null && check(referer)) {
response.setHeader("Access-Control-Allow-Origin", referer);
} else {
log.error("CORS_NOT_PASS. [origin={} allowedOrigins={} headers={}]", originHeader, JSON.toJSONString(allowedOrigins),request.getRemoteAddr());
printRequestInfo(request);
ServletServerHttpResponse serverResponse = new ServletServerHttpResponse(response);
serverResponse.setStatusCode(HttpStatus.FORBIDDEN);
serverResponse.getBody().write("Invalid CORS request".getBytes(UTF8_CHARSET));
serverResponse.flush();
// 终止Filter 返回 403 forbidden
return;
}
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.addHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
chain.doFilter(req, res);
}
/**
* CORS检查方法
*
* @param origin 要检查的源站地址
* @return 检查结果
*/
private boolean check(String origin) {
// 获取跨域请求检查规则
Pattern allowPattern = Pattern.compile(ConfigManager.getString("allow.origin", "^(https|http)?://([\\da-zA-Z.-]+).myDomian.com.cn"));
Matcher matcher = allowPattern.matcher(origin);
return matcher.find();
}
@Override
public void init(FilterConfig filterConfig) {
}
@Override
public void destroy() {
}
private void printRequestInfo(HttpServletRequest request) {
try {
StringBuilder sb = new StringBuilder();
sb.append("\n").append("---remoteAddr:").append(request.getRemoteAddr());
sb.append("\n").append("---Method:").append(request.getMethod());
sb.append("\n").append("---url:").append(request.getRequestURL());
sb.append("\n").append("---headers:");
final Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()){
final String currentHeader = headerNames.nextElement();
sb.append("\n").append(" ").append(currentHeader).append(":").append(request.getHeader(currentHeader));
}
sb.append("\n").append("---request_params:");
Stream<String> stm = request.getReader().lines();
stm.forEach(stm1 -> sb.append("\n").append(stm1));
log.warn("printRequestInfo. {}", sb.toString());
} catch (IOException ignored) {
}
}
}