WebSocketHandshake.sys.mjs (8873B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 // This file is an XPCOM service-ified copy of ../devtools/server/socket/websocket-server.js. 6 7 const CC = Components.Constructor; 8 9 const lazy = {}; 10 11 ChromeUtils.defineESModuleGetters(lazy, { 12 executeSoon: "chrome://remote/content/shared/Sync.sys.mjs", 13 Log: "chrome://remote/content/shared/Log.sys.mjs", 14 RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", 15 }); 16 17 ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); 18 19 ChromeUtils.defineLazyGetter(lazy, "CryptoHash", () => { 20 return CC("@mozilla.org/security/hash;1", "nsICryptoHash", "initWithString"); 21 }); 22 23 ChromeUtils.defineLazyGetter(lazy, "threadManager", () => { 24 return Cc["@mozilla.org/thread-manager;1"].getService(); 25 }); 26 27 /** 28 * Allowed origins are exposed through 2 separate getters because while most 29 * of the values should be valid URIs, `null` is also a valid origin and cannot 30 * be converted to a URI. Call sites interested in checking for null should use 31 * `allowedOrigins`, those interested in URIs should use `allowedOriginURIs`. 32 */ 33 ChromeUtils.defineLazyGetter(lazy, "allowedOrigins", () => 34 lazy.RemoteAgent.allowOrigins !== null ? lazy.RemoteAgent.allowOrigins : [] 35 ); 36 37 ChromeUtils.defineLazyGetter(lazy, "allowedOriginURIs", () => { 38 return lazy.allowedOrigins 39 .map(origin => { 40 try { 41 const originURI = Services.io.newURI(origin); 42 // Make sure to read host/port/scheme as those getters could throw for 43 // invalid URIs. 44 return { 45 host: originURI.host, 46 port: originURI.port, 47 scheme: originURI.scheme, 48 }; 49 } catch (e) { 50 return null; 51 } 52 }) 53 .filter(uri => uri !== null); 54 }); 55 56 /** 57 * Write a string of bytes to async output stream 58 * and return promise that resolves once all data has been written. 59 * Doesn't do any UTF-16/UTF-8 conversion. 60 * The string is treated as an array of bytes. 61 */ 62 function writeString(output, data) { 63 return new Promise((resolve, reject) => { 64 const wait = () => { 65 if (data.length === 0) { 66 resolve(); 67 return; 68 } 69 70 output.asyncWait( 71 () => { 72 try { 73 const written = output.write(data, data.length); 74 data = data.slice(written); 75 wait(); 76 } catch (ex) { 77 reject(ex); 78 } 79 }, 80 0, 81 0, 82 lazy.threadManager.currentThread 83 ); 84 }; 85 86 wait(); 87 }); 88 } 89 90 /** 91 * Write HTTP response with headers (array of strings) and body 92 * to async output stream. 93 */ 94 function writeHttpResponse(output, headers, body = "") { 95 headers.push(`Content-Length: ${body.length}`); 96 97 const s = headers.join("\r\n") + `\r\n\r\n${body}`; 98 return writeString(output, s); 99 } 100 101 /** 102 * Check if the provided URI's host is an IP address. 103 * 104 * @param {nsIURI} uri 105 * The URI to check. 106 * @returns {boolean} 107 */ 108 function isIPAddress(uri) { 109 try { 110 // getBaseDomain throws an explicit error if the uri host is an IP address. 111 Services.eTLD.getBaseDomain(uri); 112 } catch (e) { 113 return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS; 114 } 115 return false; 116 } 117 118 function isHostValid(hostHeader) { 119 try { 120 // Might throw both when calling newURI or when accessing the host/port. 121 const hostUri = Services.io.newURI(`https://${hostHeader}`); 122 const { host, port } = hostUri; 123 const isHostnameValid = 124 isIPAddress(hostUri) || lazy.RemoteAgent.allowHosts.includes(host); 125 // For nsIURI a port value of -1 corresponds to the protocol's default port. 126 const isPortValid = [-1, lazy.RemoteAgent.port].includes(port); 127 return isHostnameValid && isPortValid; 128 } catch (e) { 129 return false; 130 } 131 } 132 133 function isOriginValid(originHeader) { 134 if (originHeader === undefined) { 135 // Always accept no origin header. 136 return true; 137 } 138 139 // Special case "null" origins, used for privacy sensitive or opaque origins. 140 if (originHeader === "null") { 141 return lazy.allowedOrigins.includes("null"); 142 } 143 144 try { 145 // Extract the host, port and scheme from the provided origin header. 146 const { host, port, scheme } = Services.io.newURI(originHeader); 147 // Check if any allowed origin matches the provided host, port and scheme. 148 return lazy.allowedOriginURIs.some( 149 uri => uri.host === host && uri.port === port && uri.scheme === scheme 150 ); 151 } catch (e) { 152 // Reject invalid origin headers 153 return false; 154 } 155 } 156 157 /** 158 * Process the WebSocket handshake headers and return the key to be sent in 159 * Sec-WebSocket-Accept response header. 160 */ 161 function processRequest({ requestLine, headers }) { 162 if (!isOriginValid(headers.get("origin"))) { 163 lazy.logger.debug( 164 `Incorrect Origin header, allowed origins: [${lazy.allowedOrigins}]` 165 ); 166 throw new Error( 167 `The handshake request has incorrect Origin header ${headers.get( 168 "origin" 169 )}` 170 ); 171 } 172 173 if (!isHostValid(headers.get("host"))) { 174 lazy.logger.debug( 175 `Incorrect Host header, allowed hosts: [${lazy.RemoteAgent.allowHosts}]` 176 ); 177 throw new Error( 178 `The handshake request has incorrect Host header ${headers.get("host")}` 179 ); 180 } 181 182 const method = requestLine.split(" ")[0]; 183 if (method !== "GET") { 184 throw new Error("The handshake request must use GET method"); 185 } 186 187 const upgrade = headers.get("upgrade"); 188 if (!upgrade || upgrade.toLowerCase() !== "websocket") { 189 throw new Error( 190 `The handshake request has incorrect Upgrade header: ${upgrade}` 191 ); 192 } 193 194 const connection = headers.get("connection"); 195 if ( 196 !connection || 197 !connection 198 .split(",") 199 .map(t => t.trim().toLowerCase()) 200 .includes("upgrade") 201 ) { 202 throw new Error("The handshake request has incorrect Connection header"); 203 } 204 205 const version = headers.get("sec-websocket-version"); 206 if (!version || version !== "13") { 207 throw new Error( 208 "The handshake request must have Sec-WebSocket-Version: 13" 209 ); 210 } 211 212 // Compute the accept key 213 const key = headers.get("sec-websocket-key"); 214 if (!key) { 215 throw new Error( 216 "The handshake request must have a Sec-WebSocket-Key header" 217 ); 218 } 219 220 return { acceptKey: computeKey(key) }; 221 } 222 223 function computeKey(key) { 224 const str = `${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`; 225 const data = Array.from(str, ch => ch.charCodeAt(0)); 226 const hash = new lazy.CryptoHash("sha1"); 227 hash.update(data, data.length); 228 return hash.finish(true); 229 } 230 231 /** 232 * Perform the server part of a WebSocket opening handshake 233 * on an incoming connection. 234 */ 235 async function serverHandshake(request, output) { 236 try { 237 // Check and extract info from the request 238 const { acceptKey } = processRequest(request); 239 240 // Send response headers 241 await writeHttpResponse(output, [ 242 "HTTP/1.1 101 Switching Protocols", 243 "Server: httpd.js", 244 "Upgrade: websocket", 245 "Connection: Upgrade", 246 `Sec-WebSocket-Accept: ${acceptKey}`, 247 ]); 248 } catch (error) { 249 // Send error response in case of error 250 await writeHttpResponse( 251 output, 252 [ 253 "HTTP/1.1 400 Bad Request", 254 "Server: httpd.js", 255 "Content-Type: text/plain", 256 ], 257 error.message 258 ); 259 260 throw error; 261 } 262 } 263 264 async function createWebSocket(transport, input, output) { 265 const transportProvider = { 266 setListener(upgradeListener) { 267 // onTransportAvailable callback shouldn't be called synchronously 268 lazy.executeSoon(() => { 269 upgradeListener.onTransportAvailable(transport, input, output); 270 }); 271 }, 272 }; 273 274 return new Promise((resolve, reject) => { 275 const socket = WebSocket.createServerWebSocket( 276 null, 277 [], 278 transportProvider, 279 "" 280 ); 281 socket.addEventListener("close", () => { 282 input.close(); 283 output.close(); 284 }); 285 286 socket.onopen = () => resolve(socket); 287 socket.onerror = err => reject(err); 288 }); 289 } 290 291 /** Upgrade an existing HTTP request from httpd.js to WebSocket. */ 292 async function upgrade(request, response) { 293 // handle response manually, allowing us to send arbitrary data 294 response._powerSeized = true; 295 296 const { transport, input, output } = response._connection; 297 298 lazy.logger.info( 299 `Perform WebSocket upgrade for incoming connection from ${transport.host}:${transport.port}` 300 ); 301 302 const headers = new Map(); 303 for (let [key, values] of Object.entries(request._headers._headers)) { 304 headers.set(key, values.join("\n")); 305 } 306 const convertedRequest = { 307 requestLine: `${request.method} ${request.path}`, 308 headers, 309 }; 310 await serverHandshake(convertedRequest, output); 311 312 return createWebSocket(transport, input, output); 313 } 314 315 export const WebSocketHandshake = { upgrade };