# Web 安全知识总结

# 什么是 XSS 攻击?如何防范 XSS 攻击?

XSS 攻击指的是跨站脚本攻击,是一种代码注入攻击。攻击者通过在网站注入恶意脚本,使之在用户的浏览器上运行,从而盗取用户的信息如 cookie 等。

XSS 的本质是因为网站没有对恶意代码进行过滤,与正常的代码混合在一起了,浏览器没有办法分辨哪些脚本是可信的,从而导致了恶意代码的执行。

XSS 一般分为存储型、反射型和 DOM 型。

存储型指的是恶意代码提交到了网站的数据库中,当用户请求数据的时候,服务器将其拼接为 HTML 后返回给了用户,从而导致了恶意代码的执行。

反射型指的是攻击者构建了特殊的 URL,当服务器接收到请求后,从 URL 中获取数据,拼接到 HTML 后返回,从而导致了恶意代码的执行。

DOM 型指的是攻击者构建了特殊的 URL,用户打开网站后,js 脚本从 URL 中获取数据,从而导致了恶意代码的执行。

XSS 攻击的预防可以从两个方面入手,一个是恶意代码提交的时候,一个是浏览器执行恶意代码的时候。

对于第一个方面,如果我们对存入数据库的数据都进行的转义处理,但是一个数据可能在多个地方使用,有的地方可能不需要转义,由于我们没有办法判断数据最后的使用场景,所以直接在输入端进行恶意代码的处理,其实是不太可靠的。

因此我们可以从浏览器的执行来进行预防,一种是使用纯前端的方式,不用服务器端拼接后返回。另一种是对需要插入到 HTML 中的代码做好充分的转义。对于 DOM 型的攻击,主要是前端脚本的不可靠而造成的,我们对于数据获取渲染和字符串拼接的时候应该对可能出现的恶意代码情况进行判断。

还有一些方式,比如使用 CSP ,CSP 的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行,从而防止恶意代码的注入攻击。

还可以对一些敏感信息进行保护,比如 cookie 使用 http-only ,使得脚本无法获取。也可以使用验证码,避免脚本伪装成用户执行一些操作。

详细资料可以参考: 《前端安全系列(一):如何防止 XSS 攻击?》

# 什么是 CSP?

CSP 指的是内容安全策略,它的本质是建立一个白名单,告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截由浏览器自己来实现。

通常有两种方式来开启 CSP,一种是设置 HTTP 首部中的 Content-Security-Policy,一种是设置 meta 标签的方式

详细资料可以参考: 《内容安全策略(CSP)》 《前端面试之道》

# 什么是 CSRF 攻击?如何防范 CSRF 攻击?

CSRF 攻击指的是跨站请求伪造攻击,攻击者诱导用户进入一个第三方网站,然后该网站向被攻击网站发送跨站请求。如果用户在被
攻击网站中保存了登录状态,那么攻击者就可以利用这个登录状态,绕过后台的用户验证,冒充用户向服务器执行一些操作。

CSRF 攻击的本质是利用了 cookie 会在同源请求中携带发送给服务器的特点,以此来实现用户的冒充。

一般的 CSRF 攻击类型有三种:

第一种是 GET 类型的 CSRF 攻击,比如在网站中的一个 img 标签里构建一个请求,当用户打开这个网站的时候就会自动发起提
交。

第二种是 POST 类型的 CSRF 攻击,比如说构建一个表单,然后隐藏它,当用户进入页面时,自动提交这个表单。

第三种是链接类型的 CSRF 攻击,比如说在 a 标签的 href 属性里构建一个请求,然后诱导用户去点击。

CSRF 可以用下面几种方法来防护:

第一种是同源检测的方法,服务器根据 http 请求头中 origin 或者 referer 信息来判断请求是否为允许访问的站点,从而对请求进行过滤。当 origin 或者 referer 信息都不存在的时候,直接阻止。这种方式的缺点是有些情况下 referer 可以被伪造。还有就是我们这种方法同时把搜索引擎的链接也给屏蔽了,所以一般网站会允许搜索引擎的页面请求,但是相应的页面请求这种请求方式也可能被攻击者给利用。

第二种方法是使用 CSRF Token 来进行验证,服务器向用户返回一个随机数 Token ,当网站再次发起请求时,在请求参数中加入服务器端返回的 token ,然后服务器对这个 token 进行验证。这种方法解决了使用 cookie 单一验证方式时,可能会被冒用的问题,但是这种方法存在一个缺点就是,我们需要给网站中的所有请求都添加上这个 token,操作比较繁琐。还有一个问题是一般不会只有一台网站服务器,如果我们的请求经过负载平衡转移到了其他的服务器,但是这个服务器的 session 中没有保留这个 token 的话,就没有办法验证了。这种情况我们可以通过改变 token 的构建方式来解决。

第三种方式使用双重 Cookie 验证的办法,服务器在用户访问网站页面时,向请求域名注入一个 Cookie,内容为随机字符串,然后当用户再次向服务器发送请求的时候,从 cookie 中取出这个字符串,添加到 URL 参数中,然后服务器通过对 cookie 中的数据和参数中的数据进行比较,来进行验证。使用这种方式是利用了攻击者只能利用 cookie,但是不能访问获取 cookie 的特点。并且这种方法比 CSRF Token 的方法更加方便,并且不涉及到分布式访问的问题。这种方法的缺点是如果网站存在 XSS 漏洞的,那么这种方式会失效。同时这种方式不能做到子域名的隔离。

第四种方式是使用在设置 cookie 属性的时候设置 Samesite ,限制 cookie 不能作为被第三方使用,从而可以避免被攻击者利用。Samesite 一共有两种模式,一种是严格模式,在严格模式下 cookie 在任何情况下都不可能作为第三方 Cookie 使用,在宽松模式下,cookie 可以被请求是 GET 请求,且会发生页面跳转的请求所使用。

详细资料可以参考: 《前端安全系列之二:如何防止 CSRF 攻击?》 《[ HTTP 趣谈] origin, referer 和 host 区别》

Samesite Cookie 表示同站 cookie,避免 cookie 被第三方所利用。

将 Samesite 设为 strict ,这种称为严格模式,表示这个 cookie 在任何情况下都不可能作为第三方 cookie。

将 Samesite 设为 Lax ,这种模式称为宽松模式,如果这个请求是个 GET 请求,并且这个请求改变了当前页面或者打开了新的页面,那么这个 cookie 可以作为第三方 cookie,其余情况下都不能作为第三方 cookie。

使用这种方法的缺点是,因为它不支持子域,所以子域没有办法与主域共享登录信息,每次转入子域的网站,都回重新登录。还有一个问题就是它的兼容性不够好。
什么是点击劫持?如何防范点击劫持?

点击劫持是一种视觉欺骗的攻击手段,攻击者将需要攻击的网站通过 iframe 嵌套的方式嵌入自己的网页中,并将 iframe 设置为透明,在页面中透出一个按钮诱导用户点击。

我们可以在 http 相应头中设置 X-FRAME-OPTIONS 来防御用 iframe 嵌套的点击劫持攻击。通过不同的值,可以规定页面在特定的一些情况才能作为 iframe 来使用。 详细资料可以参考: 《web 安全之--点击劫持攻击与防御技术简介》

# SQL 注入攻击?

SQL 注入攻击指的是攻击者在 HTTP 请求中注入恶意的 SQL 代码,服务器使用参数构建数据库 SQL 命令时,恶意 SQL 被一起构造,破坏原有 SQL 结构,并在数据库中执行,达到编写程序时意料之外结果的攻击行为。

详细资料可以参考: 《Web 安全漏洞之 SQL 注入》 《如何防范常见的 Web 攻击》

# 文件上传原理

原理: 完成消息体封装和消息体的额解析,然后将二进制内容保存到文件。

人话: 把 form 标签的 enctype 设置为 multipart/form-data,同时 method 必须为 post 方法

# 单文件上传和上传进度

使用form表单上传文件,原来都是用form表单,但是现在一般使用 input + xhr 来实现比较好

HTML

 <form method="post" action="http://localhost:8100" enctype="multipart/form-data">

        选择文件:
            <input type="file" name="f1"/> input 必须设置 name 属性,否则数据无法发送<br/>
<br/>
            标题:<input type="text" name="title"/><br/><br/><br/>

        <button type="submit" id="btn-0">上 传</button>

</form>

# 多文件上传和上传进度

html5 只需要一个标签加个属性就搞定了,file 标签开启 multiple

HTML

<input type="file" name="f1" multiple/> 

NODE

//二次处理文件,修改名称
app.use((ctx) => {
    
    var files = ctx.request.files.f1;// 多文件, 得到上传文件的数组
    var result=[];

    //遍历处理
    files && files.forEach(item=>{
        var path = item.path;
        var fname = item.name;//原文件名称
        var nextPath = path + fname;
        if (item.size > 0 && path) {
            //得到扩展名
            var extArr = fname.split('.');
            var ext = extArr[extArr.length - 1];
            var nextPath = path + '.' + ext;
            //重命名文件
            fs.renameSync(path, nextPath);
            
            //文件可访问路径放入数组
            result.push(uploadHost+ nextPath.slice(nextPath.lastIndexOf('/') + 1));
        }
    });
    
    //输出 json 结果
    ctx.body = `{
        "fileUrl":${JSON.stringify(result)}
    }`;
})

局部刷新的做法:

页面内放一个隐藏的 iframe,或者使用 js 动态创建,指定 form 表单的 target 属性值为 iframe 标签 的 name 属性值,这样 form 表单的 submit 行为的跳转就会在 iframe 内完成,整体页面不会刷新。

然后为 iframe 添加 load 事件,得到 iframe 的页面内容,将结果转换为 JSON 对象,这样就拿到了接口的数据

   <iframe id="temp-iframe" name="temp-iframe" src="" style="display:none;"></iframe>
        <form method="post" target="temp-iframe" action="http://localhost:8100" enctype="multipart/form-data">
        选择文件(可多选):
            <input type="file" name="f1" id="f1" multiple/><br/> input 必须设置 name 属性,否则数据无法发送<br/>
<br/>
            标题:<input type="text" name="title"/><br/><br/><br/>

        <button type="submit" id="btn-0">上 传</button>

        </form>
        
        
<script>

var iframe = document.getElementById('temp-iframe');
iframe.addEventListener('load',function () {
      var result = iframe.contentWindow.document.body.innerText;
      //接口数据转换为 JSON 对象
      var obj = JSON.parse(result);
      if(obj && obj.fileUrl.length){
          alert('上传成功');
          
      }
      console.log(obj);
});


</script>

无刷新上传,借助XHR

 <div>
        选择文件(可多选):
        <input type="file" id="f1" multiple/><br/><br/>
        <button type="button" id="btn-submit">上 传</button>
</div>

   function submitUpload() {
        //获得文件列表,注意这里不是数组,而是对象
        var fileList = document.getElementById('f1').files;
        if(!fileList.length){
            alert('请选择文件');
            return;
        }
        var fd = new FormData();   //构造FormData对象
        fd.append('title', document.getElementById('title').value);

        //多文件上传需要遍历添加到 fromdata 对象
        for(var i =0;i<fileList.length;i++){
            fd.append('f1', fileList[i]);//支持多文件上传
        }

        var xhr = new XMLHttpRequest();   //创建对象
        xhr.open('POST', 'http://localhost:8100/', true);

        xhr.send(fd);//发送时  Content-Type默认就是: multipart/form-data; 
        xhr.onreadystatechange = function () {
            console.log('state change', xhr.readyState);
            if (this.readyState == 4 && this.status == 200) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                if(obj.fileUrl.length){
                    alert('上传成功');
                }
            }
        }

    }

    //绑定提交事件
    document.getElementById('btn-submit').addEventListener('click',submitUpload);

监控上传进度

  1. 添加显示进度的标签 div.progress
  2. 编写处理进度的监听函数,xhr.onprogress 还有 xhr.upload.onprogress
  3. 通过监控 event.lengthComputable 是否变化,event.loaded 表示发送了多少字节,event.total 表示文件总大小计算出进度,然后来控制样式。
        xhr.onprogress=updateProgress;
        xhr.upload.onprogress = updateProgress;
        function updateProgress(event) {
            console.log(event);
            if (event.lengthComputable) {
                var completedPercent = (event.loaded / event.total * 100).toFixed(2);
                progressSpan.style.width= completedPercent+'%';
                progressSpan.innerHTML=completedPercent+'%';
                if(completedPercent>90){//进度条变色
                    progressSpan.classList.add('green');
                }
                console.log('已上传',completedPercent);
            }
        }

PS: xhr.upload.onprogress 要写在 xhr.send 方法前面

# 拖拽上传

  1. 首先定义一个允许拖放文件的区域
  2. drop 事件一定要阻止事件的默认行为 e.preventDefault() ,不然浏览器会直接打开文件
  3. 为拖拽区域绑定事件,鼠标在拖拽区域上 dragover,鼠标离开拖拽区域 dragleave ,在拖拽区域上释放文件 drop
  4. drop 事件内获取文件信息 e.dataTransfer.files
  5. 组装formData,xhr发送ajax
 <div class="drop-box" id="drop-box">
       拖动文件到这里,开始上传
    </div>
    
    <button type="button" id="btn-submit">上 传</button>
    <script>
    var box = document.getElementById('drop-box');
    
    //禁用浏览器的拖放默认行为
    document.addEventListener('drop',function (e) {
        console.log('document drog');
        e.preventDefault();
    });
   
    //设置拖拽事件
    function openDropEvent() {
        box.addEventListener("dragover",function (e) {
            console.log('elemenet dragover');
             box.classList.add('over');
               e.preventDefault();
        });
         box.addEventListener("dragleave", function (e) {
              console.log('elemenet dragleave');
            box.classList.remove('over');
              e.preventDefault();
        });

        box.addEventListener("drop", function (e) {
            e.preventDefault(); //取消浏览器默认拖拽效果

            var fileList = e.dataTransfer.files; //获取拖拽中的文件对象
            var len=fileList.length;//用来获取文件的长度(其实是获得文件数量)
            
            //检测是否是拖拽文件到页面的操作
            if (!len) {
                box.classList.remove('over');
                return;
            }

            box.classList.add('over');

            window.willUploadFileList=fileList;

        }, false);
    }

    openDropEvent();

    function submitUpload() {

        var fileList = window.willUploadFileList||[];
        if(!fileList.length){
            alert('请选择文件');
            return;
        }

        var fd = new FormData();   //构造FormData对象

        for(var i =0;i<fileList.length;i++){
            fd.append('f1', fileList[i]);//支持多文件上传
        }

        var xhr = new XMLHttpRequest();   //创建对象
        xhr.open('POST', 'http://localhost:8100/', true);
        xhr.onreadystatechange = function () {
            if (xhr.readyState == 4) {
                var obj = JSON.parse(xhr.responseText);   //返回值
                if(obj.fileUrl.length){
                    alert('上传成功');
                }
            }
        }
        xhr.send(fd);//发送
    }
    //绑定提交事件
    document.getElementById('btn-submit').addEventListener('click',submitUpload);

</script>

# 大文件分片上传

file 继承了 blob ,表示原始数据和二进制数据,提供 slice 方法进行截取

大概是一个这样的过程

  1. 把大文件进行分段 比如2M一片,发送到服务器携带一个标志,可以暂时用当前的时间戳,用于标识一个完整的文件
  2. 服务端保存各段文件
  3. 浏览器端所有分片上传完成,发送给服务端一个合并文件的请求
  4. 服务端根据文件标识、类型、各分片顺序进行文件合并
  5. 删除分片文件

分片如何做到,其实就像是操作字符串一样

    var start=0,end=0;
    while (true) {
        end+=chunkSize;
        var blob = file.slice(start,end);
        start+=chunkSize;
        
        if(!blob.size){//截取的数据为空 则结束
            //拆分结束
            break;
        }
        
        chunks.push(blob);//保存分段数据
    }

前端过程,尽量会手写最好

    function submitUpload() {
        var chunkSize=2*1024*1024;//分片大小 2M
        var file = document.getElementById('f1').files[0];
        var chunks=[], //保存分片数据
         token = (+ new Date()),//时间戳
         name =file.name,chunkCount=0,sendChunkCount=0;

        //拆分文件 像操作字符串一样
        if(file.size>chunkSize){
            //拆分文件
            var start=0,end=0;
            while (true) {
                end+=chunkSize;
                var blob = file.slice(start,end);
                start+=chunkSize;
                
                if(!blob.size){//截取的数据为空 则结束
                    //拆分结束
                    break;
                }
                
                chunks.push(blob);//保存分段数据
            }
        }else{
            chunks.push(file.slice(0));
        }

        chunkCount=chunks.length;//分片的个数 
        
        //没有做并发限制,较大文件导致并发过多,tcp 链接被占光 ,需要做下并发控制,比如只有4个在请求在发送
        
        for(var i=0;i< chunkCount;i++){
            var fd = new FormData();   //构造FormData对象
            fd.append('token', token);
            fd.append('f1', chunks[i]);
            fd.append('index', i);
            xhrSend(fd,function () {
                sendChunkCount+=1;
                if(sendChunkCount===chunkCount){//上传完成,发送合并请求
                    console.log('上传完成,发送合并请求');
                    var formD = new FormData();
                    formD.append('type','merge');
                    formD.append('token',token);
                    formD.append('chunkCount',chunkCount);
                    formD.append('filename',name);
                    xhrSend(formD);
                }
            });
        }
    }

    function xhrSend(fd,cb) {
        
        var xhr = new XMLHttpRequest();   //创建对象
        xhr.open('POST', 'http://localhost:8100/', true);
        xhr.onreadystatechange = function () {
            console.log('state change', xhr.readyState);
            if (xhr.readyState == 4) {
                console.log(xhr.responseText);
                cb && cb();
            }
        }
        xhr.send(fd);//发送
    }

  //绑定提交事件
  document.getElementById('btn-submit').addEventListener('click',submitUpload);

# 大文件断点上传

大致过程:

在分片上传前提下

  1. 为每个分段生成 hash 值,使用 spark-md5 库为每个文件分片打一个哈希
  2. 将上传成功的分段信息保存到本地,(为了简单,其实要用hash,比如我这里记录文件索引的形式记录已上传还是未上传)
  3. 重新上传时,都会拿当前上传的分段和本地分段 hash 值的对比,如果相同的话则跳过,继续下一个分段的上传

    //获得本地缓存的数据
    function getUploadedFromStorage(){
        return JSON.parse( localStorage.getItem(saveChunkKey) || "{}");
    }

    //写入缓存
    function setUploadedToStorage(index) {
        var obj =  getUploadedFromStorage();
        obj[index]=true;      
        localStorage.setItem(saveChunkKey, JSON.stringify(obj) );
    }
    
    //分段对比
    
    var uploadedInfo = getUploadedFromStorage();//获得已上传的分段信息

    for(var i=0;i< chunkCount;i++){
            console.log('index',i, uploadedInfo[i]?'已上传过':'未上传');
            
            if(uploadedInfo[i]){//对比分段
                sendChunkCount=i+1;//记录已上传的索引
                continue;//如果已上传则跳过
            }
            var fd = new FormData();   //构造FormData对象
            fd.append('token', token);
            fd.append('f1', chunks[i]);
            fd.append('index', i);
           
           (function (index) {
                    xhrSend(fd, function () {
                    sendChunkCount += 1;
                    //将成功信息保存到本地
                    setUploadedToStorage(index);
                    if (sendChunkCount === chunkCount) {
                        console.log('上传完成,发送合并请求');
                        var formD = new FormData();
                        formD.append('type', 'merge');
                        formD.append('token', token);
                        formD.append('chunkCount', chunkCount);
                        formD.append('filename', name);
                        xhrSend(formD);
                    }
                });
            })(i);
    }


Last update: 3/23/2021, 9:57:53 AM