From 1f26327e3dcb9169926793b5d97e00d23d2ecaa4 Mon Sep 17 00:00:00 2001 From: Heorhii Date: Mon, 6 Nov 2023 21:27:55 +0100 Subject: [PATCH 1/7] Implement class Frame --- lib/websocket.js | 151 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 111 insertions(+), 40 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 6e5c2e9a..c9a5a8a8 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -17,29 +17,117 @@ const OPCODE_SHORT = 0x81; const LEN_16_BIT = 126; const MAX_16_BIT = 65536; const LEN_64_BIT = 127; +const MAX_64_BIT = 0x7fffffffffffffffn + 1n; -const calcOffset = (frame, length) => { - if (length < LEN_16_BIT) return [2, 6]; - if (length === LEN_16_BIT) return [4, 8]; - return [10, 14]; +const DEFAULT_OPTIONS = { + server: true, }; -const parseFrame = (frame) => { - const length = frame[1] ^ 0x80; - const [maskOffset, dataOffset] = calcOffset(frame, length); - const mask = frame.subarray(maskOffset, maskOffset + MASK_LENGTH); - const data = frame.subarray(dataOffset); - return { mask, data }; -}; +class Frame { + #frame = null; + #mask = null; + #dataOffset = 6; + #length = 0n; + #server = false; + + constructor(data, options) { + if (!Buffer.isBuffer(data) && typeof data !== 'string') { + throw new Error('Unsupported'); + } + const opt = { + ...DEFAULT_OPTIONS, + ...options, + }; + this.#server = opt.server; -const unmask = (buffer, mask) => { - const data = Buffer.allocUnsafe(buffer.length); - buffer.copy(data); - for (let i = 0; i < data.length; i++) { - data[i] ^= mask[i & 3]; + if (Buffer.isBuffer(data) && data.length !== 0) { + this.#frame = data; + this.#decode(); + return; + } + + if (typeof data === 'string' && data.length !== 0) { + this.#encode(data); + return; + } } - return data; -}; + + #hasMask() { + if ((this.#frame[1] & 0x80) === 0x80) return true; + return false; + } + + #decode() { + if (this.#server && !this.#hasMask()) + throw new Error('1002 (protocol error)'); + const maskLength = this.#hasMask() ? MASK_LENGTH : 0; + this.#length = BigInt(this.#frame[1] & 0x7f); + switch (this.#length) { + case 127n: + this.#dataOffset = 2 + 8 + maskLength; + this.#mask = this.#frame.subarray(2 + 8, 10 + MASK_LENGTH); + this.#length = this.#frame.readBigUInt64BE(2); + break; + case 126n: + this.#dataOffset = 2 + 2 + maskLength; + this.#mask = this.#frame.subarray(2 + 2, 4 + MASK_LENGTH); + this.#length = BigInt(this.#frame.readUInt16BE(2)); + break; + default: + this.#dataOffset = 2 + maskLength; + this.#mask = this.#frame.subarray(2, 2 + MASK_LENGTH); + } + + for (let i = 0n; i < this.#length; ++i) { + this.#frame[BigInt(this.#dataOffset) + i] ^= + this.#mask[i & 0x0000000000000003n]; + } + } + + #encode(text) { + const data = Buffer.from(text); + this.#frame = Buffer.alloc(2); + this.#frame[0] = 0x81; // FIN = 1, RSV = 0b000, opcode = 0b0001 (text frame) + const length = data.length; + if (length < LEN_16_BIT) { + this.#frame[1] = length; + } else if (length < MAX_16_BIT) { + const len = Buffer.alloc(2); + len.writeUint16BE(length, 0); + this.#frame[1] = LEN_16_BIT; + this.#frame = Buffer.concat([this.#frame, len]); + } else if (length < MAX_64_BIT) { + const len = Buffer.alloc(8); + len.writeBigUInt64BE(BigInt(length), 0); + this.#frame[1] = LEN_64_BIT; + this.#frame = Buffer.concat([this.#frame, len]); + } else { + throw new Error('text value is too long to encode in one frame!'); + } + if (!this.#server) throw new Error('Unsupported'); + this.#frame = Buffer.concat([this.#frame, data]); + } + + toString() { + return this.#frame.toString( + 'utf8', + this.#dataOffset, + Number(BigInt(this.#dataOffset) + this.#length), + ); + } + + get data() { + return this.#frame.subarray(this.#dataOffset); + } + + get frame() { + return this.#frame; + } + + get mask() { + return this.#mask; + } +} class Connection { constructor(socket) { @@ -56,34 +144,17 @@ class Connection { } send(text) { - const data = Buffer.from(text); - let meta = Buffer.alloc(2); - const length = data.length; - meta[0] = OPCODE_SHORT; - if (length < LEN_16_BIT) { - meta[1] = length; - } else if (length < MAX_16_BIT) { - const len = Buffer.from([(length & 0xff00) >> 8, length & 0x00ff]); - meta = Buffer.concat([meta, len]); - meta[1] = LEN_16_BIT; - } else { - const len = Buffer.alloc(8); - len.writeBigInt64BE(BigInt(length), 0); - meta = Buffer.concat([meta, len]); - meta[1] = LEN_64_BIT; - } - const frame = Buffer.concat([meta, data]); - this.socket.write(frame); + const frame = new Frame(text); + this.socket.write(frame.frame); } receive(data) { console.log('data: ', data[0], data.length); if (data[0] !== OPCODE_SHORT) return; - const frame = parseFrame(data); - const msg = unmask(frame.data, frame.mask); - const text = msg.toString(); - this.send(`Echo "${text}"`); + const frame = new Frame(data); + const text = frame.toString(); console.log('Message:', text); + this.send(`Echo "${text}"`); } accept(key) { From ede8de9860d437882bd73c6a1ac8617b71ad8e6d Mon Sep 17 00:00:00 2001 From: Heorhii Date: Tue, 7 Nov 2023 10:56:27 +0100 Subject: [PATCH 2/7] fix decode --- lib/websocket.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/websocket.js b/lib/websocket.js index c9a5a8a8..09f6884b 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -23,6 +23,8 @@ const DEFAULT_OPTIONS = { server: true, }; +const xor = (cond1, cond2) => (cond1 || cond2) && !(cond1 && cond2); + class Frame { #frame = null; #mask = null; @@ -58,7 +60,7 @@ class Frame { } #decode() { - if (this.#server && !this.#hasMask()) + if (xor(this.#server, this.#hasMask())) throw new Error('1002 (protocol error)'); const maskLength = this.#hasMask() ? MASK_LENGTH : 0; this.#length = BigInt(this.#frame[1] & 0x7f); @@ -78,6 +80,7 @@ class Frame { this.#mask = this.#frame.subarray(2, 2 + MASK_LENGTH); } + if (!this.#server) return; for (let i = 0n; i < this.#length; ++i) { this.#frame[BigInt(this.#dataOffset) + i] ^= this.#mask[i & 0x0000000000000003n]; From 7df26af3e3750d915496365e511828fcac365497 Mon Sep 17 00:00:00 2001 From: Heorhii Date: Wed, 8 Nov 2023 15:20:38 +0100 Subject: [PATCH 3/7] Rewrite class Frame --- lib/websocket.js | 90 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 09f6884b..80b4bfac 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -26,6 +26,91 @@ const DEFAULT_OPTIONS = { const xor = (cond1, cond2) => (cond1 || cond2) && !(cond1 && cond2); class Frame { + #frame = null; + #masked = null; + #dataOffset = null; + #mask = null; + #length = null; + + constructor(frame) { + this.#frame = frame; + const length = BigInt(this.#frame[1] & 0x7f); + this.#masked = true; + switch (length) { + case 127n: + this.#dataOffset = 2 + 8 + MASK_LENGTH; + this.#mask = frame.subarray(2 + 8, 10 + MASK_LENGTH); + this.#length = frame.readBigUInt64BE(2); + break; + case 126n: + this.#dataOffset = 2 + 2 + MASK_LENGTH; + this.#mask = frame.subarray(2 + 2, 4 + MASK_LENGTH); + this.#length = BigInt(this.#frame.readUInt16BE(2)); + break; + default: + this.#dataOffset = 2 + MASK_LENGTH; + this.#mask = frame.subarray(2, 2 + MASK_LENGTH); + this.#length = length; + } + } + + unmask() { + if (!this.#masked) return; + for (let i = 0n; i < this.#length; ++i) { + this.#frame[BigInt(this.#dataOffset) + i] ^= + this.#mask[i & 0x0000000000000003n]; + } + } + + toString() { + return this.#frame.toString( + 'utf8', + this.#dataOffset, + Number(BigInt(this.#dataOffset) + this.#length), + ); + } + + get frame() { + return this.#frame; + } + + static from(data) { + if (Buffer.isBuffer(data)) { + if (data.length === 0) throw new Error('Empty frame!'); + // let invalidStructureCheck = false; + if ((data[1] & 0x80) !== 0x80) throw new Error('1002: protocol error'); + return new Frame(data); + } + + if (typeof data === 'string') { + if (data.length === 0) throw new Error('Empty string!'); + const payload = Buffer.from(data); + const length = payload.length; + let meta = Buffer.alloc(2); + meta[0] = 0x81; // FIN = 1, RSV = 0b000, opcode = 0b0001 (text frame) + if (length < LEN_16_BIT) { + meta[1] = length; + } else if (length < MAX_16_BIT) { + const len = Buffer.alloc(2); + len.writeUint16BE(length, 0); + meta[1] = LEN_16_BIT; + meta = Buffer.concat([meta, len]); + } else if (length < MAX_64_BIT) { + const len = Buffer.alloc(8); + len.writeBigUInt64BE(BigInt(length), 0); + meta[1] = LEN_64_BIT; + this.meta = Buffer.concat([meta, len]); + } else { + throw new Error('string is too long to encode in one frame!'); + } + const frame = Buffer.concat([meta, payload]); + return new Frame(Buffer.from(frame)); + } + + throw new Error('Unsupported'); + } +} +class FrameO { #frame = null; #mask = null; #dataOffset = 6; @@ -147,14 +232,15 @@ class Connection { } send(text) { - const frame = new Frame(text); + const frame = Frame.from(text); this.socket.write(frame.frame); } receive(data) { console.log('data: ', data[0], data.length); if (data[0] !== OPCODE_SHORT) return; - const frame = new Frame(data); + const frame = Frame.from(data); + frame.unmask(); const text = frame.toString(); console.log('Message:', text); this.send(`Echo "${text}"`); From 509564fe97845fd302fb88c9e91a928094d65414 Mon Sep 17 00:00:00 2001 From: Heorhii Date: Wed, 8 Nov 2023 15:23:08 +0100 Subject: [PATCH 4/7] Removed commented line --- lib/websocket.js | 113 ----------------------------------------------- 1 file changed, 113 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 80b4bfac..0bdcbfa0 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -19,12 +19,6 @@ const MAX_16_BIT = 65536; const LEN_64_BIT = 127; const MAX_64_BIT = 0x7fffffffffffffffn + 1n; -const DEFAULT_OPTIONS = { - server: true, -}; - -const xor = (cond1, cond2) => (cond1 || cond2) && !(cond1 && cond2); - class Frame { #frame = null; #masked = null; @@ -77,7 +71,6 @@ class Frame { static from(data) { if (Buffer.isBuffer(data)) { if (data.length === 0) throw new Error('Empty frame!'); - // let invalidStructureCheck = false; if ((data[1] & 0x80) !== 0x80) throw new Error('1002: protocol error'); return new Frame(data); } @@ -110,112 +103,6 @@ class Frame { throw new Error('Unsupported'); } } -class FrameO { - #frame = null; - #mask = null; - #dataOffset = 6; - #length = 0n; - #server = false; - - constructor(data, options) { - if (!Buffer.isBuffer(data) && typeof data !== 'string') { - throw new Error('Unsupported'); - } - const opt = { - ...DEFAULT_OPTIONS, - ...options, - }; - this.#server = opt.server; - - if (Buffer.isBuffer(data) && data.length !== 0) { - this.#frame = data; - this.#decode(); - return; - } - - if (typeof data === 'string' && data.length !== 0) { - this.#encode(data); - return; - } - } - - #hasMask() { - if ((this.#frame[1] & 0x80) === 0x80) return true; - return false; - } - - #decode() { - if (xor(this.#server, this.#hasMask())) - throw new Error('1002 (protocol error)'); - const maskLength = this.#hasMask() ? MASK_LENGTH : 0; - this.#length = BigInt(this.#frame[1] & 0x7f); - switch (this.#length) { - case 127n: - this.#dataOffset = 2 + 8 + maskLength; - this.#mask = this.#frame.subarray(2 + 8, 10 + MASK_LENGTH); - this.#length = this.#frame.readBigUInt64BE(2); - break; - case 126n: - this.#dataOffset = 2 + 2 + maskLength; - this.#mask = this.#frame.subarray(2 + 2, 4 + MASK_LENGTH); - this.#length = BigInt(this.#frame.readUInt16BE(2)); - break; - default: - this.#dataOffset = 2 + maskLength; - this.#mask = this.#frame.subarray(2, 2 + MASK_LENGTH); - } - - if (!this.#server) return; - for (let i = 0n; i < this.#length; ++i) { - this.#frame[BigInt(this.#dataOffset) + i] ^= - this.#mask[i & 0x0000000000000003n]; - } - } - - #encode(text) { - const data = Buffer.from(text); - this.#frame = Buffer.alloc(2); - this.#frame[0] = 0x81; // FIN = 1, RSV = 0b000, opcode = 0b0001 (text frame) - const length = data.length; - if (length < LEN_16_BIT) { - this.#frame[1] = length; - } else if (length < MAX_16_BIT) { - const len = Buffer.alloc(2); - len.writeUint16BE(length, 0); - this.#frame[1] = LEN_16_BIT; - this.#frame = Buffer.concat([this.#frame, len]); - } else if (length < MAX_64_BIT) { - const len = Buffer.alloc(8); - len.writeBigUInt64BE(BigInt(length), 0); - this.#frame[1] = LEN_64_BIT; - this.#frame = Buffer.concat([this.#frame, len]); - } else { - throw new Error('text value is too long to encode in one frame!'); - } - if (!this.#server) throw new Error('Unsupported'); - this.#frame = Buffer.concat([this.#frame, data]); - } - - toString() { - return this.#frame.toString( - 'utf8', - this.#dataOffset, - Number(BigInt(this.#dataOffset) + this.#length), - ); - } - - get data() { - return this.#frame.subarray(this.#dataOffset); - } - - get frame() { - return this.#frame; - } - - get mask() { - return this.#mask; - } -} class Connection { constructor(socket) { From 302bd637b6c189faf236adc81ff5b1a5d738e23b Mon Sep 17 00:00:00 2001 From: Heorhii Date: Thu, 9 Nov 2023 11:44:26 +0100 Subject: [PATCH 5/7] Fix flag masked --- lib/websocket.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/websocket.js b/lib/websocket.js index 0bdcbfa0..7fa5a0c9 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -29,7 +29,7 @@ class Frame { constructor(frame) { this.#frame = frame; const length = BigInt(this.#frame[1] & 0x7f); - this.#masked = true; + this.#masked = (frame[1] & 0x80) === 0x80; switch (length) { case 127n: this.#dataOffset = 2 + 8 + MASK_LENGTH; From e67f5bdc154220186a9fe6aa09ae42546a23e1f0 Mon Sep 17 00:00:00 2001 From: Heorhii Date: Fri, 10 Nov 2023 02:06:20 +0100 Subject: [PATCH 6/7] Reduced max payload size to the max safe integer in JS --- lib/websocket.js | 61 ++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/lib/websocket.js b/lib/websocket.js index 7fa5a0c9..0264a25d 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -17,7 +17,10 @@ const OPCODE_SHORT = 0x81; const LEN_16_BIT = 126; const MAX_16_BIT = 65536; const LEN_64_BIT = 127; -const MAX_64_BIT = 0x7fffffffffffffffn + 1n; +const MAX_64_BIT_PAYLOAD = Number.MAX_SAFE_INTEGER; +const HEAD_LENGTH = 2; +const EXTENDED_PAYLOAD_16_LENGTH = 2; +const EXTENDED_PAYLOAD_64_LENGTH = 8; class Frame { #frame = null; @@ -28,31 +31,32 @@ class Frame { constructor(frame) { this.#frame = frame; - const length = BigInt(this.#frame[1] & 0x7f); + const length = this.#frame[1] & 0x7f; this.#masked = (frame[1] & 0x80) === 0x80; - switch (length) { - case 127n: - this.#dataOffset = 2 + 8 + MASK_LENGTH; - this.#mask = frame.subarray(2 + 8, 10 + MASK_LENGTH); - this.#length = frame.readBigUInt64BE(2); - break; - case 126n: - this.#dataOffset = 2 + 2 + MASK_LENGTH; - this.#mask = frame.subarray(2 + 2, 4 + MASK_LENGTH); - this.#length = BigInt(this.#frame.readUInt16BE(2)); - break; - default: - this.#dataOffset = 2 + MASK_LENGTH; - this.#mask = frame.subarray(2, 2 + MASK_LENGTH); - this.#length = length; + if (length < 126) { + this.#dataOffset = HEAD_LENGTH + MASK_LENGTH; + this.#mask = frame.subarray(HEAD_LENGTH, HEAD_LENGTH + MASK_LENGTH); + this.#length = length; + } else if (length === 126) { + this.#dataOffset = HEAD_LENGTH + EXTENDED_PAYLOAD_16_LENGTH + MASK_LENGTH; + this.#mask = frame.subarray( + HEAD_LENGTH + EXTENDED_PAYLOAD_16_LENGTH, + HEAD_LENGTH + EXTENDED_PAYLOAD_16_LENGTH + MASK_LENGTH, + ); + this.#length = this.#frame.readUInt16BE(2); + } else { + this.#dataOffset = HEAD_LENGTH + EXTENDED_PAYLOAD_64_LENGTH + MASK_LENGTH; + this.#mask = frame.subarray( + HEAD_LENGTH + EXTENDED_PAYLOAD_64_LENGTH, + HEAD_LENGTH + EXTENDED_PAYLOAD_64_LENGTH + MASK_LENGTH, + ); + this.#length = (frame.readUInt32BE(2) << 32) + frame.readUInt32BE(4); } } - unmask() { if (!this.#masked) return; - for (let i = 0n; i < this.#length; ++i) { - this.#frame[BigInt(this.#dataOffset) + i] ^= - this.#mask[i & 0x0000000000000003n]; + for (let i = 0; i < this.#length; ++i) { + this.#frame[this.#dataOffset + i] ^= this.#mask[i & 3]; } } @@ -60,7 +64,7 @@ class Frame { return this.#frame.toString( 'utf8', this.#dataOffset, - Number(BigInt(this.#dataOffset) + this.#length), + this.#dataOffset + this.#length, ); } @@ -72,6 +76,17 @@ class Frame { if (Buffer.isBuffer(data)) { if (data.length === 0) throw new Error('Empty frame!'); if ((data[1] & 0x80) !== 0x80) throw new Error('1002: protocol error'); + // + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + // if payload length is greater than this number. + // + if ((data[2] & 0x7f) === 127) { + const upperInt = data.readUInt32BE(2); + if (upperInt > MAX_64_BIT_PAYLOAD >> 32) + throw new Error( + '1009: Unsupported WebSocket frame: payload length > 2^53 - 1', + ); + } return new Frame(data); } @@ -88,7 +103,7 @@ class Frame { len.writeUint16BE(length, 0); meta[1] = LEN_16_BIT; meta = Buffer.concat([meta, len]); - } else if (length < MAX_64_BIT) { + } else if (length < MAX_64_BIT_PAYLOAD) { const len = Buffer.alloc(8); len.writeBigUInt64BE(BigInt(length), 0); meta[1] = LEN_64_BIT; From c474f02fabb564579646ed0c036ad4c7c373dd66 Mon Sep 17 00:00:00 2001 From: Heorhii Date: Fri, 10 Nov 2023 02:15:04 +0100 Subject: [PATCH 7/7] Fix unmask logic --- lib/websocket.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/websocket.js b/lib/websocket.js index 0264a25d..4b5168a6 100644 --- a/lib/websocket.js +++ b/lib/websocket.js @@ -53,11 +53,13 @@ class Frame { this.#length = (frame.readUInt32BE(2) << 32) + frame.readUInt32BE(4); } } + unmask() { if (!this.#masked) return; for (let i = 0; i < this.#length; ++i) { this.#frame[this.#dataOffset + i] ^= this.#mask[i & 3]; } + this.#masked = false; } toString() {