Commit 207347e1 authored by Rob Winch's avatar Rob Winch Committed by Phillip Webb

Add header based remote access security

Update the remote endpoints to use 'shared secret' authentication.
Secrets are provided as Environment properties and transfered using a
custom HTTP header.

See gh-3082
parent fe4c0022
......@@ -34,6 +34,7 @@ import org.springframework.boot.developertools.remote.server.Dispatcher;
import org.springframework.boot.developertools.remote.server.DispatcherFilter;
import org.springframework.boot.developertools.remote.server.Handler;
import org.springframework.boot.developertools.remote.server.HandlerMapper;
import org.springframework.boot.developertools.remote.server.HttpHeaderAccessManager;
import org.springframework.boot.developertools.remote.server.HttpStatusHandler;
import org.springframework.boot.developertools.remote.server.UrlHandlerMapper;
import org.springframework.boot.developertools.restart.server.DefaultSourceFolderUrlFilter;
......@@ -56,7 +57,7 @@ import org.springframework.http.server.ServerHttpRequest;
* @since 1.3.0
*/
@Configuration
@ConditionalOnProperty(prefix = "spring.developertools.remote", name = "enabled")
@ConditionalOnProperty(prefix = "spring.developertools.remote", name = "secret")
@ConditionalOnClass({ Filter.class, ServerHttpRequest.class })
@EnableConfigurationProperties(DeveloperToolsProperties.class)
public class RemoteDeveloperToolsAutoConfiguration {
......@@ -67,6 +68,14 @@ public class RemoteDeveloperToolsAutoConfiguration {
@Autowired
private DeveloperToolsProperties properties;
@Bean
@ConditionalOnMissingBean
public AccessManager remoteDeveloperToolsAccessManager() {
RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote();
return new HttpHeaderAccessManager(remoteProperties.getSecretHeaderName(),
remoteProperties.getSecret());
}
@Bean
public HandlerMapper remoteDeveloperToolsHealthCheckHandlerMapper() {
Handler handler = new HttpStatusHandler();
......@@ -76,8 +85,8 @@ public class RemoteDeveloperToolsAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public DispatcherFilter remoteDeveloperToolsDispatcherFilter(
Collection<HandlerMapper> mappers) {
Dispatcher dispatcher = new Dispatcher(AccessManager.PERMIT_ALL, mappers);
AccessManager accessManager, Collection<HandlerMapper> mappers) {
Dispatcher dispatcher = new Dispatcher(accessManager, mappers);
return new DispatcherFilter(dispatcher);
}
......
......@@ -28,11 +28,24 @@ public class RemoteDeveloperToolsProperties {
public static final String DEFAULT_CONTEXT_PATH = "/.~~spring-boot!~";
public static final String DEFAULT_SECRET_HEADER_NAME = "X-AUTH-TOKEN";
/**
* Context path used to handle the remote connection.
*/
private String contextPath = DEFAULT_CONTEXT_PATH;
/**
* A shared secret required to establish a connection (required to enable remote
* support).
*/
private String secret;
/**
* HTTP header used to transfer the shared secret.
*/
private String secretHeaderName = DEFAULT_SECRET_HEADER_NAME;
private Restart restart = new Restart();
private Debug debug = new Debug();
......@@ -45,6 +58,22 @@ public class RemoteDeveloperToolsProperties {
this.contextPath = contextPath;
}
public String getSecret() {
return this.secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public String getSecretHeaderName() {
return this.secretHeaderName;
}
public void setSecretHeaderName(String secretHeaderName) {
this.secretHeaderName = secretHeaderName;
}
public Restart getRestart() {
return this.restart;
}
......
/*
* Copyright 2012-2015 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
*
* http://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.boot.developertools.remote.client;
import java.io.IOException;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.Assert;
/**
* {@link ClientHttpRequestInterceptor} to populate arbitrary HTTP headers with a value.
* For example, it might be used to provide an X-AUTH-TOKEN and value for security
* purposes.
*
* @author Rob Winch
* @since 1.3.0
*/
public class HttpHeaderInterceptor implements ClientHttpRequestInterceptor {
private final String name;
private final String value;
/**
* Creates a new {@link HttpHeaderInterceptor} instance.
* @param name the header name to populate. Cannot be null or empty.
* @param value the header value to populate. Cannot be null or empty.
*/
public HttpHeaderInterceptor(String name, String value) {
Assert.hasLength(name, "Name must not be empty");
Assert.hasLength(value, "Value" + " must not be empty");
this.name = name;
this.value = value;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add(this.name, this.value);
return execution.execute(request, body);
}
}
......@@ -17,6 +17,8 @@
package org.springframework.boot.developertools.remote.client;
import java.net.URL;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
......@@ -51,7 +53,10 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.InterceptingClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.util.Assert;
/**
* Configuration used to connect to remote Spring Boot applications.
......@@ -79,7 +84,20 @@ public class RemoteClientConfiguration {
@Bean
public ClientHttpRequestFactory clientHttpRequestFactory() {
return new SimpleClientHttpRequestFactory();
List<ClientHttpRequestInterceptor> interceptors = Arrays
.asList(getSecurityInterceptor());
return new InterceptingClientHttpRequestFactory(
new SimpleClientHttpRequestFactory(), interceptors);
}
private ClientHttpRequestInterceptor getSecurityInterceptor() {
RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote();
String secretHeaderName = remoteProperties.getSecretHeaderName();
String secret = remoteProperties.getSecret();
Assert.state(secret != null,
"The environment value 'spring.developertools.remote.secret' "
+ "is required to secure your connection.");
return new HttpHeaderInterceptor(secretHeaderName, secret);
}
@PostConstruct
......
/*
* Copyright 2012-2015 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
*
* http://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.boot.developertools.remote.server;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.util.Assert;
/**
* {@link AccessManager} that checks for the presence of a HTTP header secret.
*
* @author Rob Winch
* @author Phillip Webb
* @since 1.3.0
*/
public class HttpHeaderAccessManager implements AccessManager {
private final String headerName;
private final String expectedSecret;
public HttpHeaderAccessManager(String headerName, String expectedSecret) {
Assert.hasLength(headerName, "HeaderName must not be empty");
Assert.hasLength(expectedSecret, "ExpectedSecret must not be empty");
this.headerName = headerName;
this.expectedSecret = expectedSecret;
}
@Override
public boolean isAllowed(ServerHttpRequest request) {
String providedSecret = request.getHeaders().getFirst(this.headerName);
return this.expectedSecret.equals(providedSecret);
}
}
......@@ -60,6 +60,8 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
private static final String DEFAULT_CONTEXT_PATH = RemoteDeveloperToolsProperties.DEFAULT_CONTEXT_PATH;
private static final String DEFAULT_SECRET_HEADER_NAME = RemoteDeveloperToolsProperties.DEFAULT_SECRET_HEADER_NAME;
@Rule
public MockRestarter mockRestarter = new MockRestarter();
......@@ -88,27 +90,55 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
}
}
@Test
public void disabledIfRemoteSecretIsMissing() throws Exception {
loadContext("a:b");
this.thrown.expect(NoSuchBeanDefinitionException.class);
this.context.getBean(DispatcherFilter.class);
}
@Test
public void ignoresUnmappedUrl() throws Exception {
loadContext("spring.developertools.remote.enabled:true");
loadContext("spring.developertools.remote.secret:supersecret");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI("/restart");
this.request.addHeader(DEFAULT_SECRET_HEADER_NAME, "supersecret");
filter.doFilter(this.request, this.response, this.chain);
assertRestartInvoked(false);
}
@Test
public void ignoresIfMissingSecretFromRequest() throws Exception {
loadContext("spring.developertools.remote.secret:supersecret");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH + "/restart");
filter.doFilter(this.request, this.response, this.chain);
assertRestartInvoked(false);
}
@Test
public void ignoresInvalidSecretInRequest() throws Exception {
loadContext("spring.developertools.remote.secret:supersecret");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH + "/restart");
this.request.addHeader(DEFAULT_SECRET_HEADER_NAME, "invalid");
filter.doFilter(this.request, this.response, this.chain);
assertRestartInvoked(false);
}
@Test
public void invokeRestartWithDefaultSetup() throws Exception {
loadContext("spring.developertools.remote.enabled:true");
loadContext("spring.developertools.remote.secret:supersecret");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH + "/restart");
this.request.addHeader(DEFAULT_SECRET_HEADER_NAME, "supersecret");
filter.doFilter(this.request, this.response, this.chain);
assertRestartInvoked(true);
}
@Test
public void disableRestart() throws Exception {
loadContext("spring.developertools.remote.enabled:true",
loadContext("spring.developertools.remote.secret:supersecret",
"spring.developertools.remote.restart.enabled:false");
this.thrown.expect(NoSuchBeanDefinitionException.class);
this.context.getBean("remoteRestartHanderMapper");
......@@ -116,16 +146,28 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
@Test
public void invokeTunnelWithDefaultSetup() throws Exception {
loadContext("spring.developertools.remote.enabled:true");
loadContext("spring.developertools.remote.secret:supersecret");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH + "/debug");
this.request.addHeader(DEFAULT_SECRET_HEADER_NAME, "supersecret");
filter.doFilter(this.request, this.response, this.chain);
assertTunnelInvoked(true);
}
@Test
public void invokeTunnelWithCustomHeaderName() throws Exception {
loadContext("spring.developertools.remote.secret:supersecret",
"spring.developertools.remote.secretHeaderName:customheader");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH + "/debug");
this.request.addHeader("customheader", "supersecret");
filter.doFilter(this.request, this.response, this.chain);
assertTunnelInvoked(true);
}
@Test
public void disableRemoteDebug() throws Exception {
loadContext("spring.developertools.remote.enabled:true",
loadContext("spring.developertools.remote.secret:supersecret",
"spring.developertools.remote.debug.enabled:false");
this.thrown.expect(NoSuchBeanDefinitionException.class);
this.context.getBean("remoteDebugHanderMapper");
......@@ -133,9 +175,10 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
@Test
public void developerToolsHealthReturns200() throws Exception {
loadContext("spring.developertools.remote.enabled:true");
loadContext("spring.developertools.remote.secret:supersecret");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH);
this.request.addHeader(DEFAULT_SECRET_HEADER_NAME, "supersecret");
this.response.setStatus(500);
filter.doFilter(this.request, this.response, this.chain);
assertThat(this.response.getStatus(), equalTo(200));
......
/*
* Copyright 2012-2015 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
*
* http://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.boot.developertools.remote.client;
import java.io.IOException;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.Assert.assertThat;
import static org.mockito.BDDMockito.given;
/**
* Tests for {@link HttpHeaderInterceptor}.
*
* @author Rob Winch
* @since 1.3.0
*/
@RunWith(MockitoJUnitRunner.class)
public class HttpHeaderInterceptorTests {
@Rule
public ExpectedException thrown = ExpectedException.none();
private String name;
private String value;
private HttpHeaderInterceptor interceptor;
private HttpRequest request;
private byte[] body;
@Mock
private ClientHttpRequestExecution execution;
@Mock
private ClientHttpResponse response;
private MockHttpServletRequest httpRequest;
@Before
public void setup() throws IOException {
this.body = new byte[] {};
this.httpRequest = new MockHttpServletRequest();
this.request = new ServletServerHttpRequest(this.httpRequest);
this.name = "X-AUTH-TOKEN";
this.value = "secret";
given(this.execution.execute(this.request, this.body)).willReturn(this.response);
this.interceptor = new HttpHeaderInterceptor(this.name, this.value);
}
@Test
public void constructorNullHeaderName() {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("Name must not be empty");
new HttpHeaderInterceptor(null, this.value);
}
@Test
public void constructorEmptyHeaderName() {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("Name must not be empty");
new HttpHeaderInterceptor("", this.value);
}
@Test
public void constructorNullHeaderValue() {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("Value must not be empty");
new HttpHeaderInterceptor(this.name, null);
}
@Test
public void constructorEmptyHeaderValue() {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("Value must not be empty");
new HttpHeaderInterceptor(this.name, "");
}
@Test
public void intercept() throws IOException {
ClientHttpResponse result = this.interceptor.intercept(this.request, this.body,
this.execution);
assertThat(this.request.getHeaders().getFirst(this.name), equalTo(this.value));
assertThat(result, equalTo(this.response));
}
}
......@@ -25,6 +25,7 @@ import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
......@@ -92,16 +93,23 @@ public class RemoteClientConfigurationTests {
@Test
public void warnIfNotHttps() throws Exception {
configureWithRemoteUrl("http://localhost");
configure("http://localhost", true);
assertThat(this.output.toString(), containsString("is insecure"));
}
@Test
public void doesntWarnIfUsingHttps() throws Exception {
configureWithRemoteUrl("https://localhost");
configure("https://localhost", true);
assertThat(this.output.toString(), not(containsString("is insecure")));
}
@Test
public void failIfNoSecret() throws Exception {
this.thrown.expect(BeanCreationException.class);
this.thrown.expectMessage("required to secure your connection");
configure("http://localhost", false);
}
@Test
public void liveReloadOnClassPathChanged() throws Exception {
configure();
......@@ -138,10 +146,10 @@ public class RemoteClientConfigurationTests {
}
private void configure(String... pairs) {
configureWithRemoteUrl("http://localhost", pairs);
configure("http://localhost", true, pairs);
}
private void configureWithRemoteUrl(String remoteUrl, String... pairs) {
private void configure(String remoteUrl, boolean setSecret, String... pairs) {
this.context = new AnnotationConfigEmbeddedWebApplicationContext();
new RestartScopeInitializer().initialize(this.context);
this.context.register(Config.class, RemoteClientConfiguration.class);
......@@ -149,6 +157,10 @@ public class RemoteClientConfigurationTests {
+ RemoteClientConfigurationTests.remotePort;
EnvironmentTestUtils.addEnvironment(this.context, remoteUrlProperty);
EnvironmentTestUtils.addEnvironment(this.context, pairs);
if (setSecret) {
EnvironmentTestUtils.addEnvironment(this.context,
"spring.developertools.remote.secret:secret");
}
this.context.refresh();
}
......
/*
* Copyright 2012-2015 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
*
* http://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.boot.developertools.remote.server;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.mock.web.MockHttpServletRequest;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.Assert.assertThat;
/**
* Tests for {@link HttpHeaderAccessManager}.
*
* @author Rob Winch
* @author Phillip Webb
*/
public class HttpHeaderAccessManagerTests {
private static final String HEADER = "X-AUTH_TOKEN";
private static final String SECRET = "password";
@Rule
public ExpectedException thrown = ExpectedException.none();
private MockHttpServletRequest request;
private ServerHttpRequest serverRequest;
private HttpHeaderAccessManager manager;
@Before
public void setup() {
this.request = new MockHttpServletRequest("GET", "/");
this.serverRequest = new ServletServerHttpRequest(this.request);
this.manager = new HttpHeaderAccessManager(HEADER, SECRET);
}
@Test
public void headerNameMustNotBeNull() throws Exception {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("HeaderName must not be empty");
new HttpHeaderAccessManager(null, SECRET);
}
@Test
public void headerNameMustNotBeEmpty() throws Exception {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("HeaderName must not be empty");
new HttpHeaderAccessManager("", SECRET);
}
@Test
public void expectedSecretMustNotBeNull() throws Exception {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("ExpectedSecret must not be empty");
new HttpHeaderAccessManager(HEADER, null);
}
@Test
public void expectedSecretMustNotBeEmpty() throws Exception {
this.thrown.expect(IllegalArgumentException.class);
this.thrown.expectMessage("ExpectedSecret must not be empty");
new HttpHeaderAccessManager(HEADER, "");
}
@Test
public void allowsMatching() throws Exception {
this.request.addHeader(HEADER, SECRET);
assertThat(this.manager.isAllowed(this.serverRequest), equalTo(true));
}
@Test
public void disallowsWrongSecret() throws Exception {
this.request.addHeader(HEADER, "wrong");
assertThat(this.manager.isAllowed(this.serverRequest), equalTo(false));
}
@Test
public void disallowsNoSecret() throws Exception {
assertThat(this.manager.isAllowed(this.serverRequest), equalTo(false));
}
@Test
public void disallowsWrongHeader() throws Exception {
this.request.addHeader("X-WRONG", SECRET);
assertThat(this.manager.isAllowed(this.serverRequest), equalTo(false));
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment