0%

HTTP的访问控制与跨域资源共享(CORS)

本文首次发表在Freebuf:https://www.freebuf.com/articles/web/195925.html

同源策略

准确的说,同源策略是指,浏览器内部在发起如下请求时,该来源必须是当前同源的HTTP资源:

  1. 以跨站点的方式调用XMLHttpRequest或者Fetch API。
  2. Web字体(用于CSS中@ font-face的跨域字体使用)
  3. WebGL textures
  4. 使用drawImage绘制到canvas的图像/视频帧。
  5. 样式表(用于CSSOM访问)

注意:两个URI同源当且仅当它们的协议://host:port相同。

从第一点可以看到,浏览器限制从脚本内部发起跨域的HTTP请求——更准确的说,同源策略有的限制有两种表现:(1)限制发起AJAX请求(XMLHttpRequest,Fetch);(2)拦截其他跨站请求的返回结果;这取决于请求是否为简单请求。

CORS

跨域资源共享(Cross-Origin Resource Sharing, CORS)是一种解决跨域请求的方案,其机制是使用一组额外响应头(Access-Control-Allow-Origin)和预检请求(OPTIONS)来使浏览器有权使用非同源资源。大部分的现代浏览器符合该标准。

简单请求

若请求满足所有下述条件,则该请求可视为“简单请求”:

  • 使用下列方法之一:

    • GET
    • HEAD
    • POST
  • 并且Content-Type的值仅限于下列三者之一:

    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
  • Fetch 规范定义了对 CORS 安全的首部字段集合,也就是说,不得手动设置除以下集合之外的字段(否则不为简单请求)。该集合为:

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  • 并且请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。

  • 并且请求中没有使用 ReadableStream 对象。

简单请求会直接发送请求而不会触发预请求,但是不一定能拿到结果,这取决于请求的服务器Response的Access-Control-Allow-Origin内容。注意以上条件只要有一条不满足则不为简单请求。

简单请求跨域表现

发起请求服务http://127.0.0.1:8000/ajax.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AJAX</title>
</head>
<script>
function submitRequest() {
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://127.0.0.1:8888/get", true);
xhr.withCredentials = true;
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
alert(xhr.responseText);
}
}
}
</script>
<button onclick="submitRequest()">AJAX</button>
</html>

非同源服务http://127.0.0.1:8888/:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from flask import Flask, request, render_template_string, session

app = Flask(__name__)
app.secret_key='random_secret_key'

@app.route('/get', methods=['GET'])
def get():
if session.get('user','')=='admin':
return "Admin do something!"
else:
return "No Privilege..."

@app.route('/login', methods=['GET'])
def login():
user=request.args.get("user", "Null")
session["user"]=user
template="""
<h3> Login as {{ user }}... </h3>
"""
return render_template_string(template, user=user)

if __name__ == '__main__':
app.run(host='127.0.0.1', port=8888, debug=True)

发请求,可以看到请求确实已发送,并且可以带cookie(withCredentials),但是js没有拿到结果:

AJAX请求结果(请求成功,回传失败,所以这也是GET型CSRF无法很好防范的原因):

1548854666171

综上,对于简单跨域请求,若未正确配置则请求正常发送,不能获取返回结果(浏览器拦截)。

Origin和Access-Control-Allow-Origin

可以看到在请求中存在Origin字段,它标记了来源,对应的Access-Control-Allow-Origin为回应包头携带字段,它表示那些来源可以访问本域,*表示所有来源(注意它不能与credentials一起使用)。

使用CORS实现的支持跨域的非同源服务http://127.0.0.1:8888/:

1
2
3
4
5
6
7
8
9
10
11
@app.route('/get', methods=['GET'])
def get():
if session.get('user','')=='admin':
ret = "Admin do something!"
else:
ret = "No Privilege..."
resp=make_response(ret)
resp.headers['Access-Control-Allow-Origin'] = "http://127.0.0.1:8000"
resp.headers['Access-Control-Allow-Credentials'] = 'true'
resp.headers['Access-Control-Allow-Methods'] = "POST, GET, OPTIONS, PUT, DELETE, PATCH"
return resp

其中还有几个header:

  • Access-Control-Allow-Credentials:如果请求需要带cookie,该header必须为true,同时Access-Control-Allow-Origin不能为*,否则同样拿不到结果;
  • Access-Control-Allow-Methods:允许的请求方式

OriginAccess-Control-Allow-Origin一个为请求携带的字段,一个为回应携带的字段,浏览器以此来判断js是否可以接收回应。

改造后前端终于能够拿到结果:

1548855646635

预检请求

若请求不为简单请求,那么在发起该请求前必须使用OPTIONS发送预验请求,服务器允许后才能发送实际请求(可以猜想这是为了防止CSRF)。

当请求满足一下任一条件时,该请求为非简单请求:

  • 使用了下面任一 HTTP 方法:
    • PUT
    • DELETE
    • CONNECT
    • OPTIONS
    • TRACE
    • PATCH
  • 人为设置了对 CORS 安全的首部字段集合 之外的其他首部字段。
  • Content-Type的值不属于下列之一:
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 请求中的XMLHttpRequestUpload 对象注册了任意多个事件监听器。
  • 请求中使用了ReadableStream对象。

预检请求跨域表现

假设有服务器http://127.0.0.1:8888/json:

1
2
3
4
5
6
7
8
9
10
11
12
13
@app.route('/json', methods=['GET','POST'])
def json():
if request.method == 'GET':
return render_template('json.html', Evil="Benign")
else:
if session.get('user','')=='admin':
print("session:",session)
data=request.json
ret='Admin do '+data["action"]
else:
ret="No Privilege2..."
print(ret)
return jsonify({'result': ret})

‘templates/json.html’内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<html>
<title>{{ Evil }}</title>
<center>
<h1> Reset Password </h1>
<head>
<script type="text/javascript">
function submitRequest() {
var xhr = new XMLHttpRequest();
xhr.open("POST", "http://127.0.0.1:8888/json", true);
xhr.setRequestHeader("Accept", "*/*");
xhr.setRequestHeader("Accept-Language", "zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3");
xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8");
xhr.withCredentials = true;
xhr.send(JSON.stringify({"action":"change passwd..."}));
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
alert(xhr.responseText);
}
}
}
</script>
</head>
<body>
<button onclick="submitRequest()">Conform</button>
</body>
</html>

同域不存在预检请求:

1548918853746

跨域出现OPTIONS请求,默认情况下跨域被阻止:

1548920449712

Access-Control-Request-Method:字段说明请求的操作

允许跨域请求

在OPTIONS和POST报头加入Access-Control-Allow-Origin等字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route('/json', methods=['GET','POST','OPTIONS'])
def json():
if request.method == 'GET':
return render_template('json.html', Evil="Benign")
else:
if session.get('user','')=='admin':
print("session:",session)
data=request.json
ret='Admin do '+data["action"]
else:
ret="No Privilege2..."
resp=make_response(jsonify({'result': ret}))
resp.headers['Access-Control-Allow-Origin'] = "http://127.0.0.1:8000"
resp.headers['Access-Control-Allow-Credentials'] = 'true'
resp.headers['Access-Control-Allow-Methods'] = "POST, GET, OPTIONS, PUT, DELETE, PATCH"
resp.headers['Access-Control-Allow-Headers'] = "origin, content-type, accept, x-requested-with"
return resp

跨站成功,先发送OPTIONS,再发送POST,注意这两个报头必须都存在CORS字段。

1548922726229

与CORS有关的HTTP头

请求

  • Origin:<origin>:表示实际请求的源站
  • Access-Control-Request-Method: <method>:用于预检请求,表示真实的请求方法。
  • Access-Control-Request-Headers: <field-name>[, <field-name>]*:用于预检请求,表示真实请求所携带的首部字段(从抓包上来看chrome没有按要求来啊Orz)

响应

  • Access-Control-Allow-Origin: <origin> | *:允许外域URI
  • Access-Control-Allow-Credentials:false:是否允许浏览器读取response内容(如cookie)
  • Access-Control-Allow-Methods:用于预检请求响应,表示允许使用的HTTP方法
  • Access-Control-Allow-Headers:用于预检请求响应,表示允许携带的头部
  • Access-Control-Expose-Headers:允许响应时能获取的其他头部(在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到一些最基本的响应头)
  • Access-Control-Max-Age:preflight请求的最大响应时间

参考链接