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:
Alexander Reelsen 2014-07-24 09:14:50 +02:00
parent 1fb9f404df
commit a1e335b1e9
8 changed files with 248 additions and 14 deletions

View File

@ -42,7 +42,10 @@ i.e. whether a browser on another origin can do requests to
Elasticsearch. Defaults to `true`. Elasticsearch. Defaults to `true`.
|`http.cors.allow-origin` |Which origins to allow. Defaults to `*`, |`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 |`http.cors.max-age` |Browsers send a "preflight" OPTIONS-request to
determine CORS settings. `max-age` defines how long the result should determine CORS settings. `max-age` defines how long the result should

View File

@ -19,9 +19,12 @@
package org.elasticsearch.http.netty; package org.elasticsearch.http.netty;
import org.elasticsearch.rest.support.RestUtils;
import org.jboss.netty.channel.*; import org.jboss.netty.channel.*;
import org.jboss.netty.handler.codec.http.HttpRequest; 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 { public class HttpRequestHandler extends SimpleChannelUpstreamHandler {
private final NettyHttpServerTransport serverTransport; private final NettyHttpServerTransport serverTransport;
private final Pattern corsPattern;
public HttpRequestHandler(NettyHttpServerTransport serverTransport) { public HttpRequestHandler(NettyHttpServerTransport serverTransport) {
this.serverTransport = serverTransport; this.serverTransport = serverTransport;
this.corsPattern = RestUtils.getCorsSettingRegex(serverTransport.settings());
} }
@Override @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 // the netty HTTP handling always copy over the buffer to its own buffer, either in NioWorker internally
// when reading, or using a cumalation buffer // when reading, or using a cumalation buffer
NettyHttpRequest httpRequest = new NettyHttpRequest(request, e.getChannel()); 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); super.messageReceived(ctx, e);
} }

View File

@ -19,6 +19,7 @@
package org.elasticsearch.http.netty; package org.elasticsearch.http.netty;
import com.google.common.base.Strings;
import org.apache.lucene.util.BytesRef; import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.UnicodeUtil; import org.apache.lucene.util.UnicodeUtil;
import org.elasticsearch.common.bytes.BytesReference; import org.elasticsearch.common.bytes.BytesReference;
@ -40,6 +41,9 @@ import org.jboss.netty.handler.codec.http.*;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; 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 NettyHttpServerTransport transport;
private final Channel channel; private final Channel channel;
private final org.jboss.netty.handler.codec.http.HttpRequest nettyRequest; 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); super(request);
this.transport = transport; this.transport = transport;
this.channel = channel; this.channel = channel;
this.nettyRequest = request.request(); this.nettyRequest = request.request();
this.corsPattern = corsPattern;
} }
@Override @Override
@ -90,15 +96,21 @@ public class NettyHttpChannel extends HttpChannel {
} else { } else {
resp = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status); 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)) { if (transport.settings().getAsBoolean("http.cors.enabled", true)) {
// Add support for cross-origin Ajax requests (CORS) String originHeader = request.header(ORIGIN);
resp.headers().add("Access-Control-Allow-Origin", transport.settings().get("http.cors.allow-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) { if (nettyRequest.getMethod() == HttpMethod.OPTIONS) {
// Allow Ajax requests based on the CORS "preflight" request // 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_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_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_ALLOW_HEADERS, transport.settings().get("http.cors.allow-headers", "X-Requested-With, Content-Type, Content-Length"));
} }
} }
} }

View File

@ -22,9 +22,11 @@ package org.elasticsearch.rest.support;
import com.google.common.base.Charsets; import com.google.common.base.Charsets;
import org.elasticsearch.common.Nullable; import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.path.PathTrie; import org.elasticsearch.common.path.PathTrie;
import org.elasticsearch.common.settings.Settings;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
/** /**
* *
@ -37,6 +39,7 @@ public class RestUtils {
return RestUtils.decodeComponent(value); return RestUtils.decodeComponent(value);
} }
}; };
public static final String HTTP_CORS_ALLOW_ORIGIN_SETTING = "http.cors.allow-origin";
public static boolean isBrowser(@Nullable String userAgent) { public static boolean isBrowser(@Nullable String userAgent) {
if (userAgent == null) { if (userAgent == null) {
@ -216,4 +219,19 @@ public class RestUtils {
return Character.MAX_VALUE; 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;
}
} }

View File

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

View File

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

View File

@ -19,15 +19,18 @@
package org.elasticsearch.rest.util; package org.elasticsearch.rest.util;
import org.elasticsearch.common.settings.ImmutableSettings;
import org.elasticsearch.rest.support.RestUtils; import org.elasticsearch.rest.support.RestUtils;
import org.elasticsearch.test.ElasticsearchTestCase; import org.elasticsearch.test.ElasticsearchTestCase;
import org.junit.Test; import org.junit.Test;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import static com.google.common.collect.Maps.newHashMap; import static com.google.common.collect.Maps.newHashMap;
import static org.hamcrest.MatcherAssert.assertThat; import static org.elasticsearch.common.settings.ImmutableSettings.settingsBuilder;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.*;
/** /**
* *
@ -122,4 +125,34 @@ public class RestUtilsTests extends ElasticsearchTestCase {
assertThat(params.get("p1"), equalTo("v1")); 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));
}
}
} }

View File

@ -20,16 +20,13 @@ package org.elasticsearch.test.rest.client.http;
import com.google.common.base.Joiner; import com.google.common.base.Joiner;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.*; import org.apache.http.client.methods.*;
import org.apache.http.entity.StringEntity; import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.lucene.util.IOUtils;
import org.elasticsearch.common.Strings; import org.elasticsearch.common.Strings;
import org.elasticsearch.common.logging.ESLogger; import org.elasticsearch.common.logging.ESLogger;
import org.elasticsearch.common.logging.Loggers; import org.elasticsearch.common.logging.Loggers;
import org.elasticsearch.common.transport.InetSocketTransportAddress; import org.elasticsearch.common.transport.InetSocketTransportAddress;
import org.elasticsearch.common.transport.TransportAddress;
import org.elasticsearch.http.HttpServerTransport; import org.elasticsearch.http.HttpServerTransport;
import java.io.IOException; import java.io.IOException;
@ -147,6 +144,11 @@ public class HttpRequestBuilder {
return new HttpHead(buildUri()); return new HttpHead(buildUri());
} }
if (HttpOptions.METHOD_NAME.equalsIgnoreCase(method)) {
checkBodyNotSupported();
return new HttpOptions(buildUri());
}
if (HttpDeleteWithEntity.METHOD_NAME.equalsIgnoreCase(method)) { if (HttpDeleteWithEntity.METHOD_NAME.equalsIgnoreCase(method)) {
return addOptionalBody(new HttpDeleteWithEntity(buildUri())); return addOptionalBody(new HttpDeleteWithEntity(buildUri()));
} }