Skip to content

Commit 011a940

Browse files
committedAug 29, 2023
Add workarounds to fudge & accept mildly invalid WebSocket protocols
Without this, WS will reject invalid subprotocols, for empty header values, or some syntactically incorrect values. Some real clients send these headers in ways that are accepted, so it's very useful to be able to intercept them, and the intent is clear: an empty-string value should be a null not-set protocol (and is generally treated as such). In a perfect world, we'd forward the subprotocol exactly as-is without this, and avoid taking an opinion entirely as long as we could do basic handling, but WS has opinions and ties our hands on that.
1 parent 931055f commit 011a940

File tree

2 files changed

+66
-3
lines changed

2 files changed

+66
-3
lines changed
 

‎src/rules/websockets/websocket-handlers.ts

+17-2
Original file line numberDiff line numberDiff line change
@@ -346,10 +346,25 @@ export class PassThroughWebSocketHandler extends PassThroughWebSocketHandlerDefi
346346

347347
// Subprotocols have to be handled explicitly. WS takes control of the headers itself,
348348
// and checks the response, so we need to parse the client headers and use them manually:
349-
const subprotocols = findRawHeaders(rawHeaders, 'sec-websocket-protocol')
349+
const originalSubprotocols = findRawHeaders(rawHeaders, 'sec-websocket-protocol')
350350
.flatMap(([_k, value]) => value.split(',').map(p => p.trim()));
351351

352-
const upstreamWebSocket = new WebSocket(wsUrl, subprotocols, {
352+
// Drop empty subprotocols, to better handle mildly badly behaved clients
353+
const filteredSubprotocols = originalSubprotocols.filter(p => !!p);
354+
355+
// If the subprotocols are invalid (there are some empty strings, or an entirely empty value) then
356+
// WS will reject the upgrade. With this, we reset the header to the 'equivalent' valid version, to
357+
// avoid unnecessarily rejecting clients who send mildly wrong headers (empty protocol values).
358+
if (originalSubprotocols.length !== filteredSubprotocols.length) {
359+
if (filteredSubprotocols.length) {
360+
// Note that req.headers is auto-lowercased by Node, so we can ignore case
361+
req.headers['sec-websocket-protocol'] = filteredSubprotocols.join(',')
362+
} else {
363+
delete req.headers['sec-websocket-protocol'];
364+
}
365+
}
366+
367+
const upstreamWebSocket = new WebSocket(wsUrl, filteredSubprotocols, {
353368
maxPayload: 0,
354369
agent,
355370
lookup: getDnsLookupFunction(this.lookupOptions),

‎test/integration/websockets.spec.ts

+49-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as net from 'net';
22
import * as WebSocket from 'isomorphic-ws';
3+
import * as http from 'http';
34
import * as https from 'https';
45
import HttpProxyAgent = require('http-proxy-agent');
56
import HttpsProxyAgent = require('https-proxy-agent');
@@ -188,7 +189,6 @@ nodeOnly(() => {
188189
]);
189190
});
190191

191-
192192
it("forwards the incoming requests' & resulting response's subprotocols", async () => {
193193
mockServer.forAnyWebSocket().thenPassThrough();
194194

@@ -225,6 +225,54 @@ nodeOnly(() => {
225225
]);
226226
});
227227

228+
it("ignores mildly invalid blank (empty string) subprotocol headers in incoming requests", async () => {
229+
await mockServer.forAnyWebSocket().thenPassThrough();
230+
const request = https.request(`https://localhost:${wsPort}`, {
231+
agent: new HttpProxyAgent(`http://localhost:${mockServer.port}`),
232+
headers: {
233+
'Connection': 'Upgrade',
234+
'Upgrade': 'websocket',
235+
'Sec-WebSocket-Version': 13,
236+
'Sec-WebSocket-Key': 'DxfWc2xtQqmWYmU/n8WUWg==',
237+
'Sec-WebSocket-Protocol': ' ' // Empty headers are invalid
238+
}
239+
}).end();
240+
241+
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
242+
request.on('response', resolve);
243+
request.on('upgrade', resolve);
244+
request.on('error', reject);
245+
});
246+
247+
expect(response.statusCode).to.equal(101);
248+
expect(response.headers['sec-websocket-protocol']).to.equal(undefined);
249+
});
250+
251+
it("handles mildly invalid non-empty subprotocol headers in incoming requests", async () => {
252+
await mockServer.forAnyWebSocket().thenPassThrough();
253+
const request = https.request(`https://localhost:${wsPort}`, {
254+
agent: new HttpProxyAgent(`http://localhost:${mockServer.port}`),
255+
headers: {
256+
'Connection': 'Upgrade',
257+
'Upgrade': 'websocket',
258+
'Sec-WebSocket-Version': 13,
259+
'Sec-WebSocket-Key': 'DxfWc2xtQqmWYmU/n8WUWg==',
260+
'Sec-WebSocket-Protocol': ' ', // Empty headers are invalid
261+
'sec-webSocket-protocol': 'a,,b', // Badly formatted other protocols
262+
'echo-ws-protocol-index': '0'
263+
}
264+
}).end();
265+
266+
const response = await new Promise<http.IncomingMessage>((resolve, reject) => {
267+
request.on('response', resolve);
268+
request.on('upgrade', resolve);
269+
request.on('error', reject);
270+
});
271+
272+
expect(response.statusCode).to.equal(101);
273+
expect(response.headers['sec-websocket-protocol']).to.equal('a');
274+
});
275+
228276
it("can handle & proxy invalid client frames upstream", async () => {
229277
mockServer.forAnyWebSocket().thenPassThrough();
230278

0 commit comments

Comments
 (0)