前端安全基础知识总结

前言

去年找工作的时候,面了好几家大厂,在面试过程中发现大厂会比较注重前端安全。而在乙方工作,我发现周围的人无论是客户还是工程师,都比较忽视这类,很多人连漏洞的原理以及修复方式都搞不清楚,包括自己也有一些不清楚的地方。想了一下,一方面可能是因为前端/客户端的漏洞从攻击模型来看,往往依赖用户交互,有些攻击场景和修复方式都是基于用户端的,相比于直接攻击服务端来说,不太直观;另一方面也可以归因于人的浮躁。于是准备写一些文章,站在应用安全的角度梳理前端相关的安全问题,这篇文章主要设置前端安全的一些基础知识。

浏览器同源策略

所谓同源即对于URL来说,同协议、同主机、同端口即是同源。站在安全的角度,同源策略的限制主要是指跨源网络访问和跨源数据存储访问。跨域数据访问好理解,A站点上面JavaScript脚本无法读取到B站点上的类似CookieLocalStorage的数据;跨源网络访问即利用JavaScript发起HTTP请求,请求的目标地址和当前脚本的URL是非同源的,这个过程根据具体情况会收到一些限制,具体由CORS机制决定。而无论站在开发还是安全的视角,要解决问题就是什么情况下可以进行跨源请求,并携带资源(CookieLocalStorage等)。

跨源网络访问

前端的一些安全配置往往是针对浏览器的,为了便于理解浏览器的工作效果,我编写了一个简单的demo,用于验证各种安全配置对浏览器的影响,方便理解:

首先是服务端A(http://127.0.0.1:9999/cross_source_request.html),用于向用户提供页面:

1
2
3
4
5
6
7
8
9
10
11
12
<script>
const req = new XMLHttpRequest()
const url = "https://192.168.98.1:5000/hello"
req.open("GET",url,true)
req.setRequestHeader("Authorization","this_is_a_secret")
req.onreadystatechange = function (){
if (this.readyState == 4 && this.status == 200){
console.log(req.responseText)
}
}
req.send();
</script>

其次是服务端B(https://192.168.98.1:5000/),用于提供实际的接口服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask, make_response, request

app = Flask(__name__)

@app.route("/auth",methods=["GET"])
def auth():
response = make_response('', 200)
response.set_cookie('session', "5d41402abc4b2a76b9719d911017c592",samesite='None',secure=True)
return response

@app.route("/hello",methods=["GET","POST","OPTIONS"])
def hello():
response = make_response("hello")
return response

if __name__ == "__main__":
app.run(ssl_context=('server.crt', 'server.key'),host='0.0.0.0')

可以观察到两个服务是不同源的,在服务端A的JavaScript代码与服务端B的接口交互时,会涉及到跨域相关的问题,并且注意到我将服务端B的http服务配置了ssl证书,这涉及到后续介绍的一些浏览器配置要求,先暂时不提,如何给flask配置ssl可以参考这篇文章

简单请求

结合跨源资源共享(CORS)浏览器的同源策略的内容,总结可以跨源的情况。首先是不受同源策略的限制的情况:

  1. 跨源写操作:比如链接、重定向以及表单提交(这也解释了为什么常见的BurpSuite生成的CSRF POC可以起作用)

  2. 跨源的资源嵌入:image-20250223192452275

  3. 简单请求(即满足以下所有要求的请求):image-20250223192536016

可以看到简单请求的定义非常的苛刻,实际上现代Web开发过程中会频繁的涉及到跨域问题。在我们的demo中,因为存在自定义的非标准头(Authorization),所以这是一个非简单请求,受到浏览器限制,无法成功请求。实际上浏览器发起了一个OPTIONS请求,但是没有发出我们期望的GET请求

image-20250224094359073image-20250224094342403

可以看到默认情况下,浏览器限制了对于跨源站点的请求。那么对于跨源的情况,如何发送请求呢?

需预检的请求

实际上就是依靠第一个OPTIONS方法的预检请求,根据此时服务器返回的配置信息来指示浏览器是否发起跨源请求。这里经常会有一道经典的面试题,A站点上的JavaScript向B站点发送跨域请求,为了让跨域逻辑正常运行,应该在哪个站点上进行CORS配置,答案是在被请求的站点上进行配置,因为资源是在被请求的站点上。控制跨域请求的常见的响应头如下:

  • Access-Control-Allow-Origin:允许跨域请求的源(*代表任何源都可以请求,也可以指定具体的源)
  • Access-Control-Allow-Credentials:是否可以携带对应站点的凭据
  • Access-Control-Allow-Methods:真正请求时允许使用的方法
  • Access-Control-Allow-Headers:实际请求中允许携带的标头字段
  • Access-Control-Allow-Credentials:是否允许携带被访问站点的Cookie

在上一个例子中,可以在浏览器中看到报错信息,由于服务器缺少Access-Control-Allow-Origin的配置导致浏览器拒绝了跨源的请求

Access to XMLHttpRequest at ‘https://192.168.98.1:5000/hello‘ from origin ‘http://127.0.0.1:9999‘ 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

那么现在我们来修改代码,来让上一个例子中的JavaScript可以发起跨域请求,并读取到响应:

1
2
3
4
5
6
7
8
9
10
@app.route("/hello",methods=["GET","POST","OPTIONS"])
def hello():
response = make_response("hello")
# 任何源都可以发起请求
response.headers['Access-Control-Allow-Origin'] = '*'
# 允许发起具体的请求的方法
response.headers['Access-Control-Allow-Methods'] = 'GET'
# 允许携带的非标准的头
response.headers['Access-Control-Allow-Headers'] = 'Authorization'
return response

现在开始测试,我们先访问,端点(http://192.168.98.1:9999/auth)获取Cookie,在有Cookie的状态访问站点(http://192.168.98.1:9999/cross_source_request.html),成功发起了跨源请求并读取到了响应值。

image-20250224095442587image-20250224095455260

携带Cookie的跨源访问

在实际的开发过程中除了携带自定义的Header头部,我们也会遇到需要携带Cookie得到情况,那么如何携带Cookie进行跨源访问呢,需要满足两个条件:

  • XMLHttpRequest等请求的API中设置类似withCredentials选项为true,意为让请求时自动携带Cookie;
  • 同时在预见请求阶段,需要被跨源的站点给出一些配置,告诉浏览器允许在携带Cookie的情况下进行跨源请求,具体的配置如下image-20250223213103597

那么此时我们来修改代码,让demo能够进行携带Cookie的跨源请求,给服务端A添加一行req.withCredentials = true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
const req = new XMLHttpRequest()
const url = "https://192.168.98.1:5000/hello"
req.open("GET",url,true)
// 自动携带Cookie
req.withCredentials = true
req.setRequestHeader("Authorization","this_is_a_secret")
req.onreadystatechange = function (){
if (this.readyState == 4 && this.status == 200){
console.log(req.responseText)
}
}
req.send();
</script>

修改服务端B代码,设置Access-Control-Allow-Credentials字段为true,并且指定Access-Control-Allow-Origin为一个具体的源

1
2
3
4
5
6
7
8
9
10
11
12
@app.route("/hello",methods=["GET","POST","OPTIONS"])
def hello():
response = make_response("hello")
# 允许携带Cookie
response.headers['Access-Control-Allow-Credentials'] = 'true'
# 任何源都可以发起请求
response.headers['Access-Control-Allow-Origin'] = 'http://127.0.0.1:9999'
# 允许发起具体的请求的方法
response.headers['Access-Control-Allow-Methods'] = 'GET'
# 允许携带的非标准的头
response.headers['Access-Control-Allow-Headers'] = 'Authorization'
return response

我们查看下效果,可以看到成功携带了Cookie:image-20250224101446060

实际上影响Cookie自动携带的因素还有一个,那就是Cookie的属性,在一开始我并没有在demo中设置Cookie属性。而现代浏览器比如Chrome和FireFox,当不设置Cookie的SameSite属性时,默认使用Lax策略,也就是无法跨域提交Cookie,此时你会发现,无论你怎么设置JavaScript请求API以及CORS头部,均无法自动携带Cookie进行跨域请求;所以只能显式的在demo中给Cookie设置上samesite='None'属性,而现代浏览器又要求当存在samesite='None'时必须同时设置secure属性,即通过https传输,这也是为什么demo要配置ssl的原因image-20250224102100510

Same Site的定义

这一部分是刷完PortSwigger的CSRF的LAB后补充的,主要是区别同站和同源两个概念。同源上面已经介绍过了,同站主要是针对Cookie的,在Cookie中一个SameSite属性,这个属性正是用于限制跨站提交Cookie的行为,所以有必要了解清楚。直接引用PortSwigger的图片:协议和根域名完全一直就是Same Site(同站)否则算Cross Site(跨站)

image-20250227110926312

Request from Request to Same-site? Same-origin?
https://example.com https://example.com Yes Yes
https://app.example.com https://intranet.example.com Yes No: mismatched domain name
https://example.com https://example.com:8080 Yes No: mismatched port
https://example.com https://example.co.uk No: mismatched eTLD No: mismatched domain name
https://example.com http://example.com No: mismatched scheme No: mismatched scheme

Cookie安全属性

说到Cookie的定义,我想起毕业那会看过的面经,问Cookie和Session的区别,直到今天仍然觉得难蚌为啥要把这俩放一起说,可能是业务开发方向喜欢把这两者放在一起把。相比我比较喜欢BurpSuite插件开发中对Cookie的定义:一种参数。和URL参数、Body参数一样,Cookie也可以看成一种参数,因为他本身就是由一组key和value构成,他存放在浏览器端,而SessionJSESSIONID等,不过是用于鉴权的常用的Cookie参数名罢了。而由于Cookie存放在浏览器端,可以被JavaScript访问,所以就有被窃取的风险,而Cookie属性正是为了告诉浏览器如何处理涉及到Cookie的行为,具体涉及的属性如下:

Domain

指定了哪些主机可以接受 Cookie,并且Cookie的Domain是不区分端口号的,127.0.0.1:80和127.0.0.1:443被浏览器认为是相同的domain,均为127.0.0.1

Path

请求的URL必须包括这些路径

SameSite

在没有SameSite属性前,浏览器在发送每个请求时会自动携带对应站点的Cookie,这样会导致一些安全问题。SameSite属性用于告诉浏览器,在跨站点的请求中是否允许携带Cookie。可以选择三个值Strict(不允许发送第三方Cookie)、Lax (比Strict稍微宽松一点,当导航到第三方站点时可以发送Cookie)和 None(不做限制但Cookie必须通过https传输,即设置Secure属性)

Secure属性

Cookie只能通过https发送

HttpOnly属性

设置后JavaScript的document.cookieAPI无法获取Cookie内容。

至此,常见的一些前端安全基础配置梳理完毕,当然还有一些比如CSP之类的配置,因为和具体的漏洞,比如XSS相关,就放在另外的文章里。

参考