All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.springframework.web.socket.sockjs.support.AbstractSockJsService Maven / Gradle / Ivy

There is a newer version: 6.1.6
Show newest version
/*
 * Copyright 2002-2020 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.web.socket.sockjs.support;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Random;
import java.util.concurrent.TimeUnit;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.lang.Nullable;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.DigestUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.sockjs.SockJsException;
import org.springframework.web.socket.sockjs.SockJsService;
import org.springframework.web.util.WebUtils;

/**
 * An abstract base class for {@link SockJsService} implementations that provides SockJS
 * path resolution and handling of static SockJS requests (e.g. "/info", "/iframe.html",
 * etc). Sub-classes must handle session URLs (i.e. transport-specific requests).
 *
 * By default, only same origin requests are allowed. Use {@link #setAllowedOrigins}
 * to specify a list of allowed origins (a list containing "*" will allow all origins).
 *
 * @author Rossen Stoyanchev
 * @author Sebastien Deleuze
 * @since 4.0
 */
public abstract class AbstractSockJsService implements SockJsService, CorsConfigurationSource {

	private static final String XFRAME_OPTIONS_HEADER = "X-Frame-Options";

	private static final long ONE_YEAR = TimeUnit.DAYS.toSeconds(365);


	private static final Random random = new Random();

	protected final Log logger = LogFactory.getLog(getClass());

	private final TaskScheduler taskScheduler;

	private String name = "SockJSService@" + ObjectUtils.getIdentityHexString(this);

	private String clientLibraryUrl = "https://cdn.jsdelivr.net/sockjs/1.0.0/sockjs.min.js";

	private int streamBytesLimit = 128 * 1024;

	private boolean sessionCookieNeeded = true;

	private long heartbeatTime = TimeUnit.SECONDS.toMillis(25);

	private long disconnectDelay = TimeUnit.SECONDS.toMillis(5);

	private int httpMessageCacheSize = 100;

	private boolean webSocketEnabled = true;

	private boolean suppressCors = false;

	protected final CorsConfiguration corsConfiguration;

	private final SockJsRequestHandler infoHandler = new InfoHandler();

	private final SockJsRequestHandler iframeHandler = new IframeHandler();


	public AbstractSockJsService(TaskScheduler scheduler) {
		Assert.notNull(scheduler, "TaskScheduler must not be null");
		this.taskScheduler = scheduler;
		this.corsConfiguration = initCorsConfiguration();
	}

	private static CorsConfiguration initCorsConfiguration() {
		CorsConfiguration config = new CorsConfiguration();
		config.addAllowedMethod("*");
		config.setAllowedOrigins(Collections.emptyList());
		config.setAllowedOriginPatterns(Collections.emptyList());
		config.setAllowCredentials(true);
		config.setMaxAge(ONE_YEAR);
		config.addAllowedHeader("*");
		return config;
	}


	/**
	 * A scheduler instance to use for scheduling heart-beat messages.
	 */
	public TaskScheduler getTaskScheduler() {
		return this.taskScheduler;
	}

	/**
	 * Set a unique name for this service (mainly for logging purposes).
	 */
	public void setName(String name) {
		this.name = name;
	}

	/**
	 * Return the unique name associated with this service.
	 */
	public String getName() {
		return this.name;
	}

	/**
	 * Transports with no native cross-domain communication (e.g. "eventsource",
	 * "htmlfile") must get a simple page from the "foreign" domain in an invisible
	 * iframe so that code in the iframe can run from  a domain local to the SockJS
	 * server. Since the iframe needs to load the SockJS javascript client library,
	 * this property allows specifying where to load it from.
	 * 

By default this is set to point to * "https://cdn.jsdelivr.net/sockjs/1.0.0/sockjs.min.js". * However, it can also be set to point to a URL served by the application. *

Note that it's possible to specify a relative URL in which case the URL * must be relative to the iframe URL. For example assuming a SockJS endpoint * mapped to "/sockjs", and resulting iframe URL "/sockjs/iframe.html", then the * the relative URL must start with "../../" to traverse up to the location * above the SockJS mapping. In case of a prefix-based Servlet mapping one more * traversal may be needed. */ public void setSockJsClientLibraryUrl(String clientLibraryUrl) { this.clientLibraryUrl = clientLibraryUrl; } /** * Return he URL to the SockJS JavaScript client library. */ public String getSockJsClientLibraryUrl() { return this.clientLibraryUrl; } /** * Streaming transports save responses on the client side and don't free * memory used by delivered messages. Such transports need to recycle the * connection once in a while. This property sets a minimum number of bytes * that can be sent over a single HTTP streaming request before it will be * closed. After that client will open a new request. Setting this value to * one effectively disables streaming and will make streaming transports to * behave like polling transports. *

The default value is 128K (i.e. 128 * 1024). */ public void setStreamBytesLimit(int streamBytesLimit) { this.streamBytesLimit = streamBytesLimit; } /** * Return the minimum number of bytes that can be sent over a single HTTP * streaming request before it will be closed. */ public int getStreamBytesLimit() { return this.streamBytesLimit; } /** * The SockJS protocol requires a server to respond to an initial "/info" request from * clients with a "cookie_needed" boolean property that indicates whether the use of a * JSESSIONID cookie is required for the application to function correctly, e.g. for * load balancing or in Java Servlet containers for the use of an HTTP session. *

This is especially important for IE 8,9 that support XDomainRequest -- a modified * AJAX/XHR -- that can do requests across domains but does not send any cookies. In * those cases, the SockJS client prefers the "iframe-htmlfile" transport over * "xdr-streaming" in order to be able to send cookies. *

The SockJS protocol also expects a SockJS service to echo back the JSESSIONID * cookie when this property is set to true. However, when running in a Servlet * container this is not necessary since the container takes care of it. *

The default value is "true" to maximize the chance for applications to work * correctly in IE 8,9 with support for cookies (and the JSESSIONID cookie in * particular). However, an application can choose to set this to "false" if * the use of cookies (and HTTP session) is not required. */ public void setSessionCookieNeeded(boolean sessionCookieNeeded) { this.sessionCookieNeeded = sessionCookieNeeded; } /** * Return whether the JSESSIONID cookie is required for the application to function. */ public boolean isSessionCookieNeeded() { return this.sessionCookieNeeded; } /** * Specify the amount of time in milliseconds when the server has not sent * any messages and after which the server should send a heartbeat frame * to the client in order to keep the connection from breaking. *

The default value is 25,000 (25 seconds). */ public void setHeartbeatTime(long heartbeatTime) { this.heartbeatTime = heartbeatTime; } /** * Return the amount of time in milliseconds when the server has not sent * any messages. */ public long getHeartbeatTime() { return this.heartbeatTime; } /** * The amount of time in milliseconds before a client is considered * disconnected after not having a receiving connection, i.e. an active * connection over which the server can send data to the client. *

The default value is 5000. */ public void setDisconnectDelay(long disconnectDelay) { this.disconnectDelay = disconnectDelay; } /** * Return the amount of time in milliseconds before a client is considered disconnected. */ public long getDisconnectDelay() { return this.disconnectDelay; } /** * The number of server-to-client messages that a session can cache while waiting * for the next HTTP polling request from the client. All HTTP transports use this * property since even streaming transports recycle HTTP requests periodically. *

The amount of time between HTTP requests should be relatively brief and will * not exceed the allows disconnect delay (see {@link #setDisconnectDelay(long)}); * 5 seconds by default. *

The default size is 100. */ public void setHttpMessageCacheSize(int httpMessageCacheSize) { this.httpMessageCacheSize = httpMessageCacheSize; } /** * Return the size of the HTTP message cache. */ public int getHttpMessageCacheSize() { return this.httpMessageCacheSize; } /** * Some load balancers do not support WebSocket. This option can be used to * disable the WebSocket transport on the server side. *

The default value is "true". */ public void setWebSocketEnabled(boolean webSocketEnabled) { this.webSocketEnabled = webSocketEnabled; } /** * Return whether WebSocket transport is enabled. */ public boolean isWebSocketEnabled() { return this.webSocketEnabled; } /** * This option can be used to disable automatic addition of CORS headers for * SockJS requests. *

The default value is "false". * @since 4.1.2 */ public void setSuppressCors(boolean suppressCors) { this.suppressCors = suppressCors; } /** * Return if automatic addition of CORS headers has been disabled. * @since 4.1.2 * @see #setSuppressCors */ public boolean shouldSuppressCors() { return this.suppressCors; } /** * Configure allowed {@code Origin} header values. This check is mostly * designed for browsers. There is nothing preventing other types of client * to modify the {@code Origin} header value. *

When SockJS is enabled and origins are restricted, transport types * that do not allow to check request origin (Iframe based transports) * are disabled. As a consequence, IE 6 to 9 are not supported when origins * are restricted. *

Each provided allowed origin must have a scheme, and optionally a port * (e.g. "https://example.org", "https://example.org:9090"). An allowed origin * string may also be "*" in which case all origins are allowed. * @since 4.1.2 * @see RFC 6454: The Web Origin Concept * @see SockJS supported transports by browser */ public void setAllowedOrigins(Collection allowedOrigins) { Assert.notNull(allowedOrigins, "Allowed origins Collection must not be null"); this.corsConfiguration.setAllowedOrigins(new ArrayList<>(allowedOrigins)); } /** * Return configure allowed {@code Origin} header values. * @since 4.1.2 * @see #setAllowedOrigins */ @SuppressWarnings("ConstantConditions") public Collection getAllowedOrigins() { return this.corsConfiguration.getAllowedOrigins(); } /** * A variant of {@link #setAllowedOrigins(Collection)} that accepts flexible * domain patterns, e.g. {@code "https://*.domain1.com"}. Furthermore it * always sets the {@code Access-Control-Allow-Origin} response header to * the matched origin and never to {@code "*"}, nor to any other pattern. *

By default this is not set. * @since 5.2.3 */ public void setAllowedOriginPatterns(Collection allowedOriginPatterns) { Assert.notNull(allowedOriginPatterns, "Allowed origin patterns Collection must not be null"); this.corsConfiguration.setAllowedOriginPatterns(new ArrayList<>(allowedOriginPatterns)); } /** * Return {@link #setAllowedOriginPatterns(Collection) configured} origin patterns. * @since 5.3.2 * @see #setAllowedOriginPatterns */ @SuppressWarnings("ConstantConditions") public Collection getAllowedOriginPatterns() { return this.corsConfiguration.getAllowedOriginPatterns(); } /** * This method determines the SockJS path and handles SockJS static URLs. * Session URLs and raw WebSocket requests are delegated to abstract methods. */ @Override public final void handleRequest(ServerHttpRequest request, ServerHttpResponse response, @Nullable String sockJsPath, WebSocketHandler wsHandler) throws SockJsException { if (sockJsPath == null) { if (logger.isWarnEnabled()) { logger.warn("Expected SockJS path. Failing request: " + request.getURI()); } response.setStatusCode(HttpStatus.NOT_FOUND); return; } try { request.getHeaders(); } catch (InvalidMediaTypeException ex) { // As per SockJS protocol content-type can be ignored (it's always json) } String requestInfo = (logger.isDebugEnabled() ? request.getMethod() + " " + request.getURI() : null); try { if (sockJsPath.isEmpty() || sockJsPath.equals("/")) { if (requestInfo != null) { logger.debug("Processing transport request: " + requestInfo); } response.getHeaders().setContentType(new MediaType("text", "plain", StandardCharsets.UTF_8)); response.getBody().write("Welcome to SockJS!\n".getBytes(StandardCharsets.UTF_8)); } else if (sockJsPath.equals("/info")) { if (requestInfo != null) { logger.debug("Processing transport request: " + requestInfo); } this.infoHandler.handle(request, response); } else if (sockJsPath.matches("/iframe[0-9-.a-z_]*.html")) { if (!getAllowedOrigins().isEmpty() && !getAllowedOrigins().contains("*") || !getAllowedOriginPatterns().isEmpty()) { if (requestInfo != null) { logger.debug("Iframe support is disabled when an origin check is required. " + "Ignoring transport request: " + requestInfo); } response.setStatusCode(HttpStatus.NOT_FOUND); return; } if (getAllowedOrigins().isEmpty()) { response.getHeaders().add(XFRAME_OPTIONS_HEADER, "SAMEORIGIN"); } if (requestInfo != null) { logger.debug("Processing transport request: " + requestInfo); } this.iframeHandler.handle(request, response); } else if (sockJsPath.equals("/websocket")) { if (isWebSocketEnabled()) { if (requestInfo != null) { logger.debug("Processing transport request: " + requestInfo); } handleRawWebSocketRequest(request, response, wsHandler); } else if (requestInfo != null) { logger.debug("WebSocket disabled. Ignoring transport request: " + requestInfo); } } else { String[] pathSegments = StringUtils.tokenizeToStringArray(sockJsPath.substring(1), "/"); if (pathSegments.length != 3) { if (logger.isWarnEnabled()) { logger.warn("Invalid SockJS path '" + sockJsPath + "' - required to have 3 path segments"); } if (requestInfo != null) { logger.debug("Ignoring transport request: " + requestInfo); } response.setStatusCode(HttpStatus.NOT_FOUND); return; } String serverId = pathSegments[0]; String sessionId = pathSegments[1]; String transport = pathSegments[2]; if (!isWebSocketEnabled() && transport.equals("websocket")) { if (requestInfo != null) { logger.debug("WebSocket disabled. Ignoring transport request: " + requestInfo); } response.setStatusCode(HttpStatus.NOT_FOUND); return; } else if (!validateRequest(serverId, sessionId, transport) || !validatePath(request)) { if (requestInfo != null) { logger.debug("Ignoring transport request: " + requestInfo); } response.setStatusCode(HttpStatus.NOT_FOUND); return; } if (requestInfo != null) { logger.debug("Processing transport request: " + requestInfo); } handleTransportRequest(request, response, wsHandler, sessionId, transport); } response.close(); } catch (IOException ex) { throw new SockJsException("Failed to write to the response", null, ex); } } protected boolean validateRequest(String serverId, String sessionId, String transport) { if (!StringUtils.hasText(serverId) || !StringUtils.hasText(sessionId) || !StringUtils.hasText(transport)) { logger.warn("No server, session, or transport path segment in SockJS request."); return false; } // Server and session id's must not contain "." if (serverId.contains(".") || sessionId.contains(".")) { logger.warn("Either server or session contains a \".\" which is not allowed by SockJS protocol."); return false; } return true; } /** * Ensure the path does not contain a file extension, either in the filename * (e.g. "/jsonp.bat") or possibly after path parameters ("/jsonp;Setup.bat") * which could be used for RFD exploits. *

Since the last part of the path is expected to be a transport type, the * presence of an extension would not work. All we need to do is check if * there are any path parameters, which would have been removed from the * SockJS path during request mapping, and if found reject the request. */ private boolean validatePath(ServerHttpRequest request) { String path = request.getURI().getPath(); int index = path.lastIndexOf('/') + 1; return (path.indexOf(';', index) == -1); } protected boolean checkOrigin(ServerHttpRequest request, ServerHttpResponse response, HttpMethod... httpMethods) throws IOException { if (WebUtils.isSameOrigin(request)) { return true; } if (this.corsConfiguration.checkOrigin(request.getHeaders().getOrigin()) == null) { if (logger.isWarnEnabled()) { logger.warn("Origin header value '" + request.getHeaders().getOrigin() + "' not allowed."); } response.setStatusCode(HttpStatus.FORBIDDEN); return false; } return true; } @Override @Nullable public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { if (!this.suppressCors && (request.getHeader(HttpHeaders.ORIGIN) != null)) { return this.corsConfiguration; } return null; } protected void addCacheHeaders(ServerHttpResponse response) { response.getHeaders().setCacheControl("public, max-age=" + ONE_YEAR); response.getHeaders().setExpires(System.currentTimeMillis() + ONE_YEAR * 1000); } protected void addNoCacheHeaders(ServerHttpResponse response) { response.getHeaders().setCacheControl("no-store, no-cache, must-revalidate, max-age=0"); } protected void sendMethodNotAllowed(ServerHttpResponse response, HttpMethod... httpMethods) { logger.warn("Sending Method Not Allowed (405)"); response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED); response.getHeaders().setAllow(new LinkedHashSet<>(Arrays.asList(httpMethods))); } /** * Handle request for raw WebSocket communication, i.e. without any SockJS message framing. */ protected abstract void handleRawWebSocketRequest(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler webSocketHandler) throws IOException; /** * Handle a SockJS session URL (i.e. transport-specific request). */ protected abstract void handleTransportRequest(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler webSocketHandler, String sessionId, String transport) throws SockJsException; private interface SockJsRequestHandler { void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException; } private class InfoHandler implements SockJsRequestHandler { private static final String INFO_CONTENT = "{\"entropy\":%s,\"origins\":[\"*:*\"],\"cookie_needed\":%s,\"websocket\":%s}"; @Override public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException { if (request.getMethod() == HttpMethod.GET) { addNoCacheHeaders(response); if (checkOrigin(request, response)) { response.getHeaders().setContentType(new MediaType("application", "json", StandardCharsets.UTF_8)); String content = String.format( INFO_CONTENT, random.nextInt(), isSessionCookieNeeded(), isWebSocketEnabled()); response.getBody().write(content.getBytes()); } } else if (request.getMethod() == HttpMethod.OPTIONS) { if (checkOrigin(request, response)) { addCacheHeaders(response); response.setStatusCode(HttpStatus.NO_CONTENT); } } else { sendMethodNotAllowed(response, HttpMethod.GET, HttpMethod.OPTIONS); } } } private class IframeHandler implements SockJsRequestHandler { private static final String IFRAME_CONTENT = "\n" + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + "\n" + "\n" + "

Don't panic!

\n" + "

This is a SockJS hidden iframe. It's used for cross domain magic.

\n" + "\n" + ""; @Override public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException { if (request.getMethod() != HttpMethod.GET) { sendMethodNotAllowed(response, HttpMethod.GET); return; } String content = String.format(IFRAME_CONTENT, getSockJsClientLibraryUrl()); byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); StringBuilder builder = new StringBuilder("\"0"); DigestUtils.appendMd5DigestAsHex(contentBytes, builder); builder.append('"'); String etagValue = builder.toString(); List ifNoneMatch = request.getHeaders().getIfNoneMatch(); if (!CollectionUtils.isEmpty(ifNoneMatch) && ifNoneMatch.get(0).equals(etagValue)) { response.setStatusCode(HttpStatus.NOT_MODIFIED); return; } response.getHeaders().setContentType(new MediaType("text", "html", StandardCharsets.UTF_8)); response.getHeaders().setContentLength(contentBytes.length); // No cache in order to check every time if IFrame are authorized addNoCacheHeaders(response); response.getHeaders().setETag(etagValue); response.getBody().write(contentBytes); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy