fix(websocket): bound frames and messages, close two RFC 6455 gaps
CI / lint (pull_request) Successful in 2m34s
CI / format (pull_request) Successful in 3m15s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 7m19s
CI / lint (pull_request) Successful in 2m34s
CI / format (pull_request) Successful in 3m15s
CI / typecheck (pull_request) Canceled after 0s
CI / typetest (pull_request) Canceled after 0s
CI / node-tests (pull_request) Canceled after 0s
CI / browser-tests (pull_request) Canceled after 0s
CI / install (pull_request) Canceled after 7m19s
readFrame buffered whatever payload length a peer announced, and continuation frames accumulated with no cap on their count or on the assembled size, so a peer could make a connection allocate without bound. It was the only protocol package without such a limit. maxFrameSize, maxMessageSize and maxFragments now bound all three and are configurable through the client and server options; exceeding one closes the connection with 1009. Two conformance gaps sat in the same function. The 64-bit extended length was accumulated with arithmetic that silently loses precision above 2^53 and never checked that its most significant bit is zero (§5.2). Masking was parsed but never enforced (§5.1): a server accepted unmasked client frames and a client accepted masked server frames. Both now fail the connection with 1002. Closes #200 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,18 @@ const handler: Handler = async (ctx) => {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
A WebSocket peer chooses how much a connection allocates, so every incoming frame and message is bounded. Both `connectWebSocket` and `upgradeWebSocket` accept these alongside their other options; pass `Infinity` for one to opt out of it.
|
||||||
|
|
||||||
|
| Option | Default | Applies to |
|
||||||
|
| ---------------- | ------- | ---------------------------------------------------- |
|
||||||
|
| `maxFrameSize` | 1 MiB | payload of a single frame, checked before it is read |
|
||||||
|
| `maxMessageSize` | 8 MiB | assembled size of a message, checked as it arrives |
|
||||||
|
| `maxFragments` | 1024 | number of frames one message may be fragmented into |
|
||||||
|
|
||||||
|
Exceeding one of them fails the connection with close status 1009 (message too big). A frame that breaks the protocol — a client frame that is not masked, a server frame that is, or a 64-bit payload length with its most significant bit set — fails it with 1002 (protocol error). In both cases the iterator ends after the Close frame is sent.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [`@webnet/http`](../http) — the `Handler`/`ClientResponse`/`Server` this package upgrades
|
- [`@webnet/http`](../http) — the `Handler`/`ClientResponse`/`Server` this package upgrades
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import type { ClientResponse } from "@webnet/http/client"
|
import type { ClientResponse } from "@webnet/http/client"
|
||||||
import { ClientConnection, ClientRequestImpl } from "@webnet/http/client"
|
import { ClientConnection, ClientRequestImpl } from "@webnet/http/client"
|
||||||
import type { RawDialer } from "@webnet/transport"
|
import type { RawDialer } from "@webnet/transport"
|
||||||
|
import type { WebSocketLimits } from "./common.js"
|
||||||
import { computeAccept, generateKey, WebSocketConnection } from "./common.js"
|
import { computeAccept, generateKey, WebSocketConnection } from "./common.js"
|
||||||
|
|
||||||
export type WebSocketConnectOptions = {
|
export type WebSocketConnectOptions = WebSocketLimits & {
|
||||||
protocols?: string[]
|
protocols?: string[]
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared: verify Sec-WebSocket-Accept, hijack on success, hijack+close on failure.
|
// Shared: verify Sec-WebSocket-Accept, hijack on success, hijack+close on failure.
|
||||||
async function fromResponse(res: ClientResponse, key: string): Promise<WebSocketConnection> {
|
async function fromResponse(
|
||||||
|
res: ClientResponse,
|
||||||
|
key: string,
|
||||||
|
limits?: WebSocketLimits,
|
||||||
|
): Promise<WebSocketConnection> {
|
||||||
if (res.status !== 101) {
|
if (res.status !== 101) {
|
||||||
throw new Error(`WebSocket upgrade failed: ${res.status} ${res.statusText}`)
|
throw new Error(`WebSocket upgrade failed: ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
@@ -28,7 +33,7 @@ async function fromResponse(res: ClientResponse, key: string): Promise<WebSocket
|
|||||||
throw new TypeError("Invalid Sec-WebSocket-Accept header in upgrade response")
|
throw new TypeError("Invalid Sec-WebSocket-Accept header in upgrade response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WebSocketConnection(res.hijack(), true)
|
return new WebSocketConnection(res.hijack(), true, limits)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overload 1: open a WebSocket to a ws:// or wss:// (or http:// / https://) URL.
|
// Overload 1: open a WebSocket to a ws:// or wss:// (or http:// / https://) URL.
|
||||||
@@ -44,6 +49,7 @@ export async function connectWebSocket(
|
|||||||
export async function connectWebSocket(
|
export async function connectWebSocket(
|
||||||
res: ClientResponse,
|
res: ClientResponse,
|
||||||
key: string,
|
key: string,
|
||||||
|
limits?: WebSocketLimits,
|
||||||
): Promise<WebSocketConnection>
|
): Promise<WebSocketConnection>
|
||||||
|
|
||||||
export async function connectWebSocket(
|
export async function connectWebSocket(
|
||||||
@@ -52,7 +58,7 @@ export async function connectWebSocket(
|
|||||||
options?: WebSocketConnectOptions,
|
options?: WebSocketConnectOptions,
|
||||||
): Promise<WebSocketConnection> {
|
): Promise<WebSocketConnection> {
|
||||||
if ("hijack" in dialerOrRes) {
|
if ("hijack" in dialerOrRes) {
|
||||||
return fromResponse(dialerOrRes, urlOrKey as string)
|
return fromResponse(dialerOrRes, urlOrKey as string, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dialer = dialerOrRes
|
const dialer = dialerOrRes
|
||||||
@@ -117,5 +123,5 @@ export async function connectWebSocket(
|
|||||||
throw new Error(`WebSocket upgrade failed: ${res.status} ${res.statusText}`)
|
throw new Error(`WebSocket upgrade failed: ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fromResponse(res, key)
|
return fromResponse(res, key, options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { WebSocketMessage } from "../common.js"
|
export type { WebSocketLimits, WebSocketMessage } from "../common.js"
|
||||||
export { WebSocketConnection } from "../common.js"
|
export { WebSocketConnection } from "../common.js"
|
||||||
export type { WebSocketConnectOptions } from "../client.js"
|
export type { WebSocketConnectOptions } from "../client.js"
|
||||||
export { connectWebSocket } from "../client.js"
|
export { connectWebSocket } from "../client.js"
|
||||||
|
|||||||
@@ -28,6 +28,48 @@ const OPCODE_CLOSE = 0x8
|
|||||||
const OPCODE_PING = 0x9
|
const OPCODE_PING = 0x9
|
||||||
const OPCODE_PONG = 0xa
|
const OPCODE_PONG = 0xa
|
||||||
|
|
||||||
|
const CLOSE_PROTOCOL_ERROR = 1002
|
||||||
|
const CLOSE_MESSAGE_TOO_BIG = 1009
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounds on what a peer can make a connection allocate. Every limit has a
|
||||||
|
* finite default; pass `Infinity` for one to opt out of it.
|
||||||
|
*/
|
||||||
|
export type WebSocketLimits = {
|
||||||
|
/**
|
||||||
|
* Maximum payload size in bytes of a single incoming frame. Checked against
|
||||||
|
* the announced length, before the payload is read.
|
||||||
|
* @defaultValue 1 MiB
|
||||||
|
*/
|
||||||
|
maxFrameSize?: number
|
||||||
|
/**
|
||||||
|
* Maximum assembled size in bytes of an incoming message.
|
||||||
|
* @defaultValue 8 MiB
|
||||||
|
*/
|
||||||
|
maxMessageSize?: number
|
||||||
|
/**
|
||||||
|
* Maximum number of frames one incoming message may be fragmented into.
|
||||||
|
* @defaultValue 1024
|
||||||
|
*/
|
||||||
|
maxFragments?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LIMITS = {
|
||||||
|
maxFrameSize: 1024 * 1024,
|
||||||
|
maxMessageSize: 8 * 1024 * 1024,
|
||||||
|
maxFragments: 1024,
|
||||||
|
} satisfies Required<WebSocketLimits>
|
||||||
|
|
||||||
|
// A peer violated the protocol. The connection is failed with `code`.
|
||||||
|
class ProtocolError extends Error {
|
||||||
|
code: number
|
||||||
|
|
||||||
|
constructor(code: number, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Frame = { fin: boolean; opcode: number; data: Uint8Array }
|
type Frame = { fin: boolean; opcode: number; data: Uint8Array }
|
||||||
|
|
||||||
// Copy slices from ReadBuffer into a single Uint8Array. Always copies so callers can mutate.
|
// Copy slices from ReadBuffer into a single Uint8Array. Always copies so callers can mutate.
|
||||||
@@ -48,7 +90,11 @@ function sliceBytes(buf: ReadBuffer, start: number, len: number): Uint8Array {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readFrame(buf: ReadBuffer): Promise<Frame> {
|
async function readFrame(
|
||||||
|
buf: ReadBuffer,
|
||||||
|
maxFrameSize: number,
|
||||||
|
expectMasked: boolean,
|
||||||
|
): Promise<Frame> {
|
||||||
await buf.read(2)
|
await buf.read(2)
|
||||||
if (buf.len < 2) throw new Error("Connection closed before frame header")
|
if (buf.len < 2) throw new Error("Connection closed before frame header")
|
||||||
|
|
||||||
@@ -60,6 +106,14 @@ async function readFrame(buf: ReadBuffer): Promise<Frame> {
|
|||||||
const masked = (b1 & 0x80) !== 0
|
const masked = (b1 & 0x80) !== 0
|
||||||
let payloadLen = b1 & 0x7f
|
let payloadLen = b1 & 0x7f
|
||||||
|
|
||||||
|
// RFC 6455 §5.1: client frames are masked, server frames are not.
|
||||||
|
if (masked !== expectMasked) {
|
||||||
|
throw new ProtocolError(
|
||||||
|
CLOSE_PROTOCOL_ERROR,
|
||||||
|
expectMasked ? "Client frame is not masked" : "Server frame is masked",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let extLen = 0
|
let extLen = 0
|
||||||
if (payloadLen === 126) extLen = 2
|
if (payloadLen === 126) extLen = 2
|
||||||
else if (payloadLen === 127) extLen = 8
|
else if (payloadLen === 127) extLen = 8
|
||||||
@@ -71,11 +125,28 @@ async function readFrame(buf: ReadBuffer): Promise<Frame> {
|
|||||||
if (extLen === 2) {
|
if (extLen === 2) {
|
||||||
payloadLen = (lenBytes[0] << 8) | lenBytes[1]
|
payloadLen = (lenBytes[0] << 8) | lenBytes[1]
|
||||||
} else {
|
} else {
|
||||||
payloadLen = 0
|
// RFC 6455 §5.2: the most significant bit of a 64-bit length must be 0.
|
||||||
for (let i = 0; i < 8; i++) payloadLen = payloadLen * 256 + lenBytes[i]
|
if (lenBytes[0] & 0x80) {
|
||||||
|
throw new ProtocolError(CLOSE_PROTOCOL_ERROR, "64-bit frame length has its high bit set")
|
||||||
|
}
|
||||||
|
const hi =
|
||||||
|
((lenBytes[0] << 24) | (lenBytes[1] << 16) | (lenBytes[2] << 8) | lenBytes[3]) >>> 0
|
||||||
|
const lo =
|
||||||
|
((lenBytes[4] << 24) | (lenBytes[5] << 16) | (lenBytes[6] << 8) | lenBytes[7]) >>> 0
|
||||||
|
payloadLen = hi * 0x1_0000_0000 + lo
|
||||||
|
if (!Number.isSafeInteger(payloadLen)) {
|
||||||
|
throw new ProtocolError(CLOSE_MESSAGE_TOO_BIG, "Frame length is not representable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payloadLen > maxFrameSize) {
|
||||||
|
throw new ProtocolError(
|
||||||
|
CLOSE_MESSAGE_TOO_BIG,
|
||||||
|
`Frame payload of ${payloadLen} bytes exceeds the ${maxFrameSize} byte limit`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const headerSize = 2 + extLen + (masked ? 4 : 0)
|
const headerSize = 2 + extLen + (masked ? 4 : 0)
|
||||||
await buf.read(headerSize + payloadLen)
|
await buf.read(headerSize + payloadLen)
|
||||||
if (buf.len < headerSize + payloadLen) throw new Error("Connection closed in frame payload")
|
if (buf.len < headerSize + payloadLen) throw new Error("Connection closed in frame payload")
|
||||||
@@ -164,8 +235,10 @@ export class WebSocketConnection implements AsyncIterable<WebSocketMessage> {
|
|||||||
#closeSent: boolean
|
#closeSent: boolean
|
||||||
#fragmentOpcode: number
|
#fragmentOpcode: number
|
||||||
#fragments: Uint8Array[]
|
#fragments: Uint8Array[]
|
||||||
|
#fragmentSize: number
|
||||||
|
#limits: Required<WebSocketLimits>
|
||||||
|
|
||||||
constructor(transport: RawTransport, masked: boolean) {
|
constructor(transport: RawTransport, masked: boolean, limits: WebSocketLimits = {}) {
|
||||||
this.#transport = transport
|
this.#transport = transport
|
||||||
this.#buf = new ReadBuffer(transport)
|
this.#buf = new ReadBuffer(transport)
|
||||||
this.#masked = masked
|
this.#masked = masked
|
||||||
@@ -175,6 +248,12 @@ export class WebSocketConnection implements AsyncIterable<WebSocketMessage> {
|
|||||||
this.#closeSent = false
|
this.#closeSent = false
|
||||||
this.#fragmentOpcode = 0
|
this.#fragmentOpcode = 0
|
||||||
this.#fragments = []
|
this.#fragments = []
|
||||||
|
this.#fragmentSize = 0
|
||||||
|
this.#limits = {
|
||||||
|
maxFrameSize: limits.maxFrameSize ?? DEFAULT_LIMITS.maxFrameSize,
|
||||||
|
maxMessageSize: limits.maxMessageSize ?? DEFAULT_LIMITS.maxMessageSize,
|
||||||
|
maxFragments: limits.maxFragments ?? DEFAULT_LIMITS.maxFragments,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get closed(): boolean {
|
get closed(): boolean {
|
||||||
@@ -212,12 +291,19 @@ export class WebSocketConnection implements AsyncIterable<WebSocketMessage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *[Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage> {
|
async *[Symbol.asyncIterator](): AsyncGenerator<WebSocketMessage> {
|
||||||
|
// A client masks what it sends and reads unmasked server frames; a server is
|
||||||
|
// the other way round.
|
||||||
|
const expectMasked = !this.#masked
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
let frame: Frame
|
let frame: Frame
|
||||||
try {
|
try {
|
||||||
frame = await readFrame(this.#buf)
|
frame = await readFrame(this.#buf, this.#limits.maxFrameSize, expectMasked)
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
// A misbehaving peer gets a Close frame with the violation's status
|
||||||
|
// code, but we stop reading rather than wait for it to echo it back.
|
||||||
|
if (e instanceof ProtocolError) await this.close(e.code, e.message)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,15 +340,34 @@ export class WebSocketConnection implements AsyncIterable<WebSocketMessage> {
|
|||||||
// Data frame (text, binary, or continuation)
|
// Data frame (text, binary, or continuation)
|
||||||
if (frame.opcode === OPCODE_CONTINUATION) {
|
if (frame.opcode === OPCODE_CONTINUATION) {
|
||||||
this.#fragments.push(frame.data)
|
this.#fragments.push(frame.data)
|
||||||
|
this.#fragmentSize += frame.data.length
|
||||||
} else {
|
} else {
|
||||||
this.#fragmentOpcode = frame.opcode
|
this.#fragmentOpcode = frame.opcode
|
||||||
this.#fragments = [frame.data]
|
this.#fragments = [frame.data]
|
||||||
|
this.#fragmentSize = frame.data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked per frame so an oversized message is rejected while it arrives.
|
||||||
|
if (this.#fragmentSize > this.#limits.maxMessageSize) {
|
||||||
|
await this.close(
|
||||||
|
CLOSE_MESSAGE_TOO_BIG,
|
||||||
|
`Message of at least ${this.#fragmentSize} bytes exceeds the ${this.#limits.maxMessageSize} byte limit`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (this.#fragments.length > this.#limits.maxFragments) {
|
||||||
|
await this.close(
|
||||||
|
CLOSE_MESSAGE_TOO_BIG,
|
||||||
|
`Message exceeds the ${this.#limits.maxFragments} fragment limit`,
|
||||||
|
)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frame.fin) {
|
if (frame.fin) {
|
||||||
const opcode = this.#fragmentOpcode
|
const opcode = this.#fragmentOpcode
|
||||||
const assembled = concat(this.#fragments)
|
const assembled = concat(this.#fragments)
|
||||||
this.#fragments = []
|
this.#fragments = []
|
||||||
|
this.#fragmentSize = 0
|
||||||
this.#fragmentOpcode = 0
|
this.#fragmentOpcode = 0
|
||||||
|
|
||||||
if (opcode === OPCODE_TEXT) {
|
if (opcode === OPCODE_TEXT) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { upgradeWebSocket, type WebSocketUpgradeOptions } from "./server.js"
|
export { upgradeWebSocket, type WebSocketUpgradeOptions } from "./server.js"
|
||||||
export { connectWebSocket, type WebSocketConnectOptions } from "./client.js"
|
export { connectWebSocket, type WebSocketConnectOptions } from "./client.js"
|
||||||
export { WebSocketConnection, type WebSocketMessage } from "./common.js"
|
export { WebSocketConnection, type WebSocketLimits, type WebSocketMessage } from "./common.js"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ServerRequest, ServerResponse } from "@webnet/http/server"
|
import type { ServerRequest, ServerResponse } from "@webnet/http/server"
|
||||||
|
import type { WebSocketLimits } from "./common.js"
|
||||||
import { computeAccept, WebSocketConnection } from "./common.js"
|
import { computeAccept, WebSocketConnection } from "./common.js"
|
||||||
|
|
||||||
export type WebSocketUpgradeOptions = {
|
export type WebSocketUpgradeOptions = WebSocketLimits & {
|
||||||
protocols?: string[]
|
protocols?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,5 +39,5 @@ export async function upgradeWebSocket(
|
|||||||
|
|
||||||
// hijack() sends the 101 response and hands us the raw transport
|
// hijack() sends the 101 response and hands us the raw transport
|
||||||
const transport = await res.hijack()
|
const transport = await res.hijack()
|
||||||
return new WebSocketConnection(transport, false)
|
return new WebSocketConnection(transport, false, options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { WebSocketMessage } from "../common.js"
|
export type { WebSocketLimits, WebSocketMessage } from "../common.js"
|
||||||
export { WebSocketConnection } from "../common.js"
|
export { WebSocketConnection } from "../common.js"
|
||||||
export type { WebSocketUpgradeOptions } from "../server.js"
|
export type { WebSocketUpgradeOptions } from "../server.js"
|
||||||
export { upgradeWebSocket } from "../server.js"
|
export { upgradeWebSocket } from "../server.js"
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from "@webnet/http/client"
|
} from "@webnet/http/client"
|
||||||
import { ReadBuffer } from "@webnet/transport/buffer"
|
import { ReadBuffer } from "@webnet/transport/buffer"
|
||||||
import type { RawTransport } from "@webnet/transport"
|
import type { RawTransport } from "@webnet/transport"
|
||||||
import { upgradeWebSocket } from "./server.js"
|
import { upgradeWebSocket, type WebSocketUpgradeOptions } from "./server.js"
|
||||||
import { connectWebSocket } from "./client.js"
|
import { connectWebSocket, type WebSocketConnectOptions } from "./client.js"
|
||||||
import { computeAccept, generateKey, WebSocketConnection, type WebSocketMessage } from "./common.js"
|
import { computeAccept, generateKey, WebSocketConnection, type WebSocketMessage } from "./common.js"
|
||||||
|
|
||||||
const enc = new TextEncoder()
|
const enc = new TextEncoder()
|
||||||
@@ -113,6 +113,158 @@ async function rawServerResponse(transport: RawTransport, response: string): Pro
|
|||||||
await transport.close()
|
await transport.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frame-level helpers for limit and conformance tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const OPCODE_TEXT = 0x1
|
||||||
|
const OPCODE_BINARY = 0x2
|
||||||
|
const OPCODE_CONTINUATION = 0x0
|
||||||
|
const OPCODE_CLOSE = 0x8
|
||||||
|
|
||||||
|
const MASK_KEY = new Uint8Array([0x01, 0x02, 0x03, 0x04])
|
||||||
|
|
||||||
|
// Frame header announcing `announced` payload bytes, without the payload.
|
||||||
|
// Takes a bigint so lengths beyond Number.MAX_SAFE_INTEGER can be expressed.
|
||||||
|
function header(fin: boolean, opcode: number, announced: bigint, masked: boolean): Uint8Array {
|
||||||
|
let lenField: number
|
||||||
|
let ext: Uint8Array
|
||||||
|
if (announced < 126n) {
|
||||||
|
lenField = Number(announced)
|
||||||
|
ext = new Uint8Array(0)
|
||||||
|
} else if (announced <= 0xffffn) {
|
||||||
|
lenField = 126
|
||||||
|
ext = new Uint8Array([Number((announced >> 8n) & 0xffn), Number(announced & 0xffn)])
|
||||||
|
} else {
|
||||||
|
lenField = 127
|
||||||
|
ext = new Uint8Array(8)
|
||||||
|
let n = announced
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
ext[i] = Number(n & 0xffn)
|
||||||
|
n >>= 8n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = new Uint8Array(2 + ext.length + (masked ? 4 : 0))
|
||||||
|
out[0] = (fin ? 0x80 : 0x00) | opcode
|
||||||
|
out[1] = (masked ? 0x80 : 0x00) | lenField
|
||||||
|
out.set(ext, 2)
|
||||||
|
if (masked) out.set(MASK_KEY, 2 + ext.length)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(fin: boolean, opcode: number, payload: Uint8Array, masked: boolean): Uint8Array {
|
||||||
|
const head = header(fin, opcode, BigInt(payload.length), masked)
|
||||||
|
const out = new Uint8Array(head.length + payload.length)
|
||||||
|
out.set(head, 0)
|
||||||
|
for (let i = 0; i < payload.length; i++) {
|
||||||
|
out[head.length + i] = masked ? payload[i] ^ MASK_KEY[i % 4] : payload[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function take(buf: ReadBuffer, n: number): Promise<Uint8Array> {
|
||||||
|
await buf.read(n)
|
||||||
|
assert.ok(buf.len >= n, "unexpected end of stream")
|
||||||
|
const out = new Uint8Array(n)
|
||||||
|
let off = 0
|
||||||
|
for (const s of buf.slice(0, n)) {
|
||||||
|
out.set(s, off)
|
||||||
|
off += s.length
|
||||||
|
}
|
||||||
|
buf.forward(n)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads one frame with a short (<126 byte) payload, which is all a control
|
||||||
|
// frame can carry. Unmasks if the peer masked it.
|
||||||
|
async function readShortFrame(buf: ReadBuffer): Promise<{ opcode: number; payload: Uint8Array }> {
|
||||||
|
const head = await take(buf, 2)
|
||||||
|
const masked = (head[1] & 0x80) !== 0
|
||||||
|
const len = head[1] & 0x7f
|
||||||
|
assert.ok(len < 126, "helper only reads short frames")
|
||||||
|
const maskKey = masked ? await take(buf, 4) : null
|
||||||
|
const payload = await take(buf, len)
|
||||||
|
if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4]
|
||||||
|
return { opcode: head[0] & 0x0f, payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCloseCode(buf: ReadBuffer): Promise<number> {
|
||||||
|
const f = await readShortFrame(buf)
|
||||||
|
assert.strictEqual(f.opcode, OPCODE_CLOSE)
|
||||||
|
return (f.payload[0] << 8) | f.payload[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performs the client half of the handshake by hand against a real server, then
|
||||||
|
// hands the raw transport to clientFn so it can write frames the client API
|
||||||
|
// would never produce.
|
||||||
|
async function withRawClient(
|
||||||
|
clientFn: (transport: RawTransport, buf: ReadBuffer) => Promise<void>,
|
||||||
|
options?: WebSocketUpgradeOptions,
|
||||||
|
): Promise<WebSocketMessage[]> {
|
||||||
|
const [clientTransport, serverTransport] = loopbackTransportPair()
|
||||||
|
const serverConn = new ServerConnection(serverTransport)
|
||||||
|
const received: WebSocketMessage[] = []
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
serverConn.handle(async ({ req, res }) => {
|
||||||
|
const ws = await upgradeWebSocket(req, res, options)
|
||||||
|
for await (const msg of ws) received.push(msg)
|
||||||
|
}),
|
||||||
|
(async () => {
|
||||||
|
await clientTransport.write(
|
||||||
|
enc.encode(
|
||||||
|
"GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||||
|
`Sec-WebSocket-Key: ${generateKey()}\r\nSec-WebSocket-Version: 13\r\n\r\n`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const buf = new ReadBuffer(clientTransport)
|
||||||
|
while ((await buf.readLine()) !== "") {
|
||||||
|
/* drain the 101 response headers */
|
||||||
|
}
|
||||||
|
await clientFn(clientTransport, buf)
|
||||||
|
await clientTransport.close()
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return received
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serves the server half of the handshake by hand, then hands the raw transport
|
||||||
|
// to serverFn. The client is a real connectWebSocket connection.
|
||||||
|
async function withRawServer(
|
||||||
|
serverFn: (transport: RawTransport, buf: ReadBuffer) => Promise<void>,
|
||||||
|
clientFn: (ws: WebSocketConnection) => Promise<void>,
|
||||||
|
options?: WebSocketConnectOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const [listener, dialer] = loopbackListener()
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
(async () => {
|
||||||
|
const t = await listener.accept()
|
||||||
|
listener.close()
|
||||||
|
const buf = new ReadBuffer(t)
|
||||||
|
let key = ""
|
||||||
|
let line: string
|
||||||
|
while ((line = await buf.readLine()) !== "") {
|
||||||
|
if (line.toLowerCase().startsWith("sec-websocket-key:"))
|
||||||
|
key = line.slice(line.indexOf(":") + 1).trim()
|
||||||
|
}
|
||||||
|
await t.write(
|
||||||
|
enc.encode(
|
||||||
|
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||||
|
`Sec-WebSocket-Accept: ${await computeAccept(key)}\r\n\r\n`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await serverFn(t, buf)
|
||||||
|
await t.close()
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
const ws = await connectWebSocket(dialer, "ws://localhost/", options)
|
||||||
|
await clientFn(ws)
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -933,6 +1085,142 @@ suite("WebSocket", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
suite("limits and conformance", () => {
|
||||||
|
test("server rejects an oversized frame before reading its payload", async () => {
|
||||||
|
let code = 0
|
||||||
|
await withRawClient(async (t, buf) => {
|
||||||
|
// Announce 4 MiB against the 1 MiB default, and send none of it.
|
||||||
|
await t.write(header(true, OPCODE_BINARY, 4n * 1024n * 1024n, true))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1009)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maxFrameSize is configurable and admits a frame at the limit", async () => {
|
||||||
|
let code = 0
|
||||||
|
|
||||||
|
const received = await withRawClient(
|
||||||
|
async (t, buf) => {
|
||||||
|
await t.write(frame(true, OPCODE_TEXT, enc.encode("12345678"), true))
|
||||||
|
await t.write(frame(true, OPCODE_TEXT, enc.encode("123456789"), true))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
},
|
||||||
|
{ maxFrameSize: 8 },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepStrictEqual(received, [{ type: "text", data: "12345678" }])
|
||||||
|
assert.strictEqual(code, 1009)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("client rejects a frame larger than maxFrameSize", async () => {
|
||||||
|
let code = 0
|
||||||
|
|
||||||
|
await withRawServer(
|
||||||
|
async (t, buf) => {
|
||||||
|
await t.write(header(true, OPCODE_BINARY, 64n, false))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
},
|
||||||
|
async (ws) => {
|
||||||
|
for await (const _ of ws) {
|
||||||
|
/* drain */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ maxFrameSize: 32 },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1009)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fragmented message over maxMessageSize is rejected mid-stream", async () => {
|
||||||
|
let code = 0
|
||||||
|
const chunk = new Uint8Array(100)
|
||||||
|
|
||||||
|
await withRawClient(
|
||||||
|
async (t, buf) => {
|
||||||
|
// 100 and 200 bytes are within the limit; the third fragment passes it,
|
||||||
|
// and is rejected without a final frame ever arriving.
|
||||||
|
await t.write(frame(false, OPCODE_BINARY, chunk, true))
|
||||||
|
await t.write(frame(false, OPCODE_CONTINUATION, chunk, true))
|
||||||
|
await t.write(frame(false, OPCODE_CONTINUATION, chunk, true))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
},
|
||||||
|
{ maxMessageSize: 200 },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1009)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("message over maxFragments is rejected", async () => {
|
||||||
|
let code = 0
|
||||||
|
const chunk = new Uint8Array(1)
|
||||||
|
|
||||||
|
await withRawClient(
|
||||||
|
async (t, buf) => {
|
||||||
|
await t.write(frame(false, OPCODE_BINARY, chunk, true))
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await t.write(frame(false, OPCODE_CONTINUATION, chunk, true))
|
||||||
|
}
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
},
|
||||||
|
{ maxFragments: 3 },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1009)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("server fails an unmasked client frame", async () => {
|
||||||
|
let code = 0
|
||||||
|
await withRawClient(async (t, buf) => {
|
||||||
|
await t.write(frame(true, OPCODE_TEXT, enc.encode("hello"), false))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1002)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("client fails a masked server frame", async () => {
|
||||||
|
let code = 0
|
||||||
|
const received: WebSocketMessage[] = []
|
||||||
|
|
||||||
|
await withRawServer(
|
||||||
|
async (t, buf) => {
|
||||||
|
await t.write(frame(true, OPCODE_TEXT, enc.encode("hello"), true))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
},
|
||||||
|
async (ws) => {
|
||||||
|
for await (const msg of ws) received.push(msg)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1002)
|
||||||
|
assert.deepStrictEqual(received, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("server rejects a 64-bit length with its high bit set", async () => {
|
||||||
|
let code = 0
|
||||||
|
await withRawClient(async (t, buf) => {
|
||||||
|
await t.write(header(true, OPCODE_BINARY, 1n << 63n, true))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1002)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a length beyond Number.MAX_SAFE_INTEGER is rejected even without a frame limit", async () => {
|
||||||
|
let code = 0
|
||||||
|
await withRawClient(
|
||||||
|
async (t, buf) => {
|
||||||
|
await t.write(header(true, OPCODE_BINARY, (1n << 62n) + 1n, true))
|
||||||
|
code = await readCloseCode(buf)
|
||||||
|
},
|
||||||
|
{ maxFrameSize: Infinity },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.strictEqual(code, 1009)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
suite("send / ping errors after close", () => {
|
suite("send / ping errors after close", () => {
|
||||||
test("send() throws after close() is called", async () => {
|
test("send() throws after close() is called", async () => {
|
||||||
await withWebSocketPair(
|
await withWebSocketPair(
|
||||||
|
|||||||
Reference in New Issue
Block a user