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
pull/3077/merge
Rob Winch 10 years ago committed by Phillip Webb
parent fe4c0022d7
commit 207347e150

@ -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;
}

@ -0,0 +1,60 @@
/*
* 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

@ -0,0 +1,48 @@
/*
* 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));

@ -0,0 +1,115 @@
/*
* 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();
}

@ -0,0 +1,109 @@
/*
* 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));
}
}
Loading…
Cancel
Save