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
|
||||
|
||||
- [`@webnet/http`](../http) — the `Handler`/`ClientResponse`/`Server` this package upgrades
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import type { ClientResponse } from "@webnet/http/client"
|
||||
import { ClientConnection, ClientRequestImpl } from "@webnet/http/client"
|
||||
import type { RawDialer } from "@webnet/transport"
|
||||
import type { WebSocketLimits } from "./common.js"
|
||||
import { computeAccept, generateKey, WebSocketConnection } from "./common.js"
|
||||
|
||||
export type WebSocketConnectOptions = {
|
||||
export type WebSocketConnectOptions = WebSocketLimits & {
|
||||
protocols?: string[]
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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")
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -44,6 +49,7 @@ export async function connectWebSocket(
|
||||
export async function connectWebSocket(
|
||||
res: ClientResponse,
|
||||
key: string,
|
||||
limits?: WebSocketLimits,
|
||||
): Promise<WebSocketConnection>
|
||||
|
||||
export async function connectWebSocket(
|
||||
@@ -52,7 +58,7 @@ export async function connectWebSocket(
|
||||
options?: WebSocketConnectOptions,
|
||||
): Promise<WebSocketConnection> {
|
||||
if ("hijack" in dialerOrRes) {
|
||||
return fromResponse(dialerOrRes, urlOrKey as string)
|
||||
return fromResponse(dialerOrRes, urlOrKey as string, options)
|
||||
}
|
||||
|
||||
const dialer = dialerOrRes
|
||||
@@ -117,5 +123,5 @@ export async function connectWebSocket(
|
||||
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 type { WebSocketConnectOptions } from "../client.js"
|
||||
export { connectWebSocket } from "../client.js"
|
||||
|
||||
@@ -28,6 +28,48 @@ const OPCODE_CLOSE = 0x8
|
||||
const OPCODE_PING = 0x9
|
||||
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 }
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
async function readFrame(buf: ReadBuffer): Promise<Frame> {
|
||||
async function readFrame(
|
||||
buf: ReadBuffer,
|
||||
maxFrameSize: number,
|
||||
expectMasked: boolean,
|
||||
): Promise<Frame> {
|
||||
await buf.read(2)
|
||||
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
|
||||
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
|
||||
if (payloadLen === 126) extLen = 2
|
||||
else if (payloadLen === 127) extLen = 8
|
||||
@@ -71,11 +125,28 @@ async function readFrame(buf: ReadBuffer): Promise<Frame> {
|
||||
if (extLen === 2) {
|
||||
payloadLen = (lenBytes[0] << 8) | lenBytes[1]
|
||||
} else {
|
||||
payloadLen = 0
|
||||
for (let i = 0; i < 8; i++) payloadLen = payloadLen * 256 + lenBytes[i]
|
||||
// RFC 6455 §5.2: the most significant bit of a 64-bit length must be 0.
|
||||
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)
|
||||
await buf.read(headerSize + payloadLen)
|
||||
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
|
||||
#fragmentOpcode: number
|
||||
#fragments: Uint8Array[]
|
||||
#fragmentSize: number
|
||||
#limits: Required<WebSocketLimits>
|
||||
|
||||
constructor(transport: RawTransport, masked: boolean) {
|
||||
constructor(transport: RawTransport, masked: boolean, limits: WebSocketLimits = {}) {
|
||||
this.#transport = transport
|
||||
this.#buf = new ReadBuffer(transport)
|
||||
this.#masked = masked
|
||||
@@ -175,6 +248,12 @@ export class WebSocketConnection implements AsyncIterable<WebSocketMessage> {
|
||||
this.#closeSent = false
|
||||
this.#fragmentOpcode = 0
|
||||
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 {
|
||||
@@ -212,12 +291,19 @@ export class WebSocketConnection implements AsyncIterable<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 {
|
||||
while (true) {
|
||||
let frame: Frame
|
||||
try {
|
||||
frame = await readFrame(this.#buf)
|
||||
} catch {
|
||||
frame = await readFrame(this.#buf, this.#limits.maxFrameSize, expectMasked)
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -254,15 +340,34 @@ export class WebSocketConnection implements AsyncIterable<WebSocketMessage> {
|
||||
// Data frame (text, binary, or continuation)
|
||||
if (frame.opcode === OPCODE_CONTINUATION) {
|
||||
this.#fragments.push(frame.data)
|
||||
this.#fragmentSize += frame.data.length
|
||||
} else {
|
||||
this.#fragmentOpcode = frame.opcode
|
||||
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) {
|
||||
const opcode = this.#fragmentOpcode
|
||||
const assembled = concat(this.#fragments)
|
||||
this.#fragments = []
|
||||
this.#fragmentSize = 0
|
||||
this.#fragmentOpcode = 0
|
||||
|
||||
if (opcode === OPCODE_TEXT) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { upgradeWebSocket, type WebSocketUpgradeOptions } from "./server.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 { WebSocketLimits } from "./common.js"
|
||||
import { computeAccept, WebSocketConnection } from "./common.js"
|
||||
|
||||
export type WebSocketUpgradeOptions = {
|
||||
export type WebSocketUpgradeOptions = WebSocketLimits & {
|
||||
protocols?: string[]
|
||||
}
|
||||
|
||||
@@ -38,5 +39,5 @@ export async function upgradeWebSocket(
|
||||
|
||||
// hijack() sends the 101 response and hands us the raw transport
|
||||
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 type { WebSocketUpgradeOptions } from "../server.js"
|
||||
export { upgradeWebSocket } from "../server.js"
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "@webnet/http/client"
|
||||
import { ReadBuffer } from "@webnet/transport/buffer"
|
||||
import type { RawTransport } from "@webnet/transport"
|
||||
import { upgradeWebSocket } from "./server.js"
|
||||
import { connectWebSocket } from "./client.js"
|
||||
import { upgradeWebSocket, type WebSocketUpgradeOptions } from "./server.js"
|
||||
import { connectWebSocket, type WebSocketConnectOptions } from "./client.js"
|
||||
import { computeAccept, generateKey, WebSocketConnection, type WebSocketMessage } from "./common.js"
|
||||
|
||||
const enc = new TextEncoder()
|
||||
@@ -113,6 +113,158 @@ async function rawServerResponse(transport: RawTransport, response: string): Pro
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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", () => {
|
||||
test("send() throws after close() is called", async () => {
|
||||
await withWebSocketPair(
|
||||
|
||||
Reference in New Issue
Block a user