第五章 补充

130 阅读12分钟

WebSocket

http协议下的客户端与服务器存在着许多限制,其中之一就是在客户端没有给服务器发送请求消息时,服务器不能主动发送响应消息给客户端,这对一些实时性要求高的页面就不是很友好

期间也诞生出了能够一定程度上处理该问题的方法,例如:轮询和长连接,但这写方法终究无法从根源上解决http所带来的问题

于是,随着HTML5的出现,诞生了一个新的协议 —— WebSocket,利用它就能从根源上处理http协议无法解决的问题

WebSocket是专门处理实时数据传输的,它和http一样需要建立在TCP协议之上(需要基于建立好的TCP连接进行数据传送),同时,WebSocket还需要利用到http,具体为:如果客户端需要利用WebSocket接收来自于服务器的实时数据,则当客户端与服务器建立完TCP连接后,需要客户端立即向服务器发送一个特殊的http请求消息(该请求消息没有消息体),该请求消息的作用是询问服务器是否支持WebSocket,如果服务器支持WebSocket,则需要向客户端发送特殊格式的http响应消息(如果不支持就不响应特殊格式的响应消息即可),这一过程称之为http握手

http握手成功后,客户端与服务器双方就能够基于建立好的持久的TCP连接实时地向对方发送数据(直至双方中的任意一方断开TCP连接),需要注意的事,发送的数据需要按照WebSocket协议的要求

http握手成功后,http客户端就成为了websocket客户端,http服务器就成为了websocket服务器

http握手

当客户端希望与服务器之间使用WebSocket进行通信时,客户端和服务器之间首先会使用http协议完成一次特殊的请求-响应,这一次请求-响应就是http握手

在握手阶段,首先由客户端向服务器发送一个请求,请求地址的协议部分固定为ws,地址中的域名和路径部分需要和服务器端协商确定

例如:

ws://my.site.com/path

该请求的请求头中会包含如下字段:

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: YWJzZmFkZmFzZmRhYw
  • Connection: Upgrade

    表示希望将通信时使用的协议进行升级,升级为Upgrade请求头中指定的协议

  • Upgrade: websocket

    希望将通信时使用的协议升级为websocket协议

  • Sec-WebSocket-Version

    希望使用的WebSocket协议的版本

  • Sec-WebSocket-Key

    该请求头用于和服务器之间“对暗号”,其值为一个随机字符串

    当服务器同意与客户端之间使用WebSocket协议进行通信时,会根据该随机字符串生成另一个字符串,并作为响应消息的Sec-WebSocket-Accept响应头的内容

    客户端收到响应消息后根据该响应头就可以判断服务器是否同意本次的合作

服务器如果同意,就会响应下面的消息:

HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: ZzIzMzQ1Z2V3NDUyMzIzNGVy

  • 状态码固定为101,状态消息为Switching Protocols
  • Sec-WebSocket-Accept是根据请求头中的Sec-WebSocket-Key生成的

握手完成后,后续消息的收发就不再使用http协议,而是使用ws协议,在这期间双方中的任意一方都可以主动发消息给对方

握手完成后,TCP连接会自动变为持久性的连接,直至通信双方中的一方主动断开TCP连接

代码实现

客户端,以浏览器为例:

// 传入服务器的地址,表示与服务器进行TCP连接,TCP连接完成后立即与服务器进行http握手
const ws = new WebSocket("ws://localhost:9527");

ws.onopen = ()=>{							// http握手成功后触发
    console.log("http握手完成");
    ws.send(...);							// 发送数据给服务器,数据的格式有要求
};

ws.onmessage = (event)=>{					// 接收到来自服务器的数据后触发
    console.log("收到了来自服务器的数据", event.data);
}

ws.onclose = ()=>{							// 任何一方断开TCP连接时触发
    console.log("TCP连接已断开");
}

ws.close();									// 客户端主动断开TCP连接

服务器:

const net = require("net");
const crypto = require("crypto");

const server = new.createServer((socket)=>{
    console.log("与客户端建立TCP连接成功");
    
    // 处理客户端发来的特殊的http请求报文
    socket.once("data", (chunk)=>{				// http握手只会进行一次,因此使用once即可
        console.log("接收到了来自客户端发来的特殊的http请求报文", chunk.toString("utf-8"));

        const hash = crypto.createHash("sha1");
        hash.update(headers["Sec-WebSocket-Key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
        const key = hash.digest("base64");
        
        // 发送特殊格式的http响应报文
        socket.write(`HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: ${key}

`);
        
        // http握手成功后,就可以接收到来自客户端发来的实时数据了
        // 响应消息的状态码固定为101,状态消息为“切换协议”
        socket.on("data", (chunk)=>{
            console.log("接收到了来自客户端发来的实时数据", chunk);
        });
        
        // 向客户端发送数据
	    socket.write(...);
    });
});

server.listen(9527);

由于websocket要求发送的数据格式比较复杂,因此通常使用第三方库来处理websocket的通信

socket.io

socket.io是专门处理WebSocket通信的第三方库

它支持在客户端使用,也支持在nodejs端使用

在nodejs端可以搭配原生node结束使用,也可以用它搭配express进行使用

客户端,以浏览器端为例:

<script src="./socket.io.js"></script>
<script>
// socket.io会暴露一个全局变量io
const socket = io.connect("http://localhost:9527");

// 向服务器发送消息
socket.on("test", "Hello, I am client");
    
// 接收来自服务器的消息
socket.emit("msg", (data)=>{
	console.log(data);
});

// TCP连接断开时触发
socket.on("disconnect", ()=>{
	console.log("TCP连接已断开");
});
</script>

服务器:

const http = require("http");
const express = require("express");
const socketIO = require("socket.io");

const app = express();

// 正常处理http请求
app.use((req, res, next)=>{
    res.send("hello");
});

const server = http.createServer(app);

// 利用server对象创建io对象,并通过io对象处理http握手,之后server对象就既能处理http请求,也能处理websocket客户端发来的消息,两者互不干扰
const io = socketIO(server);

// 每当有新的客户端与服务器进行http握手成功之后,就会触发该回调
io.on("connection", (socket)=>{
    console.log("有新的socket客户端加入");
    
    const timer = setInterval(()=>{
        // 向客户端发送消息(事件名称与客户端协商定义)
        socket.emit("test", "Hello, I am server");
    }, 3000);
    
    // 接收来自客户端的消息(事件名称与客户端协商定义)
    socket.on("msg", (data)=>{
		console.log(data);
    });
        
    // TCP连接断开时触发
    socket.on("disconnect", ()=>{
        console.log("TCP连接已断开");
        clearInterval(timer);
    });
    
    // 向除自己之外的其他所有socket的客户端发送消息
    socket.broadcast.emit("broadcast", "broadcast message from server");
});

server.listen(9527, "localhost");

server.on("listening", ()=>{
    console.log("监听端口9527成功");
});

CSRF攻击和防御

CSRF(Cross Site Request Forgery,跨站请求伪造)是指恶意网站以正常用户作为媒介,通过模拟正常用户的操作,攻击其登录过的站点

image.png

CSRF的具体攻击过程如下:

用户正常访问网站,并登录成功,于是得到了服务器颁发的JWT令牌,其保存在cookie之中

image.png

之后,用户访问到恶意网站,恶意网站通过某种方式模拟用户请求其之前登录成功的正常站点(请求伪造),于是在用户非自愿的情况下将cookie发送了过去,完成了对正常站点的攻击

image.png

防御方式

cookie的SameSite

大部分现代浏览器能够支持对跨站附带cookie的行为进行限制,只需要为要限制的cookie添加上SameSite属性即可

跨站是指页面的主机号与请求的服务器的主机号不一致

SameSite有以下取值:

  • Strict:严格,所有跨站请求都不允许附带cookie,可能会导致用户体验不好
  • Lax:宽松,所有跨站的超链接、GET请求的表单、预加载的链接允许附带cookie,其他情况不允许发送
  • None:无限制

这种防御方法简单且有效,但只支持在能够识别SameSite属性的浏览器中使用,因此用户不能使用太旧的浏览器

验证Referer和Origin

浏览器会为在页面中的发送请求附带上Referer请求头,对页面中使用ajax发出的跨域请求会附带上Origin请求头,用于向服务器表示该请求来自于哪个页面或源,服务器可以通过这些请求头进行验证

referer请求头的值是发出请求的页面的除hash以外的url

origin请求头的值是发出请求的页面的源

使用非cookie令牌

要求每次请求在请求体中附带令牌,或者直接让令牌作为一个请求头发送过去,而不是作为cookie请求头的一个属性

验证码

对每个可能遭受CSRF攻击的请求都要求必须要附带验证码

表单随机数

这种做法适用于服务端渲染的页面

服务器在页面中加入一个随机数(随机数是一次性的),客户端提交时需要提交这个随机数,服务器端收到后进行对比

流程:

  1. 客户端请求服务器,获取某个登录成功后才能访问的页面,请求时正常附带cookie

  2. 服务器会生成一个随机数,生成好的随机数会保存到session中,还会保存到生成的页面中

    在页面之中会包含一个隐藏的表单域,该表单域的value值就是该随机数,之后将页面响应过去

  3. 客户端在该页面中提交表单时,会将隐藏表单域中的随机数一并提交过去

  4. 服务器收到客户端的提交信息后,首先验证cookie以判断用户是否登录过,然后再对比提交过来的随机数和session中的随机数是否一致,最后将session中记录的随机数清除(下次就不能再使用这个随机数了)

二次验证

当做出敏感操作时,进行二次验证

XSS攻击和防御

XSS(Cross Site Scripting,跨站脚本攻击)攻击的方式主要有两种,分别是存储型、反射型

存储型

恶意用户提交了恶意内容到服务器,服务器收到后没有识别出来恶意内容,并将恶意内容保存到了数据库

之后正常用户请求服务器,服务器将保存下来的恶意内容给予了用户,让正常用户遭到攻击

反射型

恶意用户分享了一个正常网站的链接,链接中包含有恶意内容

之后正常用户点击了该链接,于是服务器在不知情的情况,把链接的恶意内容读取了出来,并放进了页面中,让正常用户遭到攻击

总之,XSS的两种攻击方式都是利用了网站的漏洞,向服务器提交能够执行的脚本代码来造成的攻击

防御XSS的方式也很简单,就是对用户提交的内容进行检查、过滤(或进行实体编码),将可能会产生危险行为的部分筛选出来即可

进程和线程

进程

任何一个程序在运行时都会至少对应着一个进程,该进程是由操作系统开启的,称之为主进程

进程除了可以由操作系统创建外,也可以由其它进程创建,例如:若进程A创建出进程B,则称B是A的子进程,A是B的父进程

通过内置模块child_process可以创建出一个新的进程

导入该内置模块后可以得到一个childProcess对象,通过childProcess的exec方法,就可以创建出一个子进程,同时可以让子进程中执行一条命令(该命令就是在集成终端中输入的命令)

exec的第一个参数就是子进程所要运行的命令,第二个参数是一个回调函数,回调函数能够接收三个参数

第一个参数err用于接收创建子进程过程中出现的错误,当创建子进程失败后,会调用该回调函数并传入错误给第一个参数err

第二个参数stdOut是子进程执行命令完成后得到的正常输出结果,第三个参数stdErr是子进程执行命令完成后得到的异常输出结果

第二个参数和第三个参数只有在创建子进程成功后才有效

const childProcess = require("child_process");

childProcess.exec("要运行的命令", (err, stdOut, stdErr) => {
    console.log(err);
    console.log(stdOut);
    console.log(stdErr);
});

nodejs还提供了cluster内置模块,它提供了另一种模式来创建进程

线程

每一个进程都至少会有一个线程,该线程是伴随着进程的创建一起被创建的,称之为主线程

线程除了可以由操作系统创建外,也可以由其它线程创建,若线程A创建出线程B,则称B是A的子线程,A是B的父线程

通过内置模块worker_threads可以创建出一个新的线程

导入该模块后可以得到一个Worker构造函数,该构造函数创建出来的实例就代表着一个线程

创建线程实例时,需要传入线程需要执行的入口文件的路径,并可以通过第二个参数为线程传递一些初始的数据

const { Worker } = require("worker_threads");
const worker = new Worker("worker.js", {			// 创建子线程实例
	workerData: data,								// 传递给子线程的初始数据
});

// 当子线程退出时会触发该事件
worker.on("exit", () => {
	console.log("子线程退出了");
});

// 收到子线程发送过来的数据时会触发该事件
worker.on("message", (msg) => {
	console.log("收到来自于子线程的数据", msg);
});

worker.postMessage(msg); 							// 向子线程发送数据

worker.terminate();									// 退出(杀死)子线程
// worker.js
const {
    isMainThread,									// 是否是主线程
    parentPort,										// 与父线程进行通信的端口
    workerData,										// 父线程传递过来的初始数据
    threadId										// 本线程的线程id
} = require("worker_threads");

// 收到父线程发送过来的数据时会触发该事件
parentPort.on("message", (msg)=>{
    console.log("收到来自于父线程的数据", msg);
});

parentPort.postMessage(msg);						// 向父线程发送数据