Add a shelf_web_socket package. R=kevmoo@google.com Review URL: https://codereview.chromium.org//297593003 git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart/pkg/shelf_web_socket@36394 260f80e4-7a28-3924-810f-c04153c831b5
diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c60afe --- /dev/null +++ b/LICENSE
@@ -0,0 +1,26 @@ +Copyright 2014, the Dart project authors. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..9091de0 --- /dev/null +++ b/README.md
@@ -0,0 +1,31 @@ +## Web Socket Handler for Shelf + +`shelf_web_socket` is a [Shelf][] handler for establishing [WebSocket][] +connections. It exposes a single function, [webSocketHandler][], which calls an +`onConnection` callback with a [CompatibleWebSocket][] object for every +connection that's established. + +[Shelf]: pub.dartlang.org/packages/shelf + +[WebSocket]: https://tools.ietf.org/html/rfc6455 + +[webSocketHandler]: https://api.dartlang.org/apidocs/channels/be/dartdoc-viewer/shelf_web_socket/shelf_web_socket.webSocketHandler + +[CompatibleWebSocket]: https://api.dartlang.org/apidocs/channels/be/dartdoc-viewer/http_parser/http_parser.CompatibleWebSocket + +```dart +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_web_socket/shelf_web_socket.dart'; + +void main() { + var handler = webSocketHandler((webSocket) { + webSocket.listen((message) { + webSocket.add("echo $message"); + }); + }); + + shelf_io.serve(handler, 'localhost', 8080).then((server) { + print('Serving at ws://${server.address.host}:${server.port}'); + }); +} +```
diff --git a/lib/shelf_web_socket.dart b/lib/shelf_web_socket.dart new file mode 100644 index 0000000..8aac366 --- /dev/null +++ b/lib/shelf_web_socket.dart
@@ -0,0 +1,61 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library shelf_web_socket; + +import 'package:shelf/shelf.dart'; + +import 'src/web_socket_handler.dart'; + +/// A typedef used to determine if a function takes two arguments or not. +typedef _BinaryFunction(arg1, arg2); + +/// Creates a Shelf handler that upgrades HTTP requests to WebSocket +/// connections. +/// +/// Only valid WebSocket upgrade requests are upgraded. If a request doesn't +/// look like a WebSocket upgrade request, a 404 Not Found is returned; if a +/// request looks like an upgrade request but is invalid, a 400 Bad Request is +/// returned; and if a request is a valid upgrade request but has an origin that +/// doesn't match [allowedOrigins] (see below), a 403 Forbidden is returned. +/// This means that this can be placed first in a [Cascade] and only upgrade +/// requests will be handled. +/// +/// The [onConnection] must take a [CompatibleWebSocket] as its first argument. +/// It may also take a string, the [WebSocket subprotocol][], as its second +/// argument. The subprotocol is determined by looking at the client's +/// `Sec-WebSocket-Protocol` header and selecting the first entry that also +/// appears in [protocols]. If no subprotocols are shared between the client and +/// the server, `null` will be passed instead. Note that if [onConnection] takes +/// two arguments, [protocols] must be passed. +/// +/// [WebSocket subprotocol]: https://tools.ietf.org/html/rfc6455#section-1.9 +/// +/// If [allowedOrigins] is passed, browser connections will only be accepted if +/// they're made by a script from one of the given origins. This ensures that +/// malicious scripts running in the browser are unable to fake a WebSocket +/// handshake. Note that non-browser programs can still make connections freely. +/// See also the WebSocket spec's discussion of [origin considerations][]. +/// +/// [origin considerations]: https://tools.ietf.org/html/rfc6455#section-10.2 +Handler webSocketHandler(Function onConnection, {Iterable<String> protocols, + Iterable<String> allowedOrigins}) { + if (protocols != null) protocols = protocols.toSet(); + if (allowedOrigins != null) { + allowedOrigins = allowedOrigins + .map((origin) => origin.toLowerCase()).toSet(); + } + + if (onConnection is! _BinaryFunction) { + if (protocols != null) { + throw new ArgumentError("If protocols is non-null, onConnection must " + "take two arguments, the WebSocket and the protocol."); + } + + var innerOnConnection = onConnection; + onConnection = (webSocket, _) => innerOnConnection(webSocket); + } + + return new WebSocketHandler(onConnection, protocols, allowedOrigins).handle; +}
diff --git a/lib/src/web_socket_handler.dart b/lib/src/web_socket_handler.dart new file mode 100644 index 0000000..ac5786a --- /dev/null +++ b/lib/src/web_socket_handler.dart
@@ -0,0 +1,133 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library shelf_web_socket.web_socket_handler; + +import 'dart:convert'; + +import 'package:http_parser/http_parser.dart'; +import 'package:shelf/shelf.dart'; + +/// A class that exposes a handler for upgrading WebSocket requests. +class WebSocketHandler { + /// The function to call when a request is upgraded. + final Function _onConnection; + + /// The set of protocols the user supports, or `null`. + final Set<String> _protocols; + + /// The set of allowed browser origin connections, or `null`.. + final Set<String> _allowedOrigins; + + WebSocketHandler(this._onConnection, this._protocols, this._allowedOrigins); + + /// The [Handler]. + Response handle(Request request) { + if (request.method != 'GET') return _notFound(); + + var connection = request.headers['Connection']; + if (connection == null) return _notFound(); + if (connection.toLowerCase() != 'upgrade') return _notFound(); + + var upgrade = request.headers['Upgrade']; + if (upgrade == null) return _notFound(); + if (upgrade.toLowerCase() != 'websocket') return _notFound(); + + var version = request.headers['Sec-WebSocket-Version']; + if (version == null) { + return _badRequest('missing Sec-WebSocket-Version header.'); + } else if (version != '13') { + return _notFound(); + } + + if (request.protocolVersion != '1.1') { + return _badRequest('unexpected HTTP version ' + '"${request.protocolVersion}".'); + } + + var key = request.headers['Sec-WebSocket-Key']; + if (key == null) return _badRequest('missing Sec-WebSocket-Key header.'); + + if (!request.canHijack) { + throw new ArgumentError("webSocketHandler may only be used with a server " + "that supports request hijacking."); + } + + // The Origin header is always set by browser connections. By filtering out + // unexpected origins, we ensure that malicious JavaScript is unable to fake + // a WebSocket handshake. + var origin = request.headers['Origin']; + if (origin != null && _allowedOrigins != null && + !_allowedOrigins.contains(origin.toLowerCase())) { + return _forbidden('invalid origin "$origin".'); + } + + var protocol = _chooseProtocol(request); + request.hijack((stream, byteSink) { + var sink = UTF8.encoder.startChunkedConversion(byteSink); + sink.add( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: ${CompatibleWebSocket.signKey(key)}\r\n"); + if (protocol != null) sink.add("Sec-WebSocket-Protocol: $protocol\r\n"); + sink.add("\r\n"); + + _onConnection(new CompatibleWebSocket(stream, sink: byteSink), protocol); + }); + + // [request.hijack] is guaranteed to throw a [HijackException], so we'll + // never get here. + assert(false); + return null; + } + + /// Selects a subprotocol to use for the given connection. + /// + /// If no matching protocol can be found, returns `null`. + String _chooseProtocol(Request request) { + var protocols = request.headers['Sec-WebSocket-Protocol']; + if (protocols == null) return null; + for (var protocol in protocols.split(',')) { + protocol = protocol.trim(); + if (_protocols.contains(protocol)) return protocol; + } + return null; + } + + /// Returns a 404 Not Found response. + Response _notFound() => _htmlResponse(404, "404 Not Found", + "Only WebSocket connections are supported."); + + /// Returns a 400 Bad Request response. + /// + /// [message] will be HTML-escaped before being included in the response body. + Response _badRequest(String message) => _htmlResponse(400, "400 Bad Request", + "Invalid WebSocket upgrade request: $message"); + + /// Returns a 403 Forbidden response. + /// + /// [message] will be HTML-escaped before being included in the response body. + Response _forbidden(String message) => _htmlResponse(403, "403 Forbidden", + "WebSocket upgrade refused: $message"); + + /// Creates an HTTP response with the given [statusCode] and an HTML body with + /// [title] and [message]. + /// + /// [title] and [message] will be automatically HTML-escaped. + Response _htmlResponse(int statusCode, String title, String message) { + title = HTML_ESCAPE.convert(title); + message = HTML_ESCAPE.convert(message); + return new Response(statusCode, body: """ + <!doctype html> + <html> + <head><title>$title</title></head> + <body> + <h1>$title</h1> + <p>$message</p> + </body> + </html> + """, headers: {'content-type': 'text/html'}); + } +}
diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..7cecc42 --- /dev/null +++ b/pubspec.yaml
@@ -0,0 +1,14 @@ +name: shelf_web_socket +version: 0.0.1 +author: "Dart Team <misc@dartlang.org>" +homepage: http://www.dartlang.org +description: > + A WebSocket handler for Shelf. +dependencies: + http_parser: ">=0.0.2 <0.1.0" + shelf: ">=0.5.2 <0.6.0" +dev_dependencies: + http: ">=0.10.0 <0.12.0" + unittest: ">=0.10.0 <0.12.0" +environment: + sdk: ">=1.4.0-dev.5.0 <2.0.0"
diff --git a/test/web_socket_test.dart b/test/web_socket_test.dart new file mode 100644 index 0000000..f9ae2dc --- /dev/null +++ b/test/web_socket_test.dart
@@ -0,0 +1,167 @@ +// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +library shelf_web_socket.web_socket_test; + +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:unittest/unittest.dart'; + +Map<String, String> get _handshakeHeaders => { + "Upgrade": "websocket", + "Connection": "Upgrade", + "Sec-WebSocket-Key": "x3JJHMbDL1EzLkh9GBhXDw==", + "Sec-WebSocket-Version": "13" +}; + +void main() { + test("can communicate with a dart:io WebSocket client", () { + return shelf_io.serve(webSocketHandler((webSocket) { + webSocket.add("hello!"); + webSocket.first.then((request) { + expect(request, equals("ping")); + webSocket.add("pong"); + webSocket.close(); + }); + }), "localhost", 0).then((server) { + return WebSocket.connect('ws://localhost:${server.port}') + .then((webSocket) { + var n = 0; + return webSocket.listen((message) { + if (n == 0) { + expect(message, equals("hello!")); + webSocket.add("ping"); + } else if (n == 1) { + expect(message, equals("pong")); + webSocket.close(); + server.close(); + } else { + fail("Only expected two messages."); + } + n++; + }).asFuture(); + }).whenComplete(server.close); + }); + }); + + test("negotiates the sub-protocol", () { + return shelf_io.serve(webSocketHandler((webSocket, protocol) { + expect(protocol, equals("two")); + webSocket.close(); + }, protocols: ["three", "two", "x"]), "localhost", 0).then((server) { + return WebSocket.connect('ws://localhost:${server.port}', + protocols: ["one", "two", "three"]).then((webSocket) { + expect(webSocket.protocol, equals("two")); + return webSocket.close(); + }).whenComplete(server.close); + }); + }); + + group("with a set of allowed origins", () { + var server; + var url; + setUp(() { + return shelf_io.serve(webSocketHandler((webSocket) { + webSocket.close(); + }, allowedOrigins: ["pub.dartlang.org", "GoOgLe.CoM"]), "localhost", 0) + .then((server_) { + server = server_; + url = 'http://localhost:${server.port}/'; + }); + }); + + tearDown(() => server.close()); + + test("allows access with an allowed origin", () { + var headers = _handshakeHeaders; + headers['Origin'] = 'pub.dartlang.org'; + expect(http.get(url, headers: headers), hasStatus(101)); + }); + + test("forbids access with a non-allowed origin", () { + var headers = _handshakeHeaders; + headers['Origin'] = 'dartlang.org'; + expect(http.get(url, headers: headers), hasStatus(403)); + }); + + test("allows access with no origin", () { + expect(http.get(url, headers: _handshakeHeaders), hasStatus(101)); + }); + + test("ignores the case of the client origin", () { + var headers = _handshakeHeaders; + headers['Origin'] = 'PuB.DaRtLaNg.OrG'; + expect(http.get(url, headers: headers), hasStatus(101)); + }); + + test("ignores the case of the server origin", () { + var headers = _handshakeHeaders; + headers['Origin'] = 'google.com'; + expect(http.get(url, headers: headers), hasStatus(101)); + }); + }); + + group("HTTP errors", () { + var server; + var url; + setUp(() { + return shelf_io.serve(webSocketHandler((_) { + fail("should not create a WebSocket"); + }), "localhost", 0).then((server_) { + server = server_; + url = 'http://localhost:${server.port}/'; + }); + }); + + tearDown(() => server.close()); + + test("404s for non-GET requests", () { + expect(http.delete(url, headers: _handshakeHeaders), hasStatus(404)); + }); + + test("404s for non-Upgrade requests", () { + var headers = _handshakeHeaders; + headers.remove('Connection'); + expect(http.get(url, headers: headers), hasStatus(404)); + }); + + test("404s for non-websocket upgrade requests", () { + var headers = _handshakeHeaders; + headers['Upgrade'] = 'fblthp'; + expect(http.get(url, headers: headers), hasStatus(404)); + }); + + test("400s for a missing Sec-WebSocket-Version", () { + var headers = _handshakeHeaders; + headers.remove('Sec-WebSocket-Version'); + expect(http.get(url, headers: headers), hasStatus(400)); + }); + + test("404s for an unknown Sec-WebSocket-Version", () { + var headers = _handshakeHeaders; + headers['Sec-WebSocket-Version'] = '15'; + expect(http.get(url, headers: headers), hasStatus(404)); + }); + + test("400s for a missing Sec-WebSocket-Key", () { + var headers = _handshakeHeaders; + headers.remove('Sec-WebSocket-Key'); + expect(http.get(url, headers: headers), hasStatus(400)); + }); + }); + + test("throws an error if a unary function is provided with protocols", () { + expect(() => webSocketHandler((_) => null, protocols: ['foo']), + throwsArgumentError); + }); +} + +Matcher hasStatus(int status) => completion(predicate((response) { + expect(response, new isInstanceOf<http.Response>()); + expect(response.statusCode, equals(status)); + return true; +}));