同源策略

同源策略主要是实施在浏览器,主要用来限制一个源的文档或 JS 脚本获取另一个源的资源。如果交互双方的协议、域名、端口都相同,那么属于同源,可以正常获取资源,只要其中一个不相同就是跨域,浏览器就会拦截响应内容并丢弃。

同源策略主要限制跨域的资源请求,比如两个不同源的tab页面之间本地存储数据的交互,以及不同源的网络请求。不同源的页面不能获取到对方的本地存储信息,不同源页面之间的存储是独立的。同样的,当前页面发起的Ajax跨域请求也会被浏览器拦截,浏览器会将返回的结果丢弃,这个跨域请求就无法获取数据。cookie和storage的隔离方式不同,cookie与域名绑定,子域可以访问并设置父域的cookie,子域向父域的跨域请求是可以携带cookie的。

写操作和资源嵌入一般是允许的。比如表单的提交就是被允许的,因为表单并不会获取新的内容,浏览器的同源策略主要是阻止用户获取另一个源的内容,所以表单可以发送跨域请求。还有一些用于请求静态资源的标签,script/link/img/video/audio/iframe,这些标签都有src属性,还可以设置onload/onerror事件处理程序,这些资源加载完成会触发load,加载失败时会触发error

当两个文档的源不同时,一些全局对象的部分方法和属性可以共享,比如window.postMessage/window.parent

  1. // 阻止跨域ajax请求,意思就是现在在知乎的tab页,我在控制台执行这个脚本获取百度首页的信息是不被允许的。
  2. // 请求是发了出去,但是结果会被浏览器丢弃,在网络请求的响应里也看不到数据。
  3. var xhr = new XMLHttpRequest();
  4. xhr.open('GET', 'https://code.jquery.com/jquery-3.1.1.min.js');
  5. xhr.send();
  6. console.log(xhr);

同源策略主要是为了防御CSRF攻击。没有同源策略的时候,A网站可以被任意来源的Ajax访问到内容。如果此时用户在A网站还是登录状态,那么对方可以通过Ajax携带用户的状态信息,冒充用户对A网站发送请求。但是跨域不能完全阻止CSRF,因为跨域请求是发送了的,不过被浏览器拦截了响应。

有个点不是很懂,MDN上说阻止跨域写操作,只要检测CSRF Token,还可以阻止跨域读和跨域资源嵌入,具体是如何使用的呢?

跨域方案

蚂蚁体验技术部小程序生态基础技术团队、美团到家事业部外卖部门。
有三种方案,JSONP / CORS,除此之外还有websocket。

JSONP

JSONP 的原理就是利用script标签没有跨域限制的特点。动态创建script标签,设置script标签的src属性,指向要获取资源的站点,并设置与站点约定好的参数与回调函数。然后插入到页面中,文档会自动请求标签对应的资源。当后端收到请求后,获取约定好的参数和函数,将数据JSON序列化后与函数名拼接起来,拼接成一个函数调用字符串返回给浏览器。收到响应数据后,浏览器解析为js代码,然后执行函数调用,获取后端传递的数据。

请求响应后,浏览器会根据 script 标签的type属性进行解析,默认为text/javascript,这时候会将请求到的内容按照解析 js 的规则进行解析,解析为js代码,然后执行。后端返回的函数调用中的数据会被浏览器自动反序列化,所以函数中获取到的数据是对象。
使用来更快速的请求接口_screaming_color的博客-CSDN博客_script 调用接口
浏览器解码规则详解Franchi小白帽的博客-CSDN博客浏览器解码顺序
JSONP后端返回的回调函数是怎么执行的? - SegmentFault 思否
JavaScript基础:手写实现JSONPinnagine的博客-CSDN博客手写jsonp的实现

JSONP 使用简单,但是仅限于GET请求,请求有安全隐患,请求是否失败不易检查。

  1. // client.js
  2. function jsonp(req){
  3. var script = document.createElement('script');
  4. var head = document.getElementsByTagName('head')[0];
  5. var url = req.url + '?callback=' + req.callback.name;
  6. script.src = url;
  7. script.async = true;
  8. script.type = 'text/javascript';
  9. script.crossOrigin = 'anonymous';
  10. function onComplete (data) {
  11. if (data.type === 'error') {
  12. console.log('jsonp加载失败', data);
  13. } else {
  14. console.log('jsonp加载成功', data);
  15. }
  16. script.onerror = script.onload = null;
  17. head.removeChild(script);
  18. script = null;
  19. }
  20. script.onload = script.onerror = onComplete;
  21. head.appendChild(script);
  22. }
  23. function hello(res){
  24. console.log(typeof res);
  25. console.log('hello ' + res.data);
  26. throw new Error('error');
  27. }
  28. jsonp({
  29. url : 'http://127.0.0.1:8080',
  30. callback : hello
  31. });
  32. window.onerror = function onerror(err) {
  33. console.log('window error');
  34. console.log(err);
  35. }
  36. // server.js
  37. var http = require('http');
  38. var urllib = require('url');
  39. var fs = require('fs');
  40. var port = 8080;
  41. var data = { 'data': 'test'};
  42. http.createServer(function (req, res) {
  43. var params = urllib.parse(req.url, true);
  44. var headers = req.headers;
  45. const { referer, origin } = headers;
  46. res.setHeader('Access-Control-Allow-Origin', 'https://juejin.cn');
  47. res.setHeader('Set-Cookie', '_from_lh_1=ninja; domain=test.juejin.cn');
  48. res.setHeader('Access-Control-Allow-Credentials', 'true');
  49. if (!origin) {
  50. res.statusCode = 403;
  51. res.end();
  52. }
  53. if (params.query.callback) {
  54. console.log(params.query.callback);
  55. //jsonp
  56. var str = params.query.callback + '(' + JSON.stringify(data) + ')';
  57. // res.end(str);
  58. fs.readFile('./addCookie.js', (err, data) => {
  59. res.satatusCode = 200;
  60. res.setHeader('Content-Type', 'text/javascript');
  61. if (!err) {
  62. res.end(data);
  63. } else {
  64. res.end('addCookie.js not found');
  65. }
  66. });
  67. } else {
  68. res.end(JSON.stringify("console.log('safsad')"));
  69. }
  70. }).listen(port, function () {
  71. console.log('jsonp server is on');
  72. })

`简述json和jsonp的区别 - 简书 (jianshu.com)

JSONP的错误处理
动态创建的script标签请求失败的时候会触发标签自身的error事件,除了IE7、8。IE7、8还是会触发 load 事件,可以通过回调是否执行来判断。回调是否执行又用标志变量来判断吗?@todo
jQuery使用JSONP时的错误处理 - Antineutrino - 博客园 (cnblogs.com)

JSONP如何处理标签、回调函数
动态创建的标签需要删除,引用标签的变量需要解引用。一般在onload/onerror的时候删除。或者积累到一定数目时再删除,雅虎库yui的做法。标签上的属性或事件回调需要手动移除吗?最好还是手动移除吧。jsonpcallback 用完了也要 delete。

jQuery这些库会将动态创建script标签这些操作封装成一个函数挂载到window对象上,并且jsonpcallback也必须挂载到全局对象上如果保证函数不被覆盖呢?可以使用命名前缀+随机数+计数器id,这样的命名方式。

动态创建jsonp的函数使用了jQuery字符串拼接uuid。如果不想挂载在window上,那么可以使用IIFE,但是jsonpcallback也得挂载到全局对象上,可以采用同样策略(命名前缀),或者不想被覆盖可以使用Symbol类型,用Symbol的话还要用一个变量来保存这个值,也麻烦。@todo
关于JSONP的两点疑问 - SegmentFault 思否

uuid
(1 封私信 / 80 条消息) UUID是如何保证唯一性的? - 知乎 (zhihu.com)

CORS

CORS,跨域资源共享,是针对 xhr 跨域请求做出策略,在HTTP协议中实现,通过http首部信息实现了对跨域请求进行控制,后端设置跨域请求源白名单,允许白名单内的源获取自己的资源。
CORS规则将网络请求分为两种,简单请求、复杂请求。简单请求的方法必须是 get / head / post,Content-Type 的值必须是 text/plain、multipart/form-data、application/x-www-form-urlencoded还有更复杂的规则,并不全),除此之外的请求都是复杂请求。复杂请求在发送真正的跨域请求前会发送一个options方法,进行预检查,检查后端是否允许真正的跨域请求进行访问。

后端收到请求后对 Origin 字段进行校验,如果允许访问,就返回Access-Control-Allow-Origin,这个字段包含了Origin的值。如果不允许访问,会返回一个正常的响应,但不会添加Access-Control-Allow-Origin字段。浏览器检查发现没有Access-Control-Allow-Origin头就会抛出加载错误,触发XHR或script的onerror事件。这种错误无法通过状态码判断,因为可能返回200。

复杂请求会先发送一个options请求,进行预请求,请求头需要包含Origin/Access-Control-Request-Method/Access-Control-Request-Headers。

后端收到后,相继检查 Origin/Access-Control-Request-Method/Access-Control-Request-Headers ,允许跨域就返回成功的响应。包含相应的字段Access-Control-Allow-Origin/Access-Control-Allow-Method/Access-Control-Allow-Headers。当浏览器获取到正常的响应后,后面的请求就和简单请求一样,只包含一个Origin字段。

如果 XHR 这样的网络请求设置withCredentials为 true,那么后端应该在响应头设置 Access-Control-Allow-Credentials:true,只有为 true 时,浏览器才能获取到请求的资源。并且此时其他三个Access-Control-Allow-x字段不能设置为通配符。xhr 设置withCredentials属性为true,让请求可以携带认证信息,否则只要是跨域的请求,都不会携带上第三方 cookie。重点,注意两者之间的关系Access-Control-Allow-Credentials只有在withCredentials=true时设置为true,后端才能正常返回响应内容。

重点,如果后端设置了CORS,那么请求静态资源的标签应该怎样处理script/img/link/video/audio这类请求静态资源的标签可以设置**crossorigin**属性来添加Origin字段。与script标签类似,这些请求静态资源的标签可以添加onload/onerror事件处理程序监听加载成功和失败事件。

script还有一个特点,当执行的是不同源的脚本,使用window.onerror事件也监听不到脚本运行报的错误,外部脚本的执行错误信息就无法查看,因为同源策略对不同源的报错信息做了屏蔽,设置crossorigin后就可以监听到脚本内运行的错误信息了。

**crossorigin**属性设置为anonymous,如果请求源在后端的白名单里,就允许获取资源,浏览器不能携带认证信息。如果设置crossorigin属性为use-credentials,请求源包含在后端的白名单中,就允许获取资源,浏览器带会携带上认证信息,并且响应头Access-Control-Allow-Credentials设为true,后端才会返回响应内容给浏览器。
script标签中的crossorigin属性详解_宋哈哈的博客-CSDN博客_crossorigin属性
jsonp-反向代理-CORS解决JS跨域问题的个人总结(更新 v2.0) - 云+社区 - 腾讯云 (tencent.com)

document.domain

用于二级域名相同的情况下,比如a.test.com/b.test.com,只需给页面添加document.domain = 'test.com'表示二级域名都相同就可以实现跨域。

还有websocket、postmessage、反向代理也可以跨域。

websocket是一种通信协议,可以在客户端和服务端直接连接持久连接,实现跨域的请求响应,但是握手的http请求中可以携带上Origin字段,表示请求来源,而后端通过校验这个origin字段,判断是否允许本次通信,后端有自己的源白名单。为什么可以跨域呢?我觉得不只是这个协议本身的功能,同源策略对跨域的ajax请求进行了拦截,ajax请求也是基于TCP连接,所以同源策略想要拦截websocket也是可以的。这样解释也只是在同源策略的角度解释的,还是应该有原理方面的原因吧。

postMessage,常用来获取嵌入页面中的第三方页面数据。一个页面发送消息,另一个页面判断来源并接收消息。

  1. // 发送消息
  2. window.parent.postMessage('message', `http://test.com`);
  3. // 接收消息
  4. var mc = new MessageChannel()
  5. mc.addEventListener('message', event => {
  6. var origin = event.origin || event.originalEvent.origin
  7. if (origin === 'http://test.com') {
  8. console.log('验证通过')
  9. }
  10. })

反向代理,代理服务器来接收网络上的请求,代理服务器位于外部网络。

存储/缓存

缓存主要有两种,一种是浏览器缓存,另一种是http缓存。cookie/ localStorage/ sessionStorage/ indexDB/ Service Worker / 内存 / 硬盘。

cookie/localStorage/sessionStorage区别

有3点区别,过期时间、大小、跨域时的表现。

cookie由服务端生成,保存在客户端,由服务端设置过期时间,主要用于服务端通信。cookie一般会在浏览器关闭时删除,除非设置了过期时间。localStorage要用户手动清除,否则一直存在,sessionStorage保存的数据在页面关闭后会被清除。
cookie/ localStorage/ sessionStorage都有大小限制,单个 cookie 不超过 4K,localStorage/ sessionStorage 5M。

storage事件

当另一个同源的不同页面(不同i资源)修改了存储的值会触发storage事件。同一个页面(或同一个资源)中的修改无法触发该事件。

  1. window.addEventListener('storage', function(event) {
  2. // event对象为StrageEvent对象,包含key、oldValue、newValue、url触发这个事件的页面的url
  3. console.log('storage', event);
  4. })

为什么同一个源的同一个页面无法触发storage事件呢?@todo 难道是为了避免陷入循环?当页面注册了storage事件,当前页面修改了值,触发一次storage事件。不对啊,在不同页面的回调里如果又修改了storage的值,那么也会陷入循环。

Cookie

cookie是一串键值对格式的字符串,可以用来存储小量数据或者记录用户信息,保存在客户端,cookie一般与域名绑定

Cookie的属性

domain,表示了cookie 所属的域,只能设置为当前域或父域,不能设置为其它域名,包括子域名,默认当前域。带.,任何子域和当前域都可以访问;不带.,只有当前域可以访问。通过document创建的cookie的domain只能是当前源的域或父域,通过set-cookie创建的cookie的domain只能是服务器源的域或父域。(1条消息) cookie设置域名问题,cookie跨域_小菜鸟czh的博客-CSDN博客_cookie根据域名设置作用域
path,请求的 url 中包含这个路径才会携带上这个cookie默认为 /。
domain 和 path 组合标识了 cookie 的作用域,允许 cookie 发送给哪些 url。当前域只能访问、修改当前域和父域的 cookie,因此向当前域发送同源请求的时候,只会发送当前域和父域的cookie,向子域发送请求时虽然跨域,但是cookie作用域仍有效,会发送子域的cookie。
注意,子域中可以有父域的cookie,父域中也可以有子域的cookie,这是没有限制的。比如说当前域是segmentfault.com,向子域sponsor.segmentfault.com发送了请求,响应中set-cookie设置了OAID 这个cookie,当前域中就有了子域的 cookie。

expires,表示cookie的过期时间,GMT格式,new Date().toGMTString()。浏览器会在cookie过期时删除cookie。默认为会话,会话结束就删除。

httpOnly,为禁止js脚本获取cookie,为了防御XSS攻击,一般用于与后端进行通信的cookie,这类cookie不需要通过js脚本去访问。适用于第一方第三方cookie。默认为false。

secure默认为 false,表示cookie是否支持https请求,为true时,https请求可以携带cookie

samesite,表示这个 cookie 是否为同站 cookie,也就是第一方cookie,这样的话任何跨域请求都不会携带上这个cookie。strict,不能作为第三方cookie,跨站点发起请求的时候不会带上这个cookie。lax,宽松一些,get请求(打开页面或修改页面的请求)、链接、预加载的跨域请求可以携带上,类似于img/script这种标签发出的请求是不携带的。none,允许跨域的时候作为第三方cookie携带上,需要同时设置secure=false默认为lax。
get表单与post表单的区别是get表单会将表单的name=value以url参数的形式传递给后端,post是放在请求体中。

四川管理平台用到了js-cookie模块。(可以深入一下)理解Cookie原理并对js-cookie源码解析 - 掘金 (juejin.cn)

cookie 的创建

有两种方式可以创建cookie。一种是通过服务端响应中的set-cookie字段设置cookie,另一种是通过document.cookie可以新增、获取、修改、删除。在 chrome 中禁止了在本地 html 中添加 cookie。

  1. // 服务器添加cookie,禁止127.0.0.1之类的本地服务器添加。
  2. set-cookie: _gid=test; domain=.juejin.cn; secure=true; httpOnly=false; expires=new Date().GMTToString(); path=/article/asd123
  1. // 获取。只能获取当前域和父域、httpOnly为false的cookie
  2. document.cookie// "key1=val1;key2=val2"
  3. // 添加,不能设置domain吗。有个问题,当添加httpOnly属性的时候,新增不了。
  4. document.cookie = "key=value; expires=expire_time; path=domain_path; domain=domain_name; secure;"
  5. // 修改,只需要重新赋值就行,旧的值会被新的值覆盖,但是domain/path要与旧的cookie保持一致。
  6. document.cookie = "key=value;"
  7. // 删除。将值赋空,过期时间设置为一个过去的时间new Date(0).toGMTString()。
  8. document.cookie = `key=""; expires=${new Date(0).toGMTString()};`

子cookie,是为了提高绕过浏览器对每个域下cookie数的限制提出的解决方法,在每个cookie键值对中存储多个键值对。比如document.cookie=”data=name=zhangsan%age=30&sex=male”。

第三方Cookie

第一方cookie是我当前正在浏览的域创建的,主要用于改善用户体验。第三方cookie是用户当前访问的域以外的域创建的cookie,比如广告商、重定向的服务方、分析和跟踪服务提供者,主要用来标识我这个用户,记录我在当前域的足迹。

第一方cookie属于当前浏览的域,是具有特定站点的,虽然也是记录了用户的信息或状态,但是主要用于改善用户在当前站点的用户体验,比如保存下当前用户状态,就可以在不同页面往同一个购物车里添加商品。还比如,没有第一方cookie记录我的状态的话,开个页面重新访问这个站点,又要重新登录。因此浏览器都是默认支持第一方cookie的,但是不支持第一方cookie跨域

第三方cookie属于其他站点或者服务商,提供广告、链接跳转功能,由当前域以外的域创建。例如当前站点要恰钱,可能会和其他公司合作,让他们投放广告。当我登录进A网页,A网页提供了一些广告,这些广告或图片会向第三方发起请求,响应中一般有js脚本,用来创建第三方cookie,第三方就通过这个cookie来收集用户的信息,当我访问第三方网站的时候就会给我投递更精准的内容。或者我登陆了github,又在掘金页面点击github的链接,就可以携带上当前页面中github的cookie,然后跳转到github,还处于登录状态。可以设置浏览器禁用第三方cookie。

第三方服务器返回的js脚本可以访问获取当前域的cookie吗?@todo
当前域的js为什么只能修改却不能获取第三方cookie?@todo
关于第一方Cookie和第三方Cookie你需要知道的 - 简书 (jianshu.com)

开发者工具中的cookies解释如下。cookie会按照域列出来,包括当前页面和所有嵌套第三方的cookie。来自不同域的cookie可能出现在同一个表中,相同cookie可能出现在多个表中。
检查和删除 Cookie - Chrome 开发者工具中文文档 - html中文网

cookie 跨域

不同的浏览器对 cookie 的跨域处理是不一样的。chrome 中的 session cookie 是不能跨域的,ie 中的就可以。第一方 cookie 无法跨域,只能包含在同域或子域的请求中。

cookie 的跨域和其他的资源不一样,cookie 如果是父子域都不算跨域。只有两个子域或者域名完全不同的域互发请求才算跨域。cookie 跨域请求受到samesitecors的限制。服务端和客户端都可以设置 samesite 的值,ajax请求需要设置withCredentials=true

Token

token 就是令牌,最大的特点是随机性和不可预测性。用处很多,防御 csrf、防止表单重复提交、保存登录态 jwt。

csrf token 的话有两种组成,第一种就是随机数,第二种是用户非敏感的信息+时间戳+随机数,再加密的字符串。第一种的话,服务端要取出保存在服务端的token,进行比对。第二种只需要解密计算验证信息。

防止表单重复提交的 token 是个随机数,保存在 session 中,通过表单将token发送给server,server取出session中的token,进行比对,一致就放行,并删除session中的token。表单重复提交时,session没有对应的token,就不通过。

jwt 也是字符串,由三部分组成,header.payload.sign。
jwt 由服务端生成,先创建头部信息,并进行 base64 编码,然后创建负荷信息并进行 base64 编码,将两者编码后的字符串拼接起来形成 header.payload,然后用 hash256算法对拼接字符串进行哈希计算,生成 sign。当服务端收到请求中的 token 后,对 header.payload 进行 hash256 加密计算,生成新的 sign,比较两者 sign 是否相同。

  1. // header
  2. // alg 要使用的算法
  3. // typ 默认 JWT
  4. {"alg": "HS256", "typ": "JWT"}
  5. // payload
  6. // 因为这里的信息只经过了base64编码,所以不要存放用户的敏感信息。
  7. {"sub": "123123", "name": "john Doe", "iat": 123123};
  8. // sign
  9. sign = HMACSHA256(base64Url(header) + '.' + base64Url(payload), secretKey);

json格式通用性好,字节少,便于传输,服务端不需要保存,便于分布式系统使用,可防护 csrf。但是,payload仅对信息做了编码,secretKey加密密钥的保密性一定要做好,token可能会被劫持。
面试官:如何实现jwt鉴权机制?说说你的思路 | web前端面试 - 面试官系列 (vue3js.cn)
(1条消息) JWT如何在服务端验证Token令牌是否正确?_tea-house的博客-CSDN博客_jwt到底怎么验证token合法性
Token的详细说明,看这一篇就够了 - 简书 (jianshu.com)
API网关设计(一)之Token多平台身份认证方案(转载) - 玻璃鱼儿 - 博客园 (cnblogs.com)

登录实现

项目中客户端和服务端通信采用的http请求。针对http不会对传输内容进行加密,无状态特性。我们在项目中采用了非对称加密,对敏感信息进行加密,非对称加密采用的JSEncrypt。还采用了基于sessionid的登录认证机制。

cookie+session登录
用户初次登录,输入账号密码,服务端生成sessionid,sessionid就是一串机字符串。存在数据库,并放在set-cookie里,返回给客户端,客户端后续请求都会带上cookie,服务器去数据库里查找对应的sessionid,验证cookie中信息是否有效。

cookie+session有什么问题?服务端需要存放大量的sessionid。这种方式依赖于cookie,可能会被浏览器禁止携带cookie,但是我们项目是内网,不会被禁止。浏览器会携带cookie,容易遭到csrf攻击。

我们的项目还没有用到集群,如果设立服务器集群的话,需要同步sessioniid,sessionid的一致性维护成本是比较高的,或者设立缓存服务器,增加硬件成本。

jwt(,json web token)登录
jwt也是一串字符串,由header、playload、signature组成。签名算法可以采用对称的、非对称的。签名和解签都是服务器来完成,服务器收到token后,对签名进行解签,然后对消息体进行哈希,比较解签得到的哈希是否一致。

初次登录时用户输入账号密码经过非对称加密,向服务端发送请求,服务端验证账号密码是否正确,验证通过服务端就根据用户信息生成token,返回给客户端。客户端将token存储在本地,添加在后续的每个 xhr 请求上。服务器收到后用对token进行验证。

通过这种方式服务端就不需要存储sessionid,并且token不依赖于cookie,可以有效防御csrf攻击。

jwt存在的问题
token只要没过期,都可以登录成功,如果token可能被盗用,服务器也会认证成功。因此还加入了设备id,将token与设备id对应起来。`

token验证失败会怎么办?token验证失败的原因可能是token过期。项目中是放在前端进行处理的,保存获取token的时间,在请求前校验token是否过期,如果已过期则退出到登录页面重新登录。没有过期业务正常进行。
面试官:说说token失效的处理方式 - 掘金 (juejin.cn)

单点登录

电信这边,由人员主数据平台作为统一认证系统,负责生成所有其他业务系统的身份认证信息。人员主数据拥有所有电信公司所有人员的信息,与其他业务系统协商好单点验证的参数,在url中携带上。

用户登录上客户端之后,点击单点应用,客户端向人员主数据发起请求,返回相应系统的单点url。客户端拿到单点url后,将单点url与客户端自身后端的token、加密的随机数进行拼接,作为新的参数,向自身后台发送请求,后台收到请求后首先 token 进行验证,再对随机数进行验证,都合法,就用参数中的单点url重定向到目标系统。

目标系统收到请求后,根据url中的参数向人员主数据平台请求验证,校验合法,目标系统放行访问,直接进入首页。

客户端在这个过程中负责请求主数据获取到了目标系统的令牌,并拼接客户端自身token、加密后的随机数,防止单点url被重复使用,比如被有意者拷贝到浏览器访问。

  1. ssoLoginAuto=unify&cqiwmLoginKey=tyrz_login_cqiwm_key&loginKey=yzyLonginKey20181024&client_id=cq-app
  2. // 上面的字段,每个业务系统是一致的
  3. token // token字符串
  4. account // 随机字符串
  5. index// 一串数字
  6. // 这三个参数是赋于业务系统自己的值。

(1条消息) token实现单点登录原理_你胖了!!的博客-CSDN博客_token实现单点登录
前端常见登录实现方案 + 单点登录方案 - 掘金 (juejin.cn)

应用系统如何验证的 token?@todo 请求人员主数据?还是自己系统验证?token 是人员主数据签发的。那么加解密的密钥和哈希算法是人员主数据平台拥有的,如果不发送给人员主数据平台的话,那就是将密钥和哈希算法交给了应用系统。密钥和哈希算法的交互方式我觉得应该不是很高级,可能就是直接写死在项目中的。

登录用cookie,还是localStorage好?
如果就从cookie、sessionStorage、localStorage里选的话,从安全性考虑,还是localStorage好,因为localStorage不会在发请求的时候被浏览器自动带上,可以避免csrf攻击,就是需要自己进行过期处理。而使用cookie的话,过期了方便处理,但是没有localStorage安全。sessionStorage时效太短,对用户体验不是很好。

浏览器缓存机制

飞书云表格一面、美团二面

缓存位置

service worker、内存、硬盘、push cache、网络请求,优先级依次降低。

Service Worker 是一个独立的线程,可以监听到下载事件,拦截下资源进行缓存,浏览器发起的请求也会被拦截下来。由于可以拦截请求响应,为了安全起见,只能在HTTPS中使用。

首先在主脚本中注册 service worker 脚本,然后在入口 html 文件引入主脚本。service worker 脚本可以监听 install / fetch 事件,访问 CacheStorage 对象,添加需要缓存的资源,例如 index.html / index.js,当下次网络请求数据的时候就会从 service worker 中读取。

Service Worker 使用的时候有个需要注意的地方,如果 Service Worker 没有命中,那么会调用fetch函数根据优先级进行查找,但最后都会资源都会显示来自Service Worker

  1. // 新建index.html引用index.js
  2. // index.js
  3. if (navigator.serviceWorker) {
  4. navigator.serviceWorker.register('sw.js').then(function (registration) {
  5. console.log('service worker 注册成功')
  6. }).catch(function (err) {
  7. console.log('service worker 注册失败')
  8. })
  9. }
  10. // sw.js
  11. self.addEventListener('install', e => {
  12. e.waitUntil(
  13. caches.open('my-cache').then(function (cache) {
  14. // caches是一个只读属性,可以访问到 CacheStorage 对象,在Service Worker规范中定义。
  15. return cache.addAll(['./index.html', './index.js'])
  16. })
  17. )
  18. })
  19. self.addEventListener('fetch', e => {
  20. e.respondWith(
  21. caches.match(e.request).then(function (response) {
  22. if (response) {
  23. return response
  24. }
  25. console.log('fetch source')
  26. })
  27. )
  28. })

cachestorage对象
内存中的数据读取的比硬盘块,但是缓存持续性短,会随着进程的释放而释放。关闭tab页面,内存中的缓存也会被释放。

硬盘用来缓存,优点在于容量和存储时效性。并且即使跨站点,相同地址的资源一旦被硬盘缓存下来,下次请求的时候就直接从硬盘中获取数据。

强缓存与弱缓存

缓存机制有两种策略,强缓存和弱缓存。强缓存通过设置 expires 和 cache-control 请求头实现。采用强缓存的资源,在下次发起 http 请求的时候,返回 200,没有向服务器发送真正的请求,直接从内存或硬盘内取资源。

expires 表示资源过期时间,资源过期后需要再次请求服务器。expires 受限于本地时间,如果本地时间被修改了,会造成缓存失效。

cache-control 优先级高于 expires,可以在请求头和响应头中设置,有很多属性和值,来控制缓存行为。public 表示响应数据可以被客户端和代理服务器缓存,private 表示响应只能被客户端缓存,max-age 表示过期时间,s-maxage 表示过期时间,只在代理服务器中有效,no-store 表示不缓存,no-cache 表示资源被缓存,但是立即失效,下次请求时会向服务器发送请求验证资源是否过期,max-stale 表示设定的这个时间内即使缓存过期,也使用这个缓存,min-fresh 表示在设定的这个时间内希望获取最新的响应。

如果缓存过期,就会发送请求验证资源是否有更新。弱缓存通过设置 last-modified 和 etag 实现。当浏览器发送请求验证资源时,如果资源没有改变会返回 304,并且更新浏览器缓存的有效期。浏览器发起的请求设置了 if-modified-since / if-none-match 字段,if-modified-since 会将 last-modified 的值发送给服务器,服务器对这个值进行判断,如果这个资源的日期有更新旧将新的资源发送回来,否则返回 304。
last-modified 存在两个缺陷,如果服务器本地打开了缓存文件,那么 last-modified 的值也会被修改,导致没有命中资源,发送了相同的资源给浏览器,第二个是 last-modified 只能以秒计时,如果在毫秒级的时间里修改了文件,那么服务器仍会判断资源命中,不会返回正确的资源。
为了避免 last-modified 的两个缺陷,可以使用 etag,etag 表示资源的内容标识,if-none-match 会将浏览器资源的 etag 值发送给服务器,服务器收到后进行匹配,如果这个资源的 etag 有变化,那就将新的资源发送回浏览器,etag 的优先级高于 last-modified。


应用

对于频繁变动的资源
Cache-Control设置为no-cache使浏览器每次都要请求服务器,然后配合ETag/Last-Modified来验证资源是否有效,这样虽然不会节省请求数量,但是能有效减少响应数据的大小,如果资源没有更新,那么会返回 304,响应体中没有数据。

对于js、css文件
代码写完后,要用打包工具打包代码,对文件名进行哈希处理,只有代码修改后才会生成新的文件名。这样的话就给 js、css 文件设置一个较长的有效期。这样只有当 html 中引入的代码文件名发生了变化才会下载最新的代码文件,否则就使用缓存。

HTML 文件一般不缓存或者缓存时间很短,js、css、图片都是在 html 页面引入的,所以需要保证入口文件 html 文件能正确更新。

一文读懂http缓存(超详细) - 简书 (jianshu.com)

浏览器默认缓存算法
如果什么缓存策略都没有设置,浏览器会采用一个启发式算法,采用响应头中的Date减去Last-Modified值得10%作为缓存时间。

cdn 原理

cdn,内容分发网络。构造在现有网络基础上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等模块,使用户就近获取内容,减轻中心服务器压力,降低网络拥塞,提高用户访问速度和命中率。

动态域名解析
没有用cdn的时候,使用域名访问一个站点的过程是,用户输入url,浏览器对域名进行解析,交由本地dns服务器进行解析,还没有结果,由本地dns服务器以迭代的方式依次请求其他服务器,获取到ip后,根据ip地址发起请求。
用了cdn之后就不一样了。本地 dns 服务器查询到的不再是ip地址,而是一个cname别名记录,指向cdn 的全局负载均衡。cname 在域名解析的过程中承担了中间人的角色。本地 dns 服务器拿到这个cname,又向负载均衡系统发起请求,由负载均衡系统挑选出 cdn 边缘节点的 ip,返回给客户端。

cname 记录,又叫做别名记录,cname 就是域名相应的另一个域名。本地dns服务器向站点授权的dns服务器请求解析,得到一个cname,然后又去 cdn 服务商的 dns 调度服务器请求解析 cname,得到 cdn 节点的 ip 地址,返回给浏览器,浏览器通过ip去请求cdn节点。
如果没有资源,就让cdn节点去做代理请求源站点。

@todo 域名缓存问题。内网、外网、内外网切换的时候,域名缓存时间的设置,可能会导致内外网切换时的时效问题。

什么是CNAME以及CDN? - 知乎 (zhihu.com)
面试官:如何理解CDN?说说实现原理? | web前端面试 - 面试官系列 (vue3js.cn)

负载均衡
没有返回ip地址,本地dns会向负载均衡系统再发送请求,进入cdn的全局负载均衡系统进行智能调度。@todo cname是否就是cdn负载均衡系统的域名?为什么cname又叫加速域名?

首先看用户的ip地址,查表知道物理地址,找到相对最近的边缘节点。
看用户所处的运营商网络,找相同网络的边缘节点。
检查边缘节点的负载情况,选择负载轻的节点。
检查其他情况,服务能力、带宽、响应时间等。
通过这些综合因素的判断,得到最合适的边缘节点,然后将这个节点的ip返回给用户,用户就能就近访问cdn的缓存代理。

cdn 缓存代理
有两个指标,用来衡量cdn的缓存系统的性能,命中率和回源率。用户请求的资源恰好在 cdn 节点的缓存系统里,命中率是命中次数与所有访问次数之比。缓存里没有,必须用代理的方式回源站获取,回源率就是回源次数与所有访问次数之比。

cdn 缓存还可以设立多级节点,一级缓存、二级缓存,一级缓存直接源站,配置相对高一些,二级缓存直连用户,配置相对低一些。

@todo 多级cdn代理资源缓存配置如何配置。
【CDN 最佳实践】CDN缓存策略解读和配置策略 - 知乎 (zhihu.com)

网络攻击

XSS

攻击原理

字节飞书云表格一面
Cross Site Scripting,跨站脚本攻击。恶意代码没有经过过滤就被注入到网页中,浏览器无法分辨正常和异常的脚本,导致恶意代码被执行,获取用户的信息或利用用户认证信息冒充用户向网站发起请求,进行恶意操作。

一个典型的示例如下,标签属性中通过拼接字符串,形成新的代码片段:

  1. <input type="text" value="<% getParamter=("keywork") %>">
  2. <button>
  3. 搜索
  4. </button>
  5. <div>
  6. 您搜索的关键词是:<%= getParamter("keyword") %>
  7. </div>
  8. /**用户输入"><script>alert("XSS");</script>"后,上述`HTML`代码会变成:*/
  9. <input type="text" value=""><script>alert("XSS");</script>">
  10. <button>
  11. 搜索
  12. </button>
  13. <div>
  14. 您搜索的关键词是:<%= getParamter("keyword") %>
  15. </div>

用户输入的内容存放在固定的容器和属性中,利用输入的内容,拼接成新的代码字符串,突破原有的位置限制,形成代码片段。攻击者通过在目标网站上注入脚本,在目标网站上运行,进行恶意操作。

模板中都带有转义配置,让所有插入到页面中的数据都默认进行转义。@todo这里的模板指的是Vue里面的那种模板吗?还是elementui中的输入表单那种模板?

然而注入a标签href属性的恶意内容,能跳过转义,并且没有形成新的代码段,就是利用的正确的href属性。javascript: 这样的字符串出现在特定位置也会发生 xss 攻击。

  1. <a href="<%= escapeHTML(getParameter("redirect_to")) %>">跳转...</a>
  2. // 当url为 http://xxx/>redirect_to=javascript:alert('xss')的时候,a标签会变为
  3. <a href="javascript:alert(&#x27;xss&#x27;)">跳转...</a>
  4. // 当用户点击a标签的时候,就会执行攻击者的恶意脚本。
  5. // escapeHTML 方法是自定义的方法,遵顼这样的转换规则,
  6. // & &amp; < &lt; > &gt; " &quot; ' &#x27; / &#x2F;

对于链接跳转,如 a href/ location href,要检查其内容,禁止以 javascript: 开头的链接和其他非法 scheme。一般使用白名单进行校验。实在有点细,@todo javascript - XSS 黑名单/白名单 - IT工具网 (coder.work)

  1. allowSchemes = ["http", "https"] @todo这里不应该加 javascript: 吗?
  2. valid = isValid(getPrameter("redirect_to"), allowSchemes);
  3. if (valid) {
  4. }

将一个数据json序列化之后插入页面会加快网页加载速度。但是插入json的地方不能使用 escapeHTML 方法,因为转义双引号之后,json格式会被破坏。内联json中包含 u+2028 / u+2029 时,不能作为js的字面量会抛出语法错误,包含字符串 时,当前script标签会被闭合,后面增加 script 会拼接成新的代码片段。又要增加工作量,对json转义。

  1. function escapeEmbedJSON(josn) {
  2. // u+2028 \u2028 u+2029 \u2029 < \u003c
  3. }

@todo为什么json数据可以加快页面加载速度。@todo内联json是什么,这两种错误情况实践一把。

所以转义工作应该采用成熟、通用的转义库。

总的来说,xss 注入方式主要有两种,用户原创内容和请求参数都可能是攻击的来源:

  • html内嵌文本中,注入 script 标签。
  • 内联 javascript 中,通过拼接数据突破原有位置限制,形成新的代码段。
  • 利用标签的 href/ src 属性,包含 javascript: 可执行代码字符串。
  • onlaod/ onerror/ onclick 事件,注入不可控代码。
  • url参数、post参数、referer、cookie。

攻击类型

根据攻击来源,可以将 xss 攻击分为存储型、反射性、dom 型3种。这是从漏洞利用角度来区分的,xss 就是 xss,只有 dom型稍有不同。

  • 存储型:攻击者将恶意代码提交到目标网站数据库中,当用户访问网站时,服务端从数据库提取出数据,拼接在 html 中返回给浏览器,浏览器收到 html 文档后进行解析,混在其中的恶意代码也被执行。 常见于用户保存数据功能的网站,论坛发帖、用户私信、评论等功能。
  • 反射型:攻击者构造特殊的 url ,将恶意代码保存在 url 中,用户打开这个 url 访问站点,站点服务端将恶意代码从 url 取出来,拼接在 html 中返回给浏览器,浏览器对 html 文档进行解析,并执行其中的恶意代码。
    常见于通过 url 传递参数的功能,网站搜索、跳转等。需要诱导用户点击,post 相对 get 安全些的就是 post 需要构造表单。
  • dom 型:攻击者构造特殊 url ,将恶意代码隐藏在其中,用户打开 url,浏览器收到响应后解析执行,js 取出 url 中的恶意代码并执行。

存储型 xss 的恶意代码存储在数据库中,反射型和dom型 xss 存在 url 中。dom型 xss 的恶意代码由前端执行,存储型和反射型由服务端执行。@todo反射型和dom型的特殊url指的是统一个url吗?是指的服务器请求url?还是页面中链接的静态资源url?

dom型 xss 与其他 xss 的最大区别是 dom xss 不经过被攻击服务器,通过网页本身的 js 进行渲染。

@todo体验。9. DOM 型 XSS 攻击实战 - 邓方鸣-在线文档库 (dengfm.com)

[这一次,彻底理解XSS攻击 - 掘金 (juejin.cn)](


防御手段

xss 攻击有两个特点:① 攻击者注入了恶意代码;② 浏览器执行了恶意代码。防御 xss 的话有三种手段,输入过滤、纯前端渲染、转义 html、谨慎使用 js 代码。

输入过滤
对于明确输入类型的用户输入内容进行输入过滤,例如数字、url、电话号码、邮件地址,确保输入的类型正确。
不能依赖于输入侧过滤,攻击者可能直接构造请求,向后台直接提交恶意代码,这样就绕过了前端的输入过滤防御。在后端写入数据库这一输入侧过滤也是不可取的,因为这个内容输出的地方不固定,可能作为 html,也可能作为 ajax 数据,可能导致转义后的字符串在前端或客户端不可用。就比如 5<7 经过 html 转义后变成 5&lt;7,作为html拼接的时候,浏览器会将其 unescape 成正确的 5<7,但是如果作为 ajax 数据返回的时候,前端获取到的就是转义后的字符串,并不是反转义之后的正确的结果。

所以还要从 html、浏览器执行恶意代码这两个方面出发。预防存储型和反射性 xss 攻击都是攻击者提交恶意代码,交给服务器,由服务器解析后插入到响应的 html 中,被浏览器解析执行。可以采用前端渲染html转义来预防。

纯前端渲染

页面交给前端渲染,服务器将相应的 html、js、css、图片等资源返回给浏览器,html 主要负责引入 js、css、图片等资源,页面具体内容的构建通过 js 实现,通过使用安全的 dom api 来明确设置或添加文本、属性、样式,不会被恶意内容破坏原有标签结构。

然而前端渲染存在3个主要问题,第一个问题是前端渲染也需要对 html 模板插入的值进行完善细致的转义,第二个问题是前端渲染性能不高,而且 seo 不好,高要求的系统还是需要后端拼接 html,第三个问题是js代码本身不够严谨,可以将不可信数据作为代码来执行,需要预防 dom型 xss。

针对第1、2个点,就需要对 html 进行转义。针对第3个点,js 中事件监听器、href 属性、eval、定时器 api都能将字符串作为代码运行,需要谨慎拼接,不能将不可信的数据拼接成字符串传递给这些属性或api。

转义html

对 html 转义的话。一般使用成熟、通用的 csp 内容安全策略。csp 策略可以告诉浏览器,哪些资源允许从哪些源加载,还可以限制转义用户输入的内容,拥有丰富的指令可以针对不同上下文配置相应的规则。可以检测到 html 标签内容注入、内联外联 js 脚本、内联 json、javascript: 字符串。@todo csp很强大,可以限制很多注入,是同源策略的极致辅助。

后端的话可以使用完善细致的转移库,例如java这边有encode转义库。

前端安全系列(一):如何防止XSS攻击? - 美团技术团队 - 博客园 (cnblogs.com)

DOM-XSS攻击原理与防御 - Mysticbinary - 博客园 (cnblogs.com)

通过上述的4种方法其实也很难完全避免 xss,因为 xss 的防护是一个很繁杂的工作,不仅需要对全部需要转义的位置进行转义,还要防止多余和错误的转义。

在开发种尽量避免 xss 漏洞的措施主要有:使用模板引擎时开启html转义功能,设置内联事件监听器最好使用 addEventListener 来绑定,js 避免拼接 html 最好使用 createElement 方法,采用 csp 策略、限制输入长度来增加攻击难度,主动检测扫描 xss。

检测 xss

有两种方法可以检测 xss 漏洞,第一种是使用 xss 攻击字符串手动检测,另一种是使用扫描工具自动检测。

https://github.com/0xsobky/HackVault/wiki/Unleashing-an-Ultimate-XSS-Polyglot

https://github.com/andresriancho/w3af[github.com](

csrf

Cross Site Request Forgery,跨站请求伪造。csrf 攻击的原理就是伪造完整的 url 请求,利用用户的登录认证信息,绕过后台验证,冒充用户进行某项操作。

一种典型的攻击,用户登录进网站a的网页,浏览器保存了用户在网站a的登录凭证。这个时候用户打开了攻击者网站,攻击者向网站a发起请求,此时浏览器默认携带用户在站点a的登录凭证。网站a误认为是用户发起的请求。这样攻击者就在用户不知道情的情况下,冒充了用户,让网站a执行了伪造请求的操作。这种情况是在没有同源策略以及访问第三方网站的情况。

还有一个典型的案例是2007年谷歌邮件的csrf漏洞。用户收到一封垃圾邮件,“甩卖比特币,一个只要998”,查看之后就关闭了邮件,过了几天,小明发现自己的域名已经被转让,同时受到了攻击者开出的赎回价格,才知道自己被攻击了。域名的转让,必须拥有验证码,而域名的验证码就保存在邮箱里。查看邮件的源代码发现,页面中包含了一个隐藏的post表单,这个表单向谷歌邮件服务器发起了一个请求,给用户配置了一个过滤器,将用户的邮件都转发到攻击者的邮箱,黑客就可以查看小明的所有邮件,拿到验证码之后,就可以要求域名服务商将域名重置给自己。这种情况伪造的请求是在本域发起的,绕过了同源策略的限制,但是核心原理也是利用了用户的登录凭证。

攻击手段

常见的攻击类型有3种,get请求类型、post请求类型、链接类型。

通过img标签发送get请求。当用户访问含有这个img的页面后,就会自动用img中的url发起http请求。可以被samesite:lax/strict阻止。

post类型,一般会创建一个自动提交的表单。访问这个页面之后,表单会自动提交,发起http请求。samesite: none就阻挡不了。

  1. <form action="http://bank.example/withdraw" method="POST">
  2. <input type="hidden" name="account" value="xiaoming" />
  3. <input type="hidden" name="amount" value="10000" />
  4. <input type="hidden" name="for" value="hacker" />
  5. </form>
  6. <script>document.form[0].submit();</script>

链接类型的 csrf 一般会在设置一个a标签或者图片或者广告之类的形式诱导用户点击,当用户点击链接的时候会发起http请求。

主要防御手段

csrf 攻击一般发生在第三方网站,被攻击网站无法防止攻击发生。攻击者无法获取登录凭证,只能冒用。

根据攻击的特点,从这两方面考虑,一方面阻止不明外域的访问,采用同源策略和 samesite cookie,另一方面提交本域才能获取到的验证信息,csrf token 和 双重 cookie。

同源检测
在http请求中,大多数请求都会携带 origin / referer 头部字段,用来标记来源。origin 表示来源域名,referer 表示来源具体url。可以使用这 origin/ referer字段来确定来源域名。

所有跨域请求和除了get / head 的同源请求都会携带上 origin 头部字段。但是在 ie11 同源策略中并不会向跨域 cors 请求添加 origin 字段,referer 是唯一能确定来源的字段。302 重定向之后的请求也不会包含 origin 字段,因为对于 302 重定向请求来说,不希望将当前域名暴露给新服务器。

referer 包含了发起请求的页面具体url。不仅包含了来源域名,也包含了用户访问页面的隐私信息。2014年,w3c制定了关于 referer 的策略,referer policy 标头,用来限制哪些请求会发送 referer 字段,以及 referer 字段包含的信息。no referer 表示不携带,no referer when downgrade 表示从https协议降级为http时不发送,是默认值,same-origin 表示同源请求会发送完整的url,strict-origin 表示同级别安全等级时只会发送源,origin when cross origin 表示同源请求会发送完整的url,非同源只发送源,strict-origin-when-cross-origin 表示同源请求会发送完整url,同级别安全等级只发送源,降级的时候不发送,unsafe-url 表示请求都会发送完整url。可以通过 csp / 页面头部的 meta 标签/ a 标签的 refererolicy 属性设置 referer 策略。@todo需要复习。

还有一些情况没有 referer 字段或不可信,ie6/7 使用 location.href 进行跳转会丢失 referer,使用 window.open 也会缺失这个字段。https 降低为 https,referer 会丢失,点击 flash 到达另一个网站,不可信。
(1条消息) 记录一次strict-origin-when-cross-origin的错误_三水草肃的博客-CSDN博客_strict-origin-when

然而当两个字段都不存在时,建议直接阻止。但是如果是搜索引擎,那么站点接收到来自搜索引擎的链接,那么可能会被当作csrf拒绝掉,所以还要通过 Accept: text/html; Method: GET 过滤掉页面请求情况,但是这样做的话,页面请求就暴露在了 csrf 攻击范围内,如果在页面请求(get请求)对当前用户做了修改操作的话,就失败防御效果了。18年的文章,感觉有点老,这样的做法不一定对,现在百度的链接,都是[http://www.baidu.com/link?url=](http://www.baidu.com/link?url=)字符串这样的形式了,应该是通过后台进行重定向。 @todo待验证,链接的方式为什么会被判定为csrf。

而且同源策略还有2个问题,第一是没法防御本域发起的情况,比例攻击者有权限在本域发布链接、图片等,那么就可以直接在本域发起攻击,此时同源策略无法防御 csrf。另一个问题是,仅仅依赖 origin / referer 并不准确,origin 可以通过一些工具 fildder 进行修改,referer 也是。除此之外,还可以利用同源策略不限制资源嵌入、写请求的特点进行攻击。

csrf token
token 就是令牌。请求网页后,服务器生成一个随机数 token,将这个 token 存放到 session 中,将 token 添加在页面的每一个 a、form 标签中,后续a标签、form表单在发送请求的时候就会带上 token,后端对 token 进行校验。后端取出存放在 session 中的 token 进行对比,如果不同就中止请求。

这种基于session 机制的 csrf token 在分布式服务器场景存在 session 同步的问题,需要将 token 存储在 redis 这样的公共存储空间。而且基于 session 存储的 token,在读取和验证的时候比较复杂并且消耗性能。改进使用一种加密 token 的方式,token 是一个加密后的字符串,服务端不需要保存这个 token。验证的时候只需要进行解密计算,进行对比即可。这种 token 通常是 useId、时间戳、随机数通过加密生成的字符串。在对 token 解密后,服务器校验其中的 userId、时间戳,验证是否合法有效。

随机数有什么用@todo 科普 | 一文读懂“随机数” | 登链社区 | 深入浅出区块链技术 (learnblockchain.cn)

采用 token 的方法,有2个问题,第一个是要保证所有页面中的 a标签、form表单都添加了 token,另外动态添加的html标签需要编写代码添加 token,第二个是如果页面存在 xss 漏洞,这个 token 可能会被盗用。

使用验证码和再次输入密码,也能防御 csrf 攻击。但是有手段能绕过,但是现在的一些很灵活的验证方式很难绕过。

双重Cookie验证
改进后的加密 scrf token 仍存在两个问题,无法在通用的拦截上统一处理所有接口。并且存在 xss 漏洞的威胁。

在请求页面的时候,后台生成一个随机数 token 作为 cookie 添加到当前域中,后面前端发起请求的时候将这个 cookie 取出,添加到 url 中。后端验证url中的 cookie 和请求头中的 cookie 是否一致,不一致就拒绝。

通过这样的方式,可以实现统一的拦截处理,后端校验也比较方便。但是仍存在一些问题,最主要的问题是子域之间无法共享 cookie,cookie 必须注入在父域中,然而这样任何一个子域都可以修改这个 cookie,如果某个子域存在 xss 漏洞,那么这种防御方式就失效了。增加了额外的 cookie。

samesite cookie
除了上面的两种主动防御方式,csrf token/ double cookie,校验源的自动防御策略同源策略,但是同源策略只能进行一些基础的防范,本域发起的 csrf 无法防御,还有一种主动防御措施。

为了从访问来源这个角度解决 csrf,对 http 协议进行了改进,为 cookie 添加了 samesite 属性。samesite 表示这个 cookie 是否可以用作第三方 cookie 在跨域请求时携带上。

strict 表示这个 cookie 不能作为第三方 cookie,任何跨域请求都不会携带上。lax 允许get表单、链接、预加载请求携带,post表单、iframe、script/img、ajax请求这些都不能携带。none 在跨域的时候允许携带。

samesite 存在一个主要问题,如果将父域的cookie设置为 strict,那么向子域发起请求时,也无法携带父域的cookie。设置为 lax,也是如此,只有满足 get表单、链接请求、预加载请求才能携带。而且 samesite 的兼容不好,18年的数据,需要考证@todo。
前端安全系列(二):如何防止CSRF攻击? - 美团技术团队 (meituan.com)
CSRF攻击防御原理 - FreeBuf网络安全行业门户

其他防护手段

csrf 攻击可能来自攻击者的网站,有文件上传漏洞的网站,第三方论坛等用户内容,被攻击网站的评论功能。
攻击者的网站发起链接请求会发起csrf攻击吗?@todo
文件上传漏洞如何发起 csrf?@todo 7.CSRF攻击和文件上传漏洞攻击 - All_just_for_fun - 博客园 (cnblogs.com)

严格管理上传接口,限制上传的内容。添加 x-content-type-options: nosniff 防止黑客上传 html 内容的资源被解析成网页。对用户上传的图片进行转存和校验,对用户填写的图片链接进行处理。当前用户打开其他用户的链接时,告知风险。

点击劫持

攻击原理

攻击者使用一个透明的iframe覆盖在网页上或用图片覆盖网页某处位置,诱导用户在网页上进行操作。

预防手段有两种,X-Frame-Optionsjs防御
X-Frame-Options字段,在服务器内设置HTTP响应头的该字段,有三个值可以选择:DENY(拒绝当前页面加载的所有iframe页面);SAMEORIGINiframe页面只能为同源域名下的页面);ALLOW-FROM-ORIGIN(允许iframe加载的页面地址)。
js防御。

  1. <head>
  2. <style id="click-jack">
  3. html {
  4. display: none !important;
  5. }
  6. </style>
  7. </head>
  8. <body>
  9. <script>
  10. if (self == top) {
  11. var style = document.getElementById('click-jack')
  12. document.body.removeChild(style)
  13. } else {
  14. top.location = self.location
  15. }
  16. </script>
  17. </body>
  18. <script>
  19. // window.self 是对当前窗口自身的引用。它和window属性是等价的。
  20. // window.parent 返回父窗口。
  21. // window.top 返回顶层窗口,即浏览器窗口。
  22. </script>

中间人攻击

客户端与服务端建立连接的过程就被中间人控制了,客户端与服务端认为连接还是安全的,但实际上每次请求响应都被中间人拦截。中间人攻击有3种形式:抓取报文获取明文信息;获取泄露的会话密钥,解密历史报文;非法中间代理,窃取明文信息。

https防范中间人攻击做了混合加密,数字证书。混合加密防止了明文传输以及会话密钥的泄露。关键在于证书的合法性,所以对合法性的校验很重要,如果是浏览器判断不对劲的证书会向用户提出报警,让用户来选择是否信任证书(App的信任操作是交给开发者控制的,一般只信任固定机构的证书)。中间人攻击发生在TLS握手阶段,冒充服务端和客户端,分别与客户端和服务端进行了加密算法、随机数的互换,生成两套会话密钥,这样的混合加密等于形同虚设,所以才有了数字证书和数字签名。

  • 示例:使用公共Wifi就可能发生中间人攻击,如果在通信过程中传输敏感信息,就暴露给了攻击者。
  • 防御:
    • 只访问HTTPS的网站。可能遭到SSLStrip实施的协议降级攻击。
    • 不要使用公共Wifi

HTTPS可以防范中间人攻击吗?HTTPS绝对安全嘛?可以防范。要实施中间人攻击需要满足硬件条件,攻击者需要控制数据传输的链路或路由。攻击者在HTTPS进行TCP建立连接过程中拦截客户端和服务端的请求响应,冒充服务端与客户端,分别与客户端和服务端建立通信连接。然后进行证书的互换。但是https通过数字签名保证了数字证书的完整性,又通过证书机构的非对称加密保证了数字证书的有效性。
不是绝对安全的。

HTTP劫持

会话劫持

DNS劫持

DNS请求会先查询系统缓存和浏览器缓存,未命中再请求DNS服务器。DNS劫持就是攻击者想尽办法,通过恶意软件或者控制用户的路由器,修改用户的缓存记录,将用户解析域名的请求,定向到他的域名服务器上,解析成不存在的IP或者恶意IP。

DNS攻击有很多方式,恶意软件修改本地DNS设置,定向到攻击者的流氓DNS服务器,重定向到恶意网站。入侵控制用户路由器,重定向路由器所有用户的请求。

代理缓存污染攻击

md5

强碰撞与弱碰撞

摘要算法会提取原始文件中的摘要而丢弃很多细节信息。然而摘要算法对信息进行了提取,丢弃了很多细节信息,那么很有可能会有两个或两个以上的原文计算结果一致,因此在知道计算结果的情况下,罗列所有原文,用一个一个原文去试,就可能试成功,这就叫弱碰撞
强碰撞

md5 算法具有较强的抗弱碰撞特性,

编码与反编码

(1条消息) 网络安全-WEB中的常见编码lady_killer9的博客-CSDN博客网络安全代码大全

事件机制

我们可以为元素注册一些事件,比如点击事件。但是HTML元素是分层的树结构,需要明确哪些元素的事件先发生,哪些后发生,事件机制就是为了规定各个层次DOM元素的事件触发的先后顺序。

DOM2规范定义事件流包含3个阶段:事件捕获、到达目标、事件冒泡。事件冒泡是从事件发生的最具体的元素向上传播到window.document对象。事件捕获是从文档对象向下传播到事件发生的具体元素。

以函数方式注册的事件处理程序有一个默认参数,event对象。DOM0 添加事件处理程序的方式是将函数赋值给元素的事件处理程序属性。移除事件处理程序是通过将元素的事件处理程序属性赋值为null。
DOM2 Events定义了添加、移除方法用于添加、移除事件处理程序。addEventListenerremoveEventListener,两个方法的参数都有事件名(例如click)、事件处理函数、捕获阶段处理还是冒泡阶段处理布尔值,默认冒泡阶段处理,为false
IE定义了attachEventdetachEvent方法来添加和移除事件处理程序,只接受两个参数,事件处理程序名(例如onclick)和事件处理函数。

事件对象event包含触发事件的元素、事件的类型、位置信息等。

事件委托

首先我们知道JS中添加过多的事件处理程序会造成性能的下降,增加维护的复杂度。例如一个节点下有三个子节点,如果分别为每个子节点添加事件处理程序,那么在子节点动态变化的时候需要重新添加事件处理程序,如果使用事件委托,只需要给父节点或文档对象添加事件处理程序,在回调函数里使用event.target找到目标元素进行相应操作即可。

事件委托就是利用事件冒泡机制,将子元素的事件委托到更外层的元素上,在更外层元素上对某一类型事件做统一处理(,又叫事件代理)。

通过事件委托方式可以有效减少页面所需内存,不必为每一个元素都添加事件处理程序;降低开发维护的复杂度,子节点的动态变化不用重新绑定事件。

常用事件

常用的事件load事件。load 事件在全部资源(html、js、css、图片)加载完成时触发。

unload 事件一般是 在从一个页面导航到另一个页面时触发,最常用于清理引用,以避免内存泄漏。

DOMContentLoaded 事件一般在load事件之前触发,表示DOM构建完毕,可以进行交互,外部资源(图片、css 样式表、js 文件)可能还未加载完。页面开始加载到DOMContentLoaded触发这段时间就是首屏加载时间。这个事件触发之后就会生成渲染树,就可以进行渲染了。

事件对象Event

阻止事件传播

可以使用stopPropagation/stopImmediatePropagation方法。
stopPropagation:主要是用来阻止不同层级元素的事件传播,在事件捕获阶段有事件处理程序执行了该函数后会阻止事件的向下传播,在事件冒泡阶段执行了该函数的话就阻止事件的向上传播。不能阻止同一个元素的其他事件处理程序的执行。

  1. <div id="d1" style="width: 500px; height: 300px; background: green;">
  2. <div id="d2" style="width: 300px; height: 200px; background: red;">
  3. </div>
  4. </div>
  5. <script>
  6. var d1 = document.getElementById('d1')
  7. var d2 = document.getElementById('d2')
  8. d1.addEventListener('click', (event) => {
  9. event.stopPropagation(); // 执行后会阻止d2元素事件处理程序的执行,但是并不会阻止同一个元素的其他事件处理程序执行。
  10. console.log('d11');
  11. }, true)
  12. d1.addEventListener('click', (event) => {
  13. console.log('d12');
  14. }, true)
  15. d2.addEventListener('click', (event) => {
  16. event.stopPropagation();
  17. console.log('d21');
  18. }, false)
  19. d2.addEventListener('click', (event) => {
  20. console.log('d22');
  21. }, false)
  22. </script>

stopImmediatePropagation:我们可以给元素添加多个事件处理程序,在其中一个事件处理程序中调用这个函数后,会阻止剩下事件处理程序的执行。

  1. <div id="d1" style="width: 500px; height: 200px; background: green;"></div>
  2. <script>
  3. var d1 = document.getElementById('d1');
  4. d1.addEventListener('click', (event) => {
  5. event.stopImmediatePropagation();
  6. console.log('d11');
  7. }, false)
  8. d1.addEventListener('click', (event) => {
  9. console.log('d12');
  10. // 这个事件处理程序不会执行,如果第三个参数改为true会改变执行顺序,
  11. // 因为事件捕获相当于拦截事件,在事件处理未到达元素的时候就进行了处理。比冒泡阶段早处理。
  12. }, false)
  13. </script>

阻止标签默认行为(蚂蚁体验技术部小程序生态基础技术团队)

event.preventDefault函数。可以取消标签的默认事件行为。

EventTarget对象

addEventListener/removeEventListener/dispatchEvent方法。

渲染流程

浏览器内核

多进程
  • 浏览器进程(主进程):浏览器的页面展示,与用户的交互。
  • GPU进程:3D渲染
  • 插件进程:每种类型的插件对应一个进程。
  • 浏览器渲染进程(浏览器内核)(渲染引擎
    • GUI渲染线程:DOM树解析、CSS树解析、生成渲染树
    • js引擎线程:执行 js 代码。js 引擎线程与 GUI 渲染线程互斥。js 引擎执行js代码会阻塞GUI工作。
    • 事件触发:管理任务队列
    • 异步HTTP请求线程
    • 定时器触发线程

渲染引擎包含:

  1. HTML解析器(解析HTML文档,将HTML文档转换为DOM树-DOM树就是一种多叉树,用栈来确定父子节点的关系)
  2. CSS解析器(针对CSS内容,构建CSSOM树)
  3. javascript引擎(解释执行js代码,可以通过DOM、CSSOM修改页面内容、样式规则,改变渲染树)
  4. 布局模块(DOM树和CSSOM树创建好了,将两者结合,得到渲染树,布局就是针对渲染树,确认每个元素的位置、大小等信息,然后进行流式布局)
  5. 绘图模块(使用图形库将布局计算后的渲染树绘制成可视化图像)。

浏览器从拿到页面到页面展示具体有2个过程,解析过程、渲染过程。解析过程包括 dom 树构建、样式计算、布局树的合成。渲染过程包括建立图层树、生成绘制列表、生成图块并栅格化、显示器显示内容。

解析过程

http线程发起网络请求,通过判断响应报文中的content-type知道html页面返回,一边将接收字节流,一边发送给渲染线程,首先将字节流转换为html字符串
对 html 进行解析,遇到link标签,就并行下载样式表,遇到style标签或遇到内联样式,就进行解析,构建CSSOM树(此时DOM树中的对象还只有元素节点、属性节点、文本节点,没有样式),遇到 script 标签就阻塞html的解析,解释执行js代码,遇到js外部文件,同样要暂停文档解析,立即下载执行,执行完再恢复文档的解析。
CSSOM树和DOM树构建完成,那么就合成渲染树,确定DOM元素的位置和大小信息,对DOM元素的外观进行绘制,最后生成图像展示在浏览器窗口上。
画了20张图,详解浏览器渲染引擎工作原理 - 掘金 (juejin.cn)

css、js、html之间的阻塞关系

CSS内联样式css样式文件的下载解析都不会阻塞文档解析。会阻塞页面渲染,会阻塞`后面JS的执行。
js代码和js外部文件的下载执行会阻塞DOM树的构建。
CSS 和 JS 阻塞二三事 - 掘金 (juejin.cn)
css加载会造成阻塞吗? - 陈陈jg - 博客园 (cnblogs.com)

解析HTML文档

浏览器接收到的HTML文件是字节数据,先转换成字符串,然后对html文档进行解析,转换为标记(token-字符串类型,分为Tag token和Text token)。然后将token转换为Node,用栈进行处理,栈顶指向正在处理的token,遇到start token就入栈,遇到end token就出栈,不断的入栈出栈,将html字符串都解析完。
构建完的DOM树只包含文本值节点属性值,不包含样式。节点属性值有很多,事件属性、name、value属性等等。
image.png
DOM有什么用,DOM有三个作用,第一是作为页面构成的基本单位,第二是对js提供了接口,在js和页面之间建立起了联系,第三是可以起到防护作用,不安全的内容可以在DOM解析阶段过滤掉。

处理 js 脚本

遇到script标签会暂停文档的解析,将控制权交给js引擎,等js引擎运行完,浏览器再从中断的地方恢复继续解析文档。
遇到外部js文件,会暂停文档的解析,去下载js文件,下载完执行完,会从暂停的地方继续解析文档。添加了defer属性,下载的同时不会暂停文档解析,而且推迟到文档加载完再执行。添加了async属性的话,js文件的下载不会暂停文档的解析,下载完后执行js脚本,如果文档还在解析,那么会阻塞文档的解析。

处理样式

遇到 link 标签加载 stylesheet 外部样式表,会发起网络请求,下载外部 css 文件,不会暂停文档的执行。css下载和解析会阻塞后面js的执行。
遇到style标签或内部样式,会构建CSSOM树,不会阻塞DOM树的构建。

CSSOM树描述的是选择器之间的层级关系。CSS样式有3个来源,上面说到过。遇到那三个来源的时候首先需要对样式属性值进行标准化处理
然后就根据选择器之间的依赖关系进行样式计算,如果没有遇到明显的选择器依赖关系,就挂载到body对象下(就比如说,写在 title 标签后的 style 标签内的类选择器,那么这个类选择器的样式对象就直接挂载到 body 样式对象下面)。

计算css的过程必须遵守两个规则,分别是继承层叠,所以需要对CSSOM树进行递归处理,从根节点往下合并样式规则,最终得到具体的样式。CSSOM树的节点值都是样式。
(1条消息) 前端面试题:DOM和CSSDOM树渲染过程_ღ故里᭄ꦿ࿐的博客-CSDN博客_dom树和cssom树

CSSOM树与DOM树是两个独立的数据结构,CSSOM树的构建不会阻塞DOM树的构建,会阻塞页面的渲染。
image.png
CSSOM有2个作用,第一是对js提供了操作样式的能力,第二是为元素提供了样式信息。

css为什么阻塞后面js执行?后面js的加载执行会被CSS的下载构建阻塞,因为js可以通过CSS对象修改元素的样式,所以在加载构建CSS的时候就会阻塞后面js的执行。等到CSSOM树构建完才继续js的执行。

构建布局树

根据已经生成好的DOM树CSSOM树,从DOM树的根节点开始遍历CSSOM树,查找DOM对象对应的样式规则。找到之后,就将CSSOM树的节点挂载到DOM树上,作为渲染树(chrome是这么做的,firefox是创建一个新的数据结构,用来连接DOM树CSSOM树的映射关系)。

渲染过程

生成图层树

如果你觉得现在DOM节点也有了,样式和位置信息也都有了,可以开始绘制页面了,那你就错了。因为你考虑掉了另外一些复杂的场景,比如3D动画如何呈现出变换效果,当元素含有层叠上下文时如何控制显示和隐藏等等。
为了解决如上所述的问题,浏览器在构建完布局树之后,还会对特定的节点进行分层,构建一棵图层树。
那这棵图层树是根据什么来构建的呢?一般情况下,节点的图层会默认属于父亲节点的图层(这些图层也称为合成层)。那什么时候会提升为一个单独的合成层呢?
有两种情况需要分别讨论,一种是显式合成,一种是隐式合成

下面是显式合成的情况:拥有层叠上下文的节点,需要剪裁的地方。层叠上下文也基本上是有一些特定的CSS属性创建的,一般有以下情况:

  1. HTML根元素本身就具有层叠上下文。
  2. 普通元素设置position不为static并且设置了z-index属性,会产生层叠上下文。
  3. 元素的 opacity 值不是 1
  4. 元素的 transform 值不是 none
  5. 元素的 filter 值不是 none
  6. 元素的 isolation 值是isolate
  7. will-change指定的属性值为上面任意一个。(will-change的作用后面会详细介绍)

比如一个div,你只给他设置 100 * 100 像素的大小,而你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条会被单独提升为一个图层。

接下来是隐式合成,简单来说就是层叠等级低的节点被提升为单独的图层之后,那么所有层叠等级比它高的节点都会成为一个单独的图层。
这个隐式合成其实隐藏着巨大的风险,如果在一个大型应用中,当一个z-index比较低的元素被提升为单独图层之后,层叠在它上面的的元素统统都会被提升为单独的图层,可能会增加上千个图层,大大增加内存的压力,甚至直接让页面崩溃。这就是层爆炸的原理。

值得注意的是,当需要repaint时,只需要repaint本身,而不会影响到其他的层。

生成绘制列表

接下来渲染引擎会将图层的绘制拆分成一个个绘制指令,比如先画背景、再描绘边框……然后将这些指令按顺序组合成一个待绘制列表,相当于给后面的绘制操作做了一波计划。

生成图块和位图
现在开始绘制操作,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程

首先,考虑到视口就这么大,当页面非常大的时候,要滑很长时间才能滑到底,如果要一口气全部绘制出来是相当浪费性能的。因此,合成线程要做的第一件事情就是将图层分块。这些块的大小一般不会特别大,通常是 256 256 或者 512 512 这个规格。这样可以大大加速页面的首屏展示。

因为后面图块数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。

渲染进程中专门维护了一个栅格化线程池,专门负责把图块转换为位图数据。然后合成线程会选择视口附近的图块,把它交给栅格化线程池生成位图。生成位图的过程实际上都会使用 GPU 进行加速,生成的位图最后发送给合成线程。

显示器显示内容

栅格化操作完成后,合成线程会生成一个绘制命令,即”DrawQuad”,并发送给浏览器进程。浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡。

为什么发给显卡呢?我想有必要先聊一聊显示器显示图像的原理。
无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区和后缓冲区对换位置,如此循环更新。

当某个动画大量占用内存的时候,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。

重排、重绘

渲染树是动态构建的,如果DOM树或CSSOM树节点发生改动,那么就会造成渲染树节点的重新构建,从而导致重排、重绘。重排就是渲染引擎重新对渲染树节点进行样式计算,计算出元素在页面上最新的位置、大小信息(因为页面的渲染是基于文档流的一种流式布局,所以周围的DOM元素也会收到影响,进行重排)。而重绘就是重新绘制渲染树节点的外观样式(例如添加背景色、字体颜色、visibility)发生变化时为元素绘制新的样式,因为没有改动元素的位置、大小信息,所以重绘一般不会触发回流。

回流一定会触发重绘,重绘不一定会触发回流。

只要改变元素的位置、大小信息就会导致回流。例如添加、删除可见的元素;字体变化;页面一开始渲染的时候;浏览器的视窗变化;获取几何信息的函数操作。
offsetTop/offsetLeft,获取元素相对于offsetParent顶部、左侧内边距的距离。offsetWidth/offsetHeight,获取元素的布局宽度和高度,一般来说会包含border/padding/scrollbar/width/height。scrollTop,,当元素的内容大于可视窗口的时候,内容卷起来到视窗顶部的距。scrollLeftscrollWidthscrollHeightclientTop/clientLeft,获取元素顶部、左侧边框的宽度。`clientWidth/clientHeight,获取元素内部的宽高,包含width/height/padding。

字体色、背景色的修改会导致重绘。

减少回流和重绘。
使用transform代替top,可以减少回流。
使用visibility代替display: none,避免发生重绘。
避免使用获取尺寸位置信息的DOM API。因为为了得到实时的位置、尺寸信息,浏览器会马上进行样式计算,得到最新的位置、尺寸信息。
读写分离(待完善)。彻底搞懂浏览器渲染页面的机制和原理 - 知乎 (zhihu.com)
浏览器在这方面也做了优化。通过队列缓存修改并批量执行重排过程。当修改过了一段时间或者操作达到一个阈值,才清空队列。只有获取几何信息的特殊函数操作会强制清空队列。

为什么操作DOM慢?

因为DOM是渲染引擎的东西,js又是在js引擎中执行。用js操作DOM的时候实际上就是js引擎与渲染引擎在通信。操作DOM次数一多,就等于两个线程之间一直在通信。并且操作DOM还可能导致回流和重绘。

插入几万个DOM,如何实现页面不卡顿?

首先我们从浏览器渲染的过程可以知道,这样一次性大量操作DOM是很耗时的,主要的时间消耗在了样式计算和布局。关键在于分批次进行渲染。一种方法是使用requestAnimationFrame的方式循环插入DOM,另一种方法是使用虚拟列表。
虚拟列表的原理是只渲染可视窗口部分的内容,非可见的部分就不渲染,当用户在滚动的时候去替换渲染的内容。

JS异步加载

(蚂蚁体验技术部小程序生态基础技术团队)

异步加载是为了满足什么呢?加载一般指的是解释、执行。因为js引擎渲染线程互斥的原因,当js代码加载的时候会阻塞文档的解析。为了尽量避免阻塞文档的解析。

第一种方式是给script标签添加defer属性,脚本并行下载,脚本的执行会被延迟到整个页面都解析完成后。第二种方法是给script标签添加async属性,脚本也是并行下载,下载完毕后立即执行,如果此时文档还在解析,那么会阻塞文档的解析。第三种方法是动态添加script标签,利用DOM API新建一个script标签放在body标签后,并放在load事件后下载执行。
图解 script 标签中的 async 和 defer 属性 - 掘金 (juejin.cn)