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