Skip to content

Commit

Permalink
CORS: Support regular expressions for origin to match against
Browse files Browse the repository at this point in the history
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 elastic#5601
Closes elastic#6891
  • Loading branch information
spinscale committed Jul 25, 2014
1 parent 1fb9f40 commit a1e335b
Show file tree
Hide file tree
Showing 8 changed files with 248 additions and 14 deletions.
5 changes: 4 additions & 1 deletion docs/reference/modules/http.asciidoc
Expand Up @@ -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
Expand Down
Expand Up @@ -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;


/**
*
Expand All @@ -30,9 +33,11 @@
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
Expand All @@ -41,7 +46,7 @@ public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Ex
// 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);
}

Expand Down
26 changes: 19 additions & 7 deletions src/main/java/org/elasticsearch/http/netty/NettyHttpChannel.java
Expand Up @@ -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;
Expand All @@ -40,6 +41,9 @@
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.*;

/**
*
Expand All @@ -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
Expand Down Expand Up @@ -90,15 +96,21 @@ public void sendResponse(RestResponse response) {
} 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"));
}
}
}
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/org/elasticsearch/rest/support/RestUtils.java
Expand Up @@ -22,9 +22,11 @@
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;

/**
*
Expand All @@ -37,6 +39,7 @@ public String decode(String 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) {
if (userAgent == null) {
Expand Down Expand Up @@ -216,4 +219,19 @@ private static char decodeHexNibble(final char c) {
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;
}
}
50 changes: 50 additions & 0 deletions src/test/java/org/elasticsearch/rest/CorsRegexDefaultTests.java
@@ -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")));
}
}
111 changes: 111 additions & 0 deletions src/test/java/org/elasticsearch/rest/CorsRegexTests.java
@@ -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));
}
}
37 changes: 35 additions & 2 deletions src/test/java/org/elasticsearch/rest/util/RestUtilsTests.java
Expand Up @@ -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.*;

/**
*
Expand Down Expand Up @@ -122,4 +125,34 @@ public void testDecodeQueryStringEdgeCases() {
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));
}
}
}
Expand Up @@ -20,16 +20,13 @@

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;
Expand Down Expand Up @@ -147,6 +144,11 @@ private HttpUriRequest buildRequest() {
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()));
}
Expand Down

0 comments on commit a1e335b

Please sign in to comment.