|
25 | 25 | */
|
26 | 26 | package gribbit.request;
|
27 | 27 |
|
28 |
| -import static io.netty.handler.codec.http.HttpHeaderNames.CACHE_CONTROL; |
29 | 28 | import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT_ENCODING;
|
| 29 | +import static io.netty.handler.codec.http.HttpHeaderNames.CACHE_CONTROL; |
30 | 30 | import static io.netty.handler.codec.http.HttpHeaderNames.CONNECTION;
|
31 | 31 | import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_ENCODING;
|
32 |
| -import static io.netty.handler.codec.http.HttpHeaderNames.SERVER; |
33 | 32 | import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
|
34 | 33 | import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
|
35 | 34 | import static io.netty.handler.codec.http.HttpHeaderNames.DATE;
|
|
38 | 37 | import static io.netty.handler.codec.http.HttpHeaderNames.EXPIRES;
|
39 | 38 | import static io.netty.handler.codec.http.HttpHeaderNames.LAST_MODIFIED;
|
40 | 39 | import static io.netty.handler.codec.http.HttpHeaderNames.PRAGMA;
|
| 40 | +import static io.netty.handler.codec.http.HttpHeaderNames.SERVER; |
41 | 41 | import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE;
|
42 | 42 | import static io.netty.handler.codec.http.HttpHeaderValues.GZIP;
|
43 | 43 | import static io.netty.handler.codec.http.HttpHeaderValues.KEEP_ALIVE;
|
| 44 | +import gribbit.auth.CSRF; |
44 | 45 | import gribbit.auth.Cookie;
|
45 | 46 | import gribbit.auth.User;
|
46 | 47 | import gribbit.response.ErrorResponse;
|
|
109 | 110 | import java.io.RandomAccessFile;
|
110 | 111 | import java.net.InetAddress;
|
111 | 112 | import java.net.InetSocketAddress;
|
| 113 | +import java.net.URI; |
112 | 114 | import java.nio.charset.Charset;
|
113 | 115 | import java.time.Instant;
|
114 | 116 | import java.time.ZoneId;
|
@@ -826,15 +828,83 @@ public void messageReceived(ChannelHandlerContext ctx, Object msg) throws Except
|
826 | 828 | // Complete websocket handshake if requested
|
827 | 829 | // ------------------------------------------------------------------------------
|
828 | 830 |
|
| 831 | + // FIXME: Make these into class annotations |
| 832 | + String websocketPath = "/websocket"; |
| 833 | + boolean isAuthenticatedWebsocket = true; |
| 834 | + |
829 | 835 | if (response == null && authorizedRoute == null && msg instanceof HttpRequest
|
830 |
| - // TODO: Read WS routes from class annotations |
831 |
| - && reqURI.endsWith("/websocket")) { |
| 836 | + // TODO: Read WS routes from class annotations, rather than using hardcoded "/websocket" |
| 837 | + && reqURI.endsWith(websocketPath)) { |
832 | 838 | HttpRequest httpReq = (HttpRequest) msg;
|
833 | 839 |
|
834 |
| - // Record which user was authenticated (if any) when websocket upgrade request was made. |
835 |
| - // TODO: Reject WS upgrade request for websockets that require authentication. |
836 |
| - // TODO: Also provide a means for revoking WS login. |
837 |
| - wsAuthenticatedUser = User.getLoggedInUser(request); |
| 840 | + // Protect against CSWSH: (Cross-Site WebSocket Hijacking) |
| 841 | + // http://www.christian-schneider.net/CrossSiteWebSocketHijacking.html |
| 842 | + // http://tools.ietf.org/html/rfc6455#page-7 |
| 843 | + CharSequence origin = request.getOrigin(); |
| 844 | + URI originUri = null; |
| 845 | + if (origin != null && origin.length() > 0) { |
| 846 | + try { |
| 847 | + // Try parsing origin URI |
| 848 | + originUri = new URI(origin.toString()); |
| 849 | + } catch (Exception e) { |
| 850 | + } |
| 851 | + } |
| 852 | + // If port number is set but it is the default for the URI scheme, revert the port number |
| 853 | + // back to -1 (which means unspecified), so that it matches the server port number, |
| 854 | + // which is unspecified when serving http on port 80 and https on port 443 |
| 855 | + int originPort = originUri == null ? -1 // |
| 856 | + : originUri.getPort() == 80 && "http".equals(originUri.getScheme()) ? -1 // |
| 857 | + : originUri.getPort() == 443 && "https".equals(originUri.getScheme()) ? -1 // |
| 858 | + : originUri.getPort(); |
| 859 | + // Scheme, host and port all must match to forbid cross-origin requests |
| 860 | + if (originUri == null // |
| 861 | + || !GribbitServer.uri.getScheme().equals(originUri.getScheme()) // |
| 862 | + || !GribbitServer.uri.getHost().equals(originUri.getHost()) // |
| 863 | + || GribbitServer.uri.getPort() != originPort) { // |
| 864 | + // Reject scripted requests to open this websocket from a different domain |
| 865 | + sendHttpErrorResponse(ctx, null, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, |
| 866 | + HttpResponseStatus.FORBIDDEN)); |
| 867 | + return; |
| 868 | + } |
| 869 | + // Log.info("Origin: " + origin.toString()); |
| 870 | + |
| 871 | + if (isAuthenticatedWebsocket) { |
| 872 | + // For authenticated websockets, check if the user is logged in |
| 873 | + User loggedInUser = User.getLoggedInUser(request); |
| 874 | + if (loggedInUser == null) { |
| 875 | + // Not logged in, so can't connect to this websocket |
| 876 | + sendHttpErrorResponse(ctx, null, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, |
| 877 | + HttpResponseStatus.FORBIDDEN)); |
| 878 | + return; |
| 879 | + } |
| 880 | + |
| 881 | + // To further mitigate CSWSH attacks: check for the CSRF token in the URL parameter "_csrf"; |
| 882 | + // the passed token must match the user's CSRF token. This means the websocket URL has to |
| 883 | + // be dynamically generated and inserted into the webpage that opened the websocket. |
| 884 | + // TODO: generate this URL an insert into the page somehow |
| 885 | + String csrfTok = loggedInUser.csrfTok; |
| 886 | + if (csrfTok == null || csrfTok.isEmpty() || csrfTok.equals(CSRF.CSRF_TOKEN_UNKNOWN) |
| 887 | + || csrfTok.equals(CSRF.CSRF_TOKEN_PLACEHOLDER)) { |
| 888 | + // No valid CSRF token in User object |
| 889 | + sendHttpErrorResponse(ctx, null, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, |
| 890 | + HttpResponseStatus.FORBIDDEN)); |
| 891 | + return; |
| 892 | + } |
| 893 | + String csrfParam = request.getQueryParam("_csrf"); |
| 894 | + if (csrfParam == null || csrfParam.isEmpty() || !csrfParam.equals(csrfTok)) { |
| 895 | + // The CSRF URL query parameter is missing, or doesn't match the user's token |
| 896 | + sendHttpErrorResponse(ctx, null, new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, |
| 897 | + HttpResponseStatus.FORBIDDEN)); |
| 898 | + return; |
| 899 | + } |
| 900 | + |
| 901 | + // Record which user was authenticated when the websocket upgrade request was made. |
| 902 | + // TODO: Also provide a means for revoking user's session while WS is still open, |
| 903 | + // e.g. poll the user table every few seconds to see if user's session token has |
| 904 | + // changed in the database? (Although this would mean that logging in on a new |
| 905 | + // device would log you out of all other sessions...) |
| 906 | + wsAuthenticatedUser = loggedInUser; |
| 907 | + } |
838 | 908 |
|
839 | 909 | WebSocketServerHandshakerFactory wsFactory =
|
840 | 910 | new WebSocketServerHandshakerFactory(GribbitServer.wsUri.toString(), null, true);
|
|
0 commit comments