CORS: Support regular expressions for origin to match against
This commit adds regular expression support for the allow-origin header depending on the value of the request `Origin` header. The existing HttpRequestBuilder is also extended to support the OPTIONS HTTP method. Relates #5601 Closes #6891
This commit is contained in:
parent
1fb9f404df
commit
a1e335b1e9
|
@ -42,7 +42,10 @@ i.e. whether a browser on another origin can do requests to
|
|||
Elasticsearch. Defaults to `true`.
|
||||
|
||||
|`http.cors.allow-origin` |Which origins to allow. Defaults to `*`,
|
||||
i.e. any origin.
|
||||
i.e. any origin. If you prepend and append a `/` to the value, this will
|
||||
be treated as a regular expression, allowing you to support HTTP and HTTPs.
|
||||
for example using `/https?:\/\/localhost(:[0-9]+)?/` would return the
|
||||
request header appropriately in both cases.
|
||||
|
||||
|`http.cors.max-age` |Browsers send a "preflight" OPTIONS-request to
|
||||
determine CORS settings. `max-age` defines how long the result should
|
||||
|
|
|
@ -19,9 +19,12 @@
|
|||
|
||||
package org.elasticsearch.http.netty;
|
||||
|
||||
import org.elasticsearch.rest.support.RestUtils;
|
||||
import org.jboss.netty.channel.*;
|
||||
import org.jboss.netty.handler.codec.http.HttpRequest;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -30,9 +33,11 @@ import org.jboss.netty.handler.codec.http.HttpRequest;
|
|||
public class HttpRequestHandler extends SimpleChannelUpstreamHandler {
|
||||
|
||||
private final NettyHttpServerTransport serverTransport;
|
||||
private final Pattern corsPattern;
|
||||
|
||||
public HttpRequestHandler(NettyHttpServerTransport serverTransport) {
|
||||
this.serverTransport = serverTransport;
|
||||
this.corsPattern = RestUtils.getCorsSettingRegex(serverTransport.settings());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -41,7 +46,7 @@ public class HttpRequestHandler extends SimpleChannelUpstreamHandler {
|
|||
// the netty HTTP handling always copy over the buffer to its own buffer, either in NioWorker internally
|
||||
// when reading, or using a cumalation buffer
|
||||
NettyHttpRequest httpRequest = new NettyHttpRequest(request, e.getChannel());
|
||||
serverTransport.dispatchRequest(httpRequest, new NettyHttpChannel(serverTransport, e.getChannel(), httpRequest));
|
||||
serverTransport.dispatchRequest(httpRequest, new NettyHttpChannel(serverTransport, e.getChannel(), httpRequest, corsPattern));
|
||||
super.messageReceived(ctx, e);
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
package org.elasticsearch.http.netty;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
import org.apache.lucene.util.BytesRef;
|
||||
import org.apache.lucene.util.UnicodeUtil;
|
||||
import org.elasticsearch.common.bytes.BytesReference;
|
||||
|
@ -40,6 +41,9 @@ import org.jboss.netty.handler.codec.http.*;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jboss.netty.handler.codec.http.HttpHeaders.Names.*;
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -57,12 +61,14 @@ public class NettyHttpChannel extends HttpChannel {
|
|||
private final NettyHttpServerTransport transport;
|
||||
private final Channel channel;
|
||||
private final org.jboss.netty.handler.codec.http.HttpRequest nettyRequest;
|
||||
private Pattern corsPattern;
|
||||
|
||||
public NettyHttpChannel(NettyHttpServerTransport transport, Channel channel, NettyHttpRequest request) {
|
||||
public NettyHttpChannel(NettyHttpServerTransport transport, Channel channel, NettyHttpRequest request, Pattern corsPattern) {
|
||||
super(request);
|
||||
this.transport = transport;
|
||||
this.channel = channel;
|
||||
this.nettyRequest = request.request();
|
||||
this.corsPattern = corsPattern;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -90,15 +96,21 @@ public class NettyHttpChannel extends HttpChannel {
|
|||
} else {
|
||||
resp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status);
|
||||
}
|
||||
if (RestUtils.isBrowser(nettyRequest.headers().get(HttpHeaders.Names.USER_AGENT))) {
|
||||
if (RestUtils.isBrowser(nettyRequest.headers().get(USER_AGENT))) {
|
||||
if (transport.settings().getAsBoolean("http.cors.enabled", true)) {
|
||||
// Add support for cross-origin Ajax requests (CORS)
|
||||
resp.headers().add("Access-Control-Allow-Origin", transport.settings().get("http.cors.allow-origin", "*"));
|
||||
String originHeader = request.header(ORIGIN);
|
||||
if (!Strings.isNullOrEmpty(originHeader)) {
|
||||
if (corsPattern == null) {
|
||||
resp.headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, transport.settings().get("http.cors.allow-origin", "*"));
|
||||
} else {
|
||||
resp.headers().add(ACCESS_CONTROL_ALLOW_ORIGIN, corsPattern.matcher(originHeader).matches() ? originHeader : "null");
|
||||
}
|
||||
}
|
||||
if (nettyRequest.getMethod() == HttpMethod.OPTIONS) {
|
||||
// Allow Ajax requests based on the CORS "preflight" request
|
||||
resp.headers().add("Access-Control-Max-Age", transport.settings().getAsInt("http.cors.max-age", 1728000));
|
||||
resp.headers().add("Access-Control-Allow-Methods", transport.settings().get("http.cors.allow-methods", "OPTIONS, HEAD, GET, POST, PUT, DELETE"));
|
||||
resp.headers().add("Access-Control-Allow-Headers", transport.settings().get("http.cors.allow-headers", "X-Requested-With, Content-Type, Content-Length"));
|
||||
resp.headers().add(ACCESS_CONTROL_MAX_AGE, transport.settings().getAsInt("http.cors.max-age", 1728000));
|
||||
resp.headers().add(ACCESS_CONTROL_ALLOW_METHODS, transport.settings().get("http.cors.allow-methods", "OPTIONS, HEAD, GET, POST, PUT, DELETE"));
|
||||
resp.headers().add(ACCESS_CONTROL_ALLOW_HEADERS, transport.settings().get("http.cors.allow-headers", "X-Requested-With, Content-Type, Content-Length"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,9 +22,11 @@ package org.elasticsearch.rest.support;
|
|||
import com.google.common.base.Charsets;
|
||||
import org.elasticsearch.common.Nullable;
|
||||
import org.elasticsearch.common.path.PathTrie;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -37,6 +39,7 @@ public class RestUtils {
|
|||
return RestUtils.decodeComponent(value);
|
||||
}
|
||||
};
|
||||
public static final String HTTP_CORS_ALLOW_ORIGIN_SETTING = "http.cors.allow-origin";
|
||||
|
||||
public static boolean isBrowser(@Nullable String userAgent) {
|
||||
if (userAgent == null) {
|
||||
|
@ -216,4 +219,19 @@ public class RestUtils {
|
|||
return Character.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if CORS setting is a regex
|
||||
*/
|
||||
public static Pattern getCorsSettingRegex(Settings settings) {
|
||||
String corsSetting = settings.get(HTTP_CORS_ALLOW_ORIGIN_SETTING, "*");
|
||||
int len = corsSetting.length();
|
||||
boolean isRegex = len > 2 && corsSetting.startsWith("/") && corsSetting.endsWith("/");
|
||||
|
||||
if (isRegex) {
|
||||
return Pattern.compile(corsSetting.substring(1, corsSetting.length()-1));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch licenses this file to you 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.elasticsearch.rest;
|
||||
|
||||
import org.elasticsearch.test.ElasticsearchIntegrationTest;
|
||||
import org.elasticsearch.test.rest.client.http.HttpResponse;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.elasticsearch.rest.CorsRegexTests.httpClient;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public class CorsRegexDefaultTests extends ElasticsearchIntegrationTest {
|
||||
|
||||
@Test
|
||||
public void testCorsSettingDefaultBehaviour() throws Exception {
|
||||
String corsValue = "http://localhost:9200";
|
||||
HttpResponse response = httpClient().method("GET").path("/").addHeader("User-Agent", "Mozilla Bar").addHeader("Origin", corsValue).execute();
|
||||
|
||||
assertThat(response.getStatusCode(), is(200));
|
||||
assertThat(response.getHeaders(), hasKey("Access-Control-Allow-Origin"));
|
||||
assertThat(response.getHeaders().get("Access-Control-Allow-Origin"), is("*"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatOmittingCorsHeaderDoesNotReturnAnything() throws Exception {
|
||||
HttpResponse response = httpClient().method("GET").path("/").execute();
|
||||
|
||||
assertThat(response.getStatusCode(), is(200));
|
||||
assertThat(response.getHeaders(), not(hasKey("Access-Control-Allow-Origin")));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch licenses this file to you 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.elasticsearch.rest;
|
||||
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.elasticsearch.common.logging.ESLogger;
|
||||
import org.elasticsearch.common.logging.Loggers;
|
||||
import org.elasticsearch.common.settings.ImmutableSettings;
|
||||
import org.elasticsearch.common.settings.Settings;
|
||||
import org.elasticsearch.common.transport.InetSocketTransportAddress;
|
||||
import org.elasticsearch.http.HttpServerTransport;
|
||||
import org.elasticsearch.test.ElasticsearchIntegrationTest;
|
||||
import org.elasticsearch.test.rest.client.http.HttpRequestBuilder;
|
||||
import org.elasticsearch.test.rest.client.http.HttpResponse;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
|
||||
import static org.elasticsearch.test.ElasticsearchIntegrationTest.ClusterScope;
|
||||
import static org.elasticsearch.test.ElasticsearchIntegrationTest.Scope;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
@ClusterScope(scope = Scope.SUITE, numDataNodes = 1)
|
||||
public class CorsRegexTests extends ElasticsearchIntegrationTest {
|
||||
|
||||
protected static final ESLogger logger = Loggers.getLogger(CorsRegexTests.class);
|
||||
|
||||
@Override
|
||||
protected Settings nodeSettings(int nodeOrdinal) {
|
||||
return ImmutableSettings.settingsBuilder()
|
||||
.put("http.cors.allow-origin", "/https?:\\/\\/localhost(:[0-9]+)?/")
|
||||
.put("network.host", "127.0.0.1")
|
||||
.put(super.nodeSettings(nodeOrdinal))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatRegularExpressionWorksOnMatch() throws Exception {
|
||||
String corsValue = "http://localhost:9200";
|
||||
HttpResponse response = httpClient().method("GET").path("/").addHeader("User-Agent", "Mozilla Bar").addHeader("Origin", corsValue).execute();
|
||||
assertResponseWithOriginheader(response, corsValue);
|
||||
|
||||
corsValue = "https://localhost:9200";
|
||||
response = httpClient().method("GET").path("/").addHeader("User-Agent", "Mozilla Bar").addHeader("Origin", corsValue).execute();
|
||||
assertResponseWithOriginheader(response, corsValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatRegularExpressionReturnsNullOnNonMatch() throws Exception {
|
||||
HttpResponse response = httpClient().method("GET").path("/").addHeader("User-Agent", "Mozilla Bar").addHeader("Origin", "http://evil-host:9200").execute();
|
||||
assertResponseWithOriginheader(response, "null");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatSendingNoOriginHeaderReturnsNoAccessControlHeader() throws Exception {
|
||||
HttpResponse response = httpClient().method("GET").path("/").addHeader("User-Agent", "Mozilla Bar").execute();
|
||||
assertThat(response.getStatusCode(), is(200));
|
||||
assertThat(response.getHeaders(), not(hasKey("Access-Control-Allow-Origin")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatRegularExpressionIsNotAppliedWithoutCorrectBrowserOnMatch() throws Exception {
|
||||
HttpResponse response = httpClient().method("GET").path("/").execute();
|
||||
assertThat(response.getStatusCode(), is(200));
|
||||
assertThat(response.getHeaders(), not(hasKey("Access-Control-Allow-Origin")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatPreFlightRequestWorksOnMatch() throws Exception {
|
||||
String corsValue = "http://localhost:9200";
|
||||
HttpResponse response = httpClient().method("OPTIONS").path("/").addHeader("User-Agent", "Mozilla Bar").addHeader("Origin", corsValue).execute();
|
||||
assertResponseWithOriginheader(response, corsValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testThatPreFlightRequestReturnsNullOnNonMatch() throws Exception {
|
||||
HttpResponse response = httpClient().method("OPTIONS").path("/").addHeader("User-Agent", "Mozilla Bar").addHeader("Origin", "http://evil-host:9200").execute();
|
||||
assertResponseWithOriginheader(response, "null");
|
||||
}
|
||||
|
||||
public static HttpRequestBuilder httpClient() {
|
||||
HttpServerTransport httpServerTransport = internalCluster().getDataNodeInstance(HttpServerTransport.class);
|
||||
InetSocketAddress address = ((InetSocketTransportAddress) httpServerTransport.boundAddress().publishAddress()).address();
|
||||
return new HttpRequestBuilder(HttpClients.createDefault()).host(address.getHostName()).port(address.getPort());
|
||||
}
|
||||
|
||||
public static void assertResponseWithOriginheader(HttpResponse response, String expectedCorsHeader) {
|
||||
assertThat(response.getStatusCode(), is(200));
|
||||
assertThat(response.getHeaders(), hasKey("Access-Control-Allow-Origin"));
|
||||
assertThat(response.getHeaders().get("Access-Control-Allow-Origin"), is(expectedCorsHeader));
|
||||
}
|
||||
}
|
|
@ -19,15 +19,18 @@
|
|||
|
||||
package org.elasticsearch.rest.util;
|
||||
|
||||
import org.elasticsearch.common.settings.ImmutableSettings;
|
||||
import org.elasticsearch.rest.support.RestUtils;
|
||||
import org.elasticsearch.test.ElasticsearchTestCase;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static com.google.common.collect.Maps.newHashMap;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.elasticsearch.common.settings.ImmutableSettings.settingsBuilder;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
*
|
||||
|
@ -122,4 +125,34 @@ public class RestUtilsTests extends ElasticsearchTestCase {
|
|||
assertThat(params.get("p1"), equalTo("v1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCorsSettingIsARegex() {
|
||||
assertCorsSettingRegex("/foo/", Pattern.compile("foo"));
|
||||
assertCorsSettingRegex("/.*/", Pattern.compile(".*"));
|
||||
assertCorsSettingRegex("/https?:\\/\\/localhost(:[0-9]+)?/", Pattern.compile("https?:\\/\\/localhost(:[0-9]+)?"));
|
||||
assertCorsSettingRegexMatches("/https?:\\/\\/localhost(:[0-9]+)?/", true, "http://localhost:9200", "http://localhost:9215", "https://localhost:9200", "https://localhost");
|
||||
assertCorsSettingRegexMatches("/https?:\\/\\/localhost(:[0-9]+)?/", false, "htt://localhost:9200", "http://localhost:9215/foo", "localhost:9215");
|
||||
assertCorsSettingRegexIsNull("//");
|
||||
assertCorsSettingRegexIsNull("/");
|
||||
assertCorsSettingRegexIsNull("/foo");
|
||||
assertCorsSettingRegexIsNull("foo");
|
||||
assertCorsSettingRegexIsNull("");
|
||||
assertThat(RestUtils.getCorsSettingRegex(ImmutableSettings.EMPTY), is(nullValue()));
|
||||
}
|
||||
|
||||
private void assertCorsSettingRegexIsNull(String settingsValue) {
|
||||
assertThat(RestUtils.getCorsSettingRegex(settingsBuilder().put("http.cors.allow-origin", settingsValue).build()), is(nullValue()));
|
||||
}
|
||||
|
||||
private void assertCorsSettingRegex(String settingsValue, Pattern pattern) {
|
||||
assertThat(RestUtils.getCorsSettingRegex(settingsBuilder().put("http.cors.allow-origin", settingsValue).build()).toString(), is(pattern.toString()));
|
||||
}
|
||||
|
||||
private void assertCorsSettingRegexMatches(String settingsValue, boolean expectMatch, String ... candidates) {
|
||||
Pattern pattern = RestUtils.getCorsSettingRegex(settingsBuilder().put("http.cors.allow-origin", settingsValue).build());
|
||||
for (String candidate : candidates) {
|
||||
assertThat(String.format(Locale.ROOT, "Expected pattern %s to match against %s: %s", settingsValue, candidate, expectMatch),
|
||||
pattern.matcher(candidate).matches(), is(expectMatch));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,16 +20,13 @@ package org.elasticsearch.test.rest.client.http;
|
|||
|
||||
import com.google.common.base.Joiner;
|
||||
import com.google.common.collect.Maps;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.client.methods.*;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.lucene.util.IOUtils;
|
||||
import org.elasticsearch.common.Strings;
|
||||
import org.elasticsearch.common.logging.ESLogger;
|
||||
import org.elasticsearch.common.logging.Loggers;
|
||||
import org.elasticsearch.common.transport.InetSocketTransportAddress;
|
||||
import org.elasticsearch.common.transport.TransportAddress;
|
||||
import org.elasticsearch.http.HttpServerTransport;
|
||||
|
||||
import java.io.IOException;
|
||||
|
@ -147,6 +144,11 @@ public class HttpRequestBuilder {
|
|||
return new HttpHead(buildUri());
|
||||
}
|
||||
|
||||
if (HttpOptions.METHOD_NAME.equalsIgnoreCase(method)) {
|
||||
checkBodyNotSupported();
|
||||
return new HttpOptions(buildUri());
|
||||
}
|
||||
|
||||
if (HttpDeleteWithEntity.METHOD_NAME.equalsIgnoreCase(method)) {
|
||||
return addOptionalBody(new HttpDeleteWithEntity(buildUri()));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue