001/*
002 * Copyright 2002-2019 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.web.socket.sockjs.support;
018
019import java.io.IOException;
020import java.nio.charset.StandardCharsets;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.Date;
026import java.util.LinkedHashSet;
027import java.util.List;
028import java.util.Random;
029import java.util.Set;
030import java.util.concurrent.TimeUnit;
031
032import javax.servlet.http.HttpServletRequest;
033
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036
037import org.springframework.http.HttpHeaders;
038import org.springframework.http.HttpMethod;
039import org.springframework.http.HttpStatus;
040import org.springframework.http.InvalidMediaTypeException;
041import org.springframework.http.MediaType;
042import org.springframework.http.server.ServerHttpRequest;
043import org.springframework.http.server.ServerHttpResponse;
044import org.springframework.lang.Nullable;
045import org.springframework.scheduling.TaskScheduler;
046import org.springframework.util.Assert;
047import org.springframework.util.CollectionUtils;
048import org.springframework.util.DigestUtils;
049import org.springframework.util.ObjectUtils;
050import org.springframework.util.StringUtils;
051import org.springframework.web.cors.CorsConfiguration;
052import org.springframework.web.cors.CorsConfigurationSource;
053import org.springframework.web.socket.WebSocketHandler;
054import org.springframework.web.socket.sockjs.SockJsException;
055import org.springframework.web.socket.sockjs.SockJsService;
056import org.springframework.web.util.WebUtils;
057
058/**
059 * An abstract base class for {@link SockJsService} implementations that provides SockJS
060 * path resolution and handling of static SockJS requests (e.g. "/info", "/iframe.html",
061 * etc). Sub-classes must handle session URLs (i.e. transport-specific requests).
062 *
063 * By default, only same origin requests are allowed. Use {@link #setAllowedOrigins}
064 * to specify a list of allowed origins (a list containing "*" will allow all origins).
065 *
066 * @author Rossen Stoyanchev
067 * @author Sebastien Deleuze
068 * @since 4.0
069 */
070public abstract class AbstractSockJsService implements SockJsService, CorsConfigurationSource {
071
072        private static final String XFRAME_OPTIONS_HEADER = "X-Frame-Options";
073
074        private static final long ONE_YEAR = TimeUnit.DAYS.toSeconds(365);
075
076
077        private static final Random random = new Random();
078
079        protected final Log logger = LogFactory.getLog(getClass());
080
081        private final TaskScheduler taskScheduler;
082
083        private String name = "SockJSService@" + ObjectUtils.getIdentityHexString(this);
084
085        private String clientLibraryUrl = "https://cdn.jsdelivr.net/sockjs/1.0.0/sockjs.min.js";
086
087        private int streamBytesLimit = 128 * 1024;
088
089        private boolean sessionCookieNeeded = true;
090
091        private long heartbeatTime = TimeUnit.SECONDS.toMillis(25);
092
093        private long disconnectDelay = TimeUnit.SECONDS.toMillis(5);
094
095        private int httpMessageCacheSize = 100;
096
097        private boolean webSocketEnabled = true;
098
099        private boolean suppressCors = false;
100
101        protected final Set<String> allowedOrigins = new LinkedHashSet<>();
102
103        private final SockJsRequestHandler infoHandler = new InfoHandler();
104
105        private final SockJsRequestHandler iframeHandler = new IframeHandler();
106
107
108        public AbstractSockJsService(TaskScheduler scheduler) {
109                Assert.notNull(scheduler, "TaskScheduler must not be null");
110                this.taskScheduler = scheduler;
111        }
112
113
114        /**
115         * A scheduler instance to use for scheduling heart-beat messages.
116         */
117        public TaskScheduler getTaskScheduler() {
118                return this.taskScheduler;
119        }
120
121        /**
122         * Set a unique name for this service (mainly for logging purposes).
123         */
124        public void setName(String name) {
125                this.name = name;
126        }
127
128        /**
129         * Return the unique name associated with this service.
130         */
131        public String getName() {
132                return this.name;
133        }
134
135        /**
136         * Transports with no native cross-domain communication (e.g. "eventsource",
137         * "htmlfile") must get a simple page from the "foreign" domain in an invisible
138         * iframe so that code in the iframe can run from  a domain local to the SockJS
139         * server. Since the iframe needs to load the SockJS javascript client library,
140         * this property allows specifying where to load it from.
141         * <p>By default this is set to point to
142         * "https://cdn.jsdelivr.net/sockjs/1.0.0/sockjs.min.js".
143         * However, it can also be set to point to a URL served by the application.
144         * <p>Note that it's possible to specify a relative URL in which case the URL
145         * must be relative to the iframe URL. For example assuming a SockJS endpoint
146         * mapped to "/sockjs", and resulting iframe URL "/sockjs/iframe.html", then the
147         * the relative URL must start with "../../" to traverse up to the location
148         * above the SockJS mapping. In case of a prefix-based Servlet mapping one more
149         * traversal may be needed.
150         */
151        public void setSockJsClientLibraryUrl(String clientLibraryUrl) {
152                this.clientLibraryUrl = clientLibraryUrl;
153        }
154
155        /**
156         * Return he URL to the SockJS JavaScript client library.
157         */
158        public String getSockJsClientLibraryUrl() {
159                return this.clientLibraryUrl;
160        }
161
162        /**
163         * Streaming transports save responses on the client side and don't free
164         * memory used by delivered messages. Such transports need to recycle the
165         * connection once in a while. This property sets a minimum number of bytes
166         * that can be sent over a single HTTP streaming request before it will be
167         * closed. After that client will open a new request. Setting this value to
168         * one effectively disables streaming and will make streaming transports to
169         * behave like polling transports.
170         * <p>The default value is 128K (i.e. 128 * 1024).
171         */
172        public void setStreamBytesLimit(int streamBytesLimit) {
173                this.streamBytesLimit = streamBytesLimit;
174        }
175
176        /**
177         * Return the minimum number of bytes that can be sent over a single HTTP
178         * streaming request before it will be closed.
179         */
180        public int getStreamBytesLimit() {
181                return this.streamBytesLimit;
182        }
183
184        /**
185         * The SockJS protocol requires a server to respond to an initial "/info" request from
186         * clients with a "cookie_needed" boolean property that indicates whether the use of a
187         * JSESSIONID cookie is required for the application to function correctly, e.g. for
188         * load balancing or in Java Servlet containers for the use of an HTTP session.
189         * <p>This is especially important for IE 8,9 that support XDomainRequest -- a modified
190         * AJAX/XHR -- that can do requests across domains but does not send any cookies. In
191         * those cases, the SockJS client prefers the "iframe-htmlfile" transport over
192         * "xdr-streaming" in order to be able to send cookies.
193         * <p>The SockJS protocol also expects a SockJS service to echo back the JSESSIONID
194         * cookie when this property is set to true. However, when running in a Servlet
195         * container this is not necessary since the container takes care of it.
196         * <p>The default value is "true" to maximize the chance for applications to work
197         * correctly in IE 8,9 with support for cookies (and the JSESSIONID cookie in
198         * particular). However, an application can choose to set this to "false" if
199         * the use of cookies (and HTTP session) is not required.
200         */
201        public void setSessionCookieNeeded(boolean sessionCookieNeeded) {
202                this.sessionCookieNeeded = sessionCookieNeeded;
203        }
204
205        /**
206         * Return whether the JSESSIONID cookie is required for the application to function.
207         */
208        public boolean isSessionCookieNeeded() {
209                return this.sessionCookieNeeded;
210        }
211
212        /**
213         * Specify the amount of time in milliseconds when the server has not sent
214         * any messages and after which the server should send a heartbeat frame
215         * to the client in order to keep the connection from breaking.
216         * <p>The default value is 25,000 (25 seconds).
217         */
218        public void setHeartbeatTime(long heartbeatTime) {
219                this.heartbeatTime = heartbeatTime;
220        }
221
222        /**
223         * Return the amount of time in milliseconds when the server has not sent
224         * any messages.
225         */
226        public long getHeartbeatTime() {
227                return this.heartbeatTime;
228        }
229
230        /**
231         * The amount of time in milliseconds before a client is considered
232         * disconnected after not having a receiving connection, i.e. an active
233         * connection over which the server can send data to the client.
234         * <p>The default value is 5000.
235         */
236        public void setDisconnectDelay(long disconnectDelay) {
237                this.disconnectDelay = disconnectDelay;
238        }
239
240        /**
241         * Return the amount of time in milliseconds before a client is considered disconnected.
242         */
243        public long getDisconnectDelay() {
244                return this.disconnectDelay;
245        }
246
247        /**
248         * The number of server-to-client messages that a session can cache while waiting
249         * for the next HTTP polling request from the client. All HTTP transports use this
250         * property since even streaming transports recycle HTTP requests periodically.
251         * <p>The amount of time between HTTP requests should be relatively brief and will
252         * not exceed the allows disconnect delay (see {@link #setDisconnectDelay(long)});
253         * 5 seconds by default.
254         * <p>The default size is 100.
255         */
256        public void setHttpMessageCacheSize(int httpMessageCacheSize) {
257                this.httpMessageCacheSize = httpMessageCacheSize;
258        }
259
260        /**
261         * Return the size of the HTTP message cache.
262         */
263        public int getHttpMessageCacheSize() {
264                return this.httpMessageCacheSize;
265        }
266
267        /**
268         * Some load balancers do not support WebSocket. This option can be used to
269         * disable the WebSocket transport on the server side.
270         * <p>The default value is "true".
271         */
272        public void setWebSocketEnabled(boolean webSocketEnabled) {
273                this.webSocketEnabled = webSocketEnabled;
274        }
275
276        /**
277         * Return whether WebSocket transport is enabled.
278         */
279        public boolean isWebSocketEnabled() {
280                return this.webSocketEnabled;
281        }
282
283        /**
284         * This option can be used to disable automatic addition of CORS headers for
285         * SockJS requests.
286         * <p>The default value is "false".
287         * @since 4.1.2
288         */
289        public void setSuppressCors(boolean suppressCors) {
290                this.suppressCors = suppressCors;
291        }
292
293        /**
294         * Return if automatic addition of CORS headers has been disabled.
295         * @since 4.1.2
296         * @see #setSuppressCors
297         */
298        public boolean shouldSuppressCors() {
299                return this.suppressCors;
300        }
301
302        /**
303         * Configure allowed {@code Origin} header values. This check is mostly
304         * designed for browsers. There is nothing preventing other types of client
305         * to modify the {@code Origin} header value.
306         * <p>When SockJS is enabled and origins are restricted, transport types
307         * that do not allow to check request origin (Iframe based transports)
308         * are disabled. As a consequence, IE 6 to 9 are not supported when origins
309         * are restricted.
310         * <p>Each provided allowed origin must have a scheme, and optionally a port
311         * (e.g. "https://example.org", "https://example.org:9090"). An allowed origin
312         * string may also be "*" in which case all origins are allowed.
313         * @since 4.1.2
314         * @see <a href="https://tools.ietf.org/html/rfc6454">RFC 6454: The Web Origin Concept</a>
315         * @see <a href="https://github.com/sockjs/sockjs-client#supported-transports-by-browser-html-served-from-http-or-https">SockJS supported transports by browser</a>
316         */
317        public void setAllowedOrigins(Collection<String> allowedOrigins) {
318                Assert.notNull(allowedOrigins, "Allowed origins Collection must not be null");
319                this.allowedOrigins.clear();
320                this.allowedOrigins.addAll(allowedOrigins);
321        }
322
323        /**
324         * Return configure allowed {@code Origin} header values.
325         * @since 4.1.2
326         * @see #setAllowedOrigins
327         */
328        public Collection<String> getAllowedOrigins() {
329                return Collections.unmodifiableSet(this.allowedOrigins);
330        }
331
332
333        /**
334         * This method determines the SockJS path and handles SockJS static URLs.
335         * Session URLs and raw WebSocket requests are delegated to abstract methods.
336         */
337        @Override
338        public final void handleRequest(ServerHttpRequest request, ServerHttpResponse response,
339                        @Nullable String sockJsPath, WebSocketHandler wsHandler) throws SockJsException {
340
341                if (sockJsPath == null) {
342                        if (logger.isWarnEnabled()) {
343                                logger.warn("Expected SockJS path. Failing request: " + request.getURI());
344                        }
345                        response.setStatusCode(HttpStatus.NOT_FOUND);
346                        return;
347                }
348
349                try {
350                        request.getHeaders();
351                }
352                catch (InvalidMediaTypeException ex) {
353                        // As per SockJS protocol content-type can be ignored (it's always json)
354                }
355
356                String requestInfo = (logger.isDebugEnabled() ? request.getMethod() + " " + request.getURI() : null);
357
358                try {
359                        if (sockJsPath.isEmpty() || sockJsPath.equals("/")) {
360                                if (requestInfo != null) {
361                                        logger.debug("Processing transport request: " + requestInfo);
362                                }
363                                response.getHeaders().setContentType(new MediaType("text", "plain", StandardCharsets.UTF_8));
364                                response.getBody().write("Welcome to SockJS!\n".getBytes(StandardCharsets.UTF_8));
365                        }
366
367                        else if (sockJsPath.equals("/info")) {
368                                if (requestInfo != null) {
369                                        logger.debug("Processing transport request: " + requestInfo);
370                                }
371                                this.infoHandler.handle(request, response);
372                        }
373
374                        else if (sockJsPath.matches("/iframe[0-9-.a-z_]*.html")) {
375                                if (!this.allowedOrigins.isEmpty() && !this.allowedOrigins.contains("*")) {
376                                        if (requestInfo != null) {
377                                                logger.debug("Iframe support is disabled when an origin check is required. " +
378                                                                "Ignoring transport request: " + requestInfo);
379                                        }
380                                        response.setStatusCode(HttpStatus.NOT_FOUND);
381                                        return;
382                                }
383                                if (this.allowedOrigins.isEmpty()) {
384                                        response.getHeaders().add(XFRAME_OPTIONS_HEADER, "SAMEORIGIN");
385                                }
386                                if (requestInfo != null) {
387                                        logger.debug("Processing transport request: " + requestInfo);
388                                }
389                                this.iframeHandler.handle(request, response);
390                        }
391
392                        else if (sockJsPath.equals("/websocket")) {
393                                if (isWebSocketEnabled()) {
394                                        if (requestInfo != null) {
395                                                logger.debug("Processing transport request: " + requestInfo);
396                                        }
397                                        handleRawWebSocketRequest(request, response, wsHandler);
398                                }
399                                else if (requestInfo != null) {
400                                        logger.debug("WebSocket disabled. Ignoring transport request: " + requestInfo);
401                                }
402                        }
403
404                        else {
405                                String[] pathSegments = StringUtils.tokenizeToStringArray(sockJsPath.substring(1), "/");
406                                if (pathSegments.length != 3) {
407                                        if (logger.isWarnEnabled()) {
408                                                logger.warn("Invalid SockJS path '" + sockJsPath + "' - required to have 3 path segments");
409                                        }
410                                        if (requestInfo != null) {
411                                                logger.debug("Ignoring transport request: " + requestInfo);
412                                        }
413                                        response.setStatusCode(HttpStatus.NOT_FOUND);
414                                        return;
415                                }
416
417                                String serverId = pathSegments[0];
418                                String sessionId = pathSegments[1];
419                                String transport = pathSegments[2];
420
421                                if (!isWebSocketEnabled() && transport.equals("websocket")) {
422                                        if (requestInfo != null) {
423                                                logger.debug("WebSocket disabled. Ignoring transport request: " + requestInfo);
424                                        }
425                                        response.setStatusCode(HttpStatus.NOT_FOUND);
426                                        return;
427                                }
428                                else if (!validateRequest(serverId, sessionId, transport) || !validatePath(request)) {
429                                        if (requestInfo != null) {
430                                                logger.debug("Ignoring transport request: " + requestInfo);
431                                        }
432                                        response.setStatusCode(HttpStatus.NOT_FOUND);
433                                        return;
434                                }
435
436                                if (requestInfo != null) {
437                                        logger.debug("Processing transport request: " + requestInfo);
438                                }
439                                handleTransportRequest(request, response, wsHandler, sessionId, transport);
440                        }
441                        response.close();
442                }
443                catch (IOException ex) {
444                        throw new SockJsException("Failed to write to the response", null, ex);
445                }
446        }
447
448        protected boolean validateRequest(String serverId, String sessionId, String transport) {
449                if (!StringUtils.hasText(serverId) || !StringUtils.hasText(sessionId) || !StringUtils.hasText(transport)) {
450                        logger.warn("No server, session, or transport path segment in SockJS request.");
451                        return false;
452                }
453
454                // Server and session id's must not contain "."
455                if (serverId.contains(".") || sessionId.contains(".")) {
456                        logger.warn("Either server or session contains a \".\" which is not allowed by SockJS protocol.");
457                        return false;
458                }
459
460                return true;
461        }
462
463        /**
464         * Ensure the path does not contain a file extension, either in the filename
465         * (e.g. "/jsonp.bat") or possibly after path parameters ("/jsonp;Setup.bat")
466         * which could be used for RFD exploits.
467         * <p>Since the last part of the path is expected to be a transport type, the
468         * presence of an extension would not work. All we need to do is check if
469         * there are any path parameters, which would have been removed from the
470         * SockJS path during request mapping, and if found reject the request.
471         */
472        private boolean validatePath(ServerHttpRequest request) {
473                String path = request.getURI().getPath();
474                int index = path.lastIndexOf('/') + 1;
475                return (path.indexOf(';', index) == -1);
476        }
477
478        protected boolean checkOrigin(ServerHttpRequest request, ServerHttpResponse response, HttpMethod... httpMethods)
479                        throws IOException {
480
481                if (WebUtils.isSameOrigin(request)) {
482                        return true;
483                }
484
485                if (!WebUtils.isValidOrigin(request, this.allowedOrigins)) {
486                        if (logger.isWarnEnabled()) {
487                                logger.warn("Origin header value '" + request.getHeaders().getOrigin() + "' not allowed.");
488                        }
489                        response.setStatusCode(HttpStatus.FORBIDDEN);
490                        return false;
491                }
492
493                return true;
494        }
495
496        @Override
497        @Nullable
498        public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
499                if (!this.suppressCors && (request.getHeader(HttpHeaders.ORIGIN) != null)) {
500                        CorsConfiguration config = new CorsConfiguration();
501                        config.setAllowedOrigins(new ArrayList<>(this.allowedOrigins));
502                        config.addAllowedMethod("*");
503                        config.setAllowCredentials(true);
504                        config.setMaxAge(ONE_YEAR);
505                        config.addAllowedHeader("*");
506                        return config;
507                }
508                return null;
509        }
510
511        protected void addCacheHeaders(ServerHttpResponse response) {
512                response.getHeaders().setCacheControl("public, max-age=" + ONE_YEAR);
513                response.getHeaders().setExpires(new Date().getTime() + ONE_YEAR * 1000);
514        }
515
516        protected void addNoCacheHeaders(ServerHttpResponse response) {
517                response.getHeaders().setCacheControl("no-store, no-cache, must-revalidate, max-age=0");
518        }
519
520        protected void sendMethodNotAllowed(ServerHttpResponse response, HttpMethod... httpMethods) {
521                logger.warn("Sending Method Not Allowed (405)");
522                response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED);
523                response.getHeaders().setAllow(new LinkedHashSet<>(Arrays.asList(httpMethods)));
524        }
525
526
527        /**
528         * Handle request for raw WebSocket communication, i.e. without any SockJS message framing.
529         */
530        protected abstract void handleRawWebSocketRequest(ServerHttpRequest request,
531                        ServerHttpResponse response, WebSocketHandler webSocketHandler) throws IOException;
532
533        /**
534         * Handle a SockJS session URL (i.e. transport-specific request).
535         */
536        protected abstract void handleTransportRequest(ServerHttpRequest request, ServerHttpResponse response,
537                        WebSocketHandler webSocketHandler, String sessionId, String transport) throws SockJsException;
538
539
540        private interface SockJsRequestHandler {
541
542                void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException;
543        }
544
545
546        private class InfoHandler implements SockJsRequestHandler {
547
548                private static final String INFO_CONTENT =
549                                "{\"entropy\":%s,\"origins\":[\"*:*\"],\"cookie_needed\":%s,\"websocket\":%s}";
550
551                @Override
552                public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
553                        if (request.getMethod() == HttpMethod.GET) {
554                                addNoCacheHeaders(response);
555                                if (checkOrigin(request, response)) {
556                                        response.getHeaders().setContentType(new MediaType("application", "json", StandardCharsets.UTF_8));
557                                        String content = String.format(
558                                                        INFO_CONTENT, random.nextInt(), isSessionCookieNeeded(), isWebSocketEnabled());
559                                        response.getBody().write(content.getBytes());
560                                }
561
562                        }
563                        else if (request.getMethod() == HttpMethod.OPTIONS) {
564                                if (checkOrigin(request, response)) {
565                                        addCacheHeaders(response);
566                                        response.setStatusCode(HttpStatus.NO_CONTENT);
567                                }
568                        }
569                        else {
570                                sendMethodNotAllowed(response, HttpMethod.GET, HttpMethod.OPTIONS);
571                        }
572                }
573        }
574
575
576        private class IframeHandler implements SockJsRequestHandler {
577
578                private static final String IFRAME_CONTENT =
579                                "<!DOCTYPE html>\n" +
580                                "<html>\n" +
581                                "<head>\n" +
582                                "  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n" +
583                                "  <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\" />\n" +
584                                "  <script>\n" +
585                                "    document.domain = document.domain;\n" +
586                                "    _sockjs_onload = function(){SockJS.bootstrap_iframe();};\n" +
587                                "  </script>\n" +
588                                "  <script src=\"%s\"></script>\n" +
589                                "</head>\n" +
590                                "<body>\n" +
591                                "  <h2>Don't panic!</h2>\n" +
592                                "  <p>This is a SockJS hidden iframe. It's used for cross domain magic.</p>\n" +
593                                "</body>\n" +
594                                "</html>";
595
596                @Override
597                public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
598                        if (request.getMethod() != HttpMethod.GET) {
599                                sendMethodNotAllowed(response, HttpMethod.GET);
600                                return;
601                        }
602
603                        String content = String.format(IFRAME_CONTENT, getSockJsClientLibraryUrl());
604                        byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
605                        StringBuilder builder = new StringBuilder("\"0");
606                        DigestUtils.appendMd5DigestAsHex(contentBytes, builder);
607                        builder.append('"');
608                        String etagValue = builder.toString();
609
610                        List<String> ifNoneMatch = request.getHeaders().getIfNoneMatch();
611                        if (!CollectionUtils.isEmpty(ifNoneMatch) && ifNoneMatch.get(0).equals(etagValue)) {
612                                response.setStatusCode(HttpStatus.NOT_MODIFIED);
613                                return;
614                        }
615
616                        response.getHeaders().setContentType(new MediaType("text", "html", StandardCharsets.UTF_8));
617                        response.getHeaders().setContentLength(contentBytes.length);
618
619                        // No cache in order to check every time if IFrame are authorized
620                        addNoCacheHeaders(response);
621                        response.getHeaders().setETag(etagValue);
622                        response.getBody().write(contentBytes);
623                }
624        }
625
626}