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}