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}