diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsConfig.java b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsConfig.java index b818453d94..5cd7004626 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsConfig.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsConfig.java @@ -15,13 +15,21 @@ */ package io.netty.handler.codec.http.cors; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpHeaders.Names; import io.netty.handler.codec.http.HttpMethod; import io.netty.util.internal.StringUtil; import java.util.Arrays; import java.util.Collections; +import java.util.Date; +import java.util.HashMap; import java.util.HashSet; +import java.util.Map; +import java.util.Map.Entry; import java.util.Set; +import java.util.concurrent.Callable; /** * Configuration for Cross-Origin Resource Sharing (CORS). @@ -36,6 +44,7 @@ public final class CorsConfig { private final Set allowedRequestMethods; private final Set allowedRequestHeaders; private final boolean allowNullOrigin; + private final Map> preflightHeaders; private CorsConfig(final Builder builder) { origin = builder.origin; @@ -46,6 +55,7 @@ public final class CorsConfig { allowedRequestMethods = builder.requestMethods; allowedRequestHeaders = builder.requestHeaders; allowNullOrigin = builder.allowNullOrigin; + preflightHeaders = builder.preflightHeaders; } /** @@ -69,6 +79,7 @@ public final class CorsConfig { /** * Web browsers may set the 'Origin' request header to 'null' if a resource is loaded * from the local file system. + * * If isNullOriginAllowed is true then the server will response with the wildcard for the * the CORS response header 'Access-Control-Allow-Origin'. * @@ -95,8 +106,8 @@ public final class CorsConfig { *
  • Last-Modified
  • *
  • Pragma
  • * - * To expose other headers they need to be specified which what this method enables by adding the headers - * to the CORS 'Access-Control-Expose-Headers' response header. + * To expose other headers they need to be specified, which is what this method enables by + * adding the headers names to the CORS 'Access-Control-Expose-Headers' response header. * * @return {@code List} a list of the headers to expose. */ @@ -107,9 +118,17 @@ public final class CorsConfig { /** * Determines if cookies are supported for CORS requests. * - * By default cookies are not included in CORS requests but if isCredentialsAllowed returns true cookies will - * be added to CORS requests. Setting this value to true will set the CORS 'Access-Control-Allow-Credentials' - * response header to true. + * By default cookies are not included in CORS requests but if isCredentialsAllowed returns + * true cookies will be added to CORS requests. Setting this value to true will set the + * CORS 'Access-Control-Allow-Credentials' response header to true. + * + * Please note that cookie support needs to be enabled on the client side as well. + * The client needs to opt-in to send cookies by calling: + *
    +     * xhr.withCredentials = true;
    +     * 
    + * The default value for 'withCredentials' is false in which case no cookies are sent. + * Settning this to true will included cookies in cross origin requests. * * @return {@code true} if cookies are supported. */ @@ -120,9 +139,10 @@ public final class CorsConfig { /** * Gets the maxAge setting. * - * When making a preflight request the client has to perform two request with can be inefficient. This setting - * will set the CORS 'Access-Control-Max-Age' response header and enables the caching of the preflight response - * for the specified time. During this time no preflight request will be made. + * When making a preflight request the client has to perform two request with can be inefficient. + * This setting will set the CORS 'Access-Control-Max-Age' response header and enables the + * caching of the preflight response for the specified time. During this time no preflight + * request will be made. * * @return {@code long} the time in seconds that a preflight request may be cached. */ @@ -132,10 +152,9 @@ public final class CorsConfig { /** * Returns the allowed set of Request Methods. The Http methods that should be returned in the - * * CORS 'Access-Control-Request-Method' response header. * - * @return {@code Set} strings that represent the allowed Request Methods. + * @return {@code Set} of {@link HttpMethod}s that represent the allowed Request Methods. */ public Set allowedRequestMethods() { return Collections.unmodifiableSet(allowedRequestMethods); @@ -144,15 +163,45 @@ public final class CorsConfig { /** * Returns the allowed set of Request Headers. * - * The header names returned from this method will be used to set the CORS 'Access-Control-Allow-Headers' - * response header. + * The header names returned from this method will be used to set the CORS + * 'Access-Control-Allow-Headers' response header. * - * @return {@code Set} of strings that represent the allowed Request Headers. + * @return {@code Set} of strings that represent the allowed Request Headers. */ public Set allowedRequestHeaders() { return Collections.unmodifiableSet(allowedRequestHeaders); } + /** + * Returns HTTP response headers that should be added to a CORS preflight response. + * + * @return {@link HttpHeaders} the HTTP response headers to be added. + */ + public HttpHeaders preflightResponseHeaders() { + if (preflightHeaders.isEmpty()) { + return HttpHeaders.EMPTY_HEADERS; + } + final HttpHeaders preflightHeaders = new DefaultHttpHeaders(); + for (Entry> entry : this.preflightHeaders.entrySet()) { + final Object value = getValue(entry.getValue()); + if (value instanceof Iterable) { + preflightHeaders.add(entry.getKey(), (Iterable) value); + } else { + preflightHeaders.add(entry.getKey(), value); + } + } + return preflightHeaders; + } + + private static T getValue(final Callable callable) { + try { + return callable.call(); + } catch (final Exception e) { + throw new IllegalStateException("Could not generate value for callable [" + callable + ']', e); + } + } + + @Override public String toString() { return StringUtil.simpleClassName(this) + "[enabled=" + enabled + ", origin=" + origin + @@ -160,17 +209,31 @@ public final class CorsConfig { ", isCredentialsAllowed=" + allowCredentials + ", maxAge=" + maxAge + ", allowedRequestMethods=" + allowedRequestMethods + - ", allowedRequestHeaders=" + allowedRequestHeaders + ']'; + ", allowedRequestHeaders=" + allowedRequestHeaders + + ", preflightHeaders=" + preflightHeaders + ']'; } + /** + * Creates a Builder instance with it's origin set to '*'. + * + * @return Builder to support method chaining. + */ public static Builder anyOrigin() { return new Builder("*"); } + /** + * Creates a {@link Builder} instance with the specified origin. + * + * @return {@link Builder} to support method chaining. + */ public static Builder withOrigin(final String origin) { return new Builder(origin); } + /** + * Builder used to configure and build a CorsConfig instance. + */ public static class Builder { private final String origin; @@ -181,49 +244,254 @@ public final class CorsConfig { private long maxAge; private final Set requestMethods = new HashSet(); private final Set requestHeaders = new HashSet(); + private final Map> preflightHeaders = new HashMap>(); + private boolean noPreflightHeaders; + /** + * Creates a new Builder instance with the origin passed in. + * + * @param origin the origin to be used for this builder. + */ public Builder(final String origin) { this.origin = origin; } + /** + * Web browsers may set the 'Origin' request header to 'null' if a resource is loaded + * from the local file system. Calling this method will enable a successful CORS response + * with a wildcard for the the CORS response header 'Access-Control-Allow-Origin'. + * + * @return {@link Builder} to support method chaining. + */ public Builder allowNullOrigin() { allowNullOrigin = true; return this; } + /** + * Disables CORS support. + * + * @return {@link Builder} to support method chaining. + */ public Builder disable() { enabled = false; return this; } + /** + * Specifies the headers to be exposed to calling clients. + * + * During a simple CORS request, only certain response headers are made available by the + * browser, for example using: + *
    +         * xhr.getResponseHeader("Content-Type");
    +         * 
    + * + * The headers that are available by default are: + *
      + *
    • Cache-Control
    • + *
    • Content-Language
    • + *
    • Content-Type
    • + *
    • Expires
    • + *
    • Last-Modified
    • + *
    • Pragma
    • + *
    + * + * To expose other headers they need to be specified which is what this method enables by + * adding the headers to the CORS 'Access-Control-Expose-Headers' response header. + * + * @param headers the values to be added to the 'Access-Control-Expose-Headers' response header + * @return {@link Builder} to support method chaining. + */ public Builder exposeHeaders(final String... headers) { exposeHeaders.addAll(Arrays.asList(headers)); return this; } + /** + * By default cookies are not included in CORS requests, but this method will enable cookies to + * be added to CORS requests. Calling this method will set the CORS 'Access-Control-Allow-Credentials' + * response header to true. + * + * Please note, that cookie support needs to be enabled on the client side as well. + * The client needs to opt-in to send cookies by calling: + *
    +         * xhr.withCredentials = true;
    +         * 
    + * The default value for 'withCredentials' is false in which case no cookies are sent. + * Settning this to true will included cookies in cross origin requests. + * + * @return {@link Builder} to support method chaining. + */ public Builder allowCredentials() { allowCredentials = true; return this; } + /** + * When making a preflight request the client has to perform two request with can be inefficient. + * This setting will set the CORS 'Access-Control-Max-Age' response header and enables the + * caching of the preflight response for the specified time. During this time no preflight + * request will be made. + * + * @param max the maximum time, in seconds, that the preflight response may be cached. + * @return {@link Builder} to support method chaining. + */ public Builder maxAge(final long max) { maxAge = max; return this; } + /** + * Specifies the allowed set of HTTP Request Methods that should be returned in the + * CORS 'Access-Control-Request-Method' response header. + * + * @param methods the {@link HttpMethod}s that should be allowed. + * @return {@link Builder} to support method chaining. + */ public Builder allowedRequestMethods(final HttpMethod... methods) { requestMethods.addAll(Arrays.asList(methods)); return this; } + /** + * Specifies the if headers that should be returned in the CORS 'Access-Control-Allow-Headers' + * response header. + * + * If a client specifies headers on the request, for example by calling: + *
    +         * xhr.setRequestHeader('My-Custom-Header', "SomeValue");
    +         * 
    + * the server will recieve the above header name in the 'Access-Control-Request-Headers' of the + * preflight request. The server will then decide if it allows this header to be sent for the + * real request (remember that a preflight is not the real request but a request asking the server + * if it allow a request). + * + * @param headers the headers to be added to the preflight 'Access-Control-Allow-Headers' response header. + * @return {@link Builder} to support method chaining. + */ public Builder allowedRequestHeaders(final String... headers) { requestHeaders.addAll(Arrays.asList(headers)); return this; } + /** + * Returns HTTP response headers that should be added to a CORS preflight response. + * + * An intermediary like a load balancer might require that a CORS preflight request + * have certain headers set. This enables such headers to be added. + * + * @param name the name of the HTTP header. + * @param values the values for the HTTP header. + * @return {@link Builder} to support method chaining. + */ + public Builder preflightResponseHeader(final CharSequence name, final Object... values) { + if (values.length == 1) { + preflightHeaders.put(name, new ConstantValueGenerator(values[0])); + } else { + preflightResponseHeader(name, Arrays.asList(values)); + } + return this; + } + + /** + * Returns HTTP response headers that should be added to a CORS preflight response. + * + * An intermediary like a load balancer might require that a CORS preflight request + * have certain headers set. This enables such headers to be added. + * + * @param name the name of the HTTP header. + * @param value the values for the HTTP header. + * @param the type of values that the Iterable contains. + * @return {@link Builder} to support method chaining. + */ + public Builder preflightResponseHeader(final CharSequence name, final Iterable value) { + preflightHeaders.put(name, new ConstantValueGenerator(value)); + return this; + } + + /** + * Returns HTTP response headers that should be added to a CORS preflight response. + * + * An intermediary like a load balancer might require that a CORS preflight request + * have certain headers set. This enables such headers to be added. + * + * Some values must be dynamically created when the HTTP response is created, for + * example the 'Date' response header. This can be occomplished by using a Callable + * which will have its 'call' method invoked when the HTTP response is created. + * + * @param name the name of the HTTP header. + * @param valueGenerator a Callable which will be invoked at HTTP response creation. + * @param the type of the value that the Callable can return. + * @return {@link Builder} to support method chaining. + */ + public Builder preflightResponseHeader(final String name, final Callable valueGenerator) { + preflightHeaders.put(name, valueGenerator); + return this; + } + + /** + * Specifies that no preflight response headers should be added to a preflight response. + * + * @return {@link Builder} to support method chaining. + */ + public Builder noPreflightResponseHeaders() { + noPreflightHeaders = true; + return this; + } + + /** + * Builds a {@link CorsConfig} with settings specified by previous method calls. + * + * @return {@link CorsConfig} the configured CorsConfig instance. + */ public CorsConfig build() { + if (preflightHeaders.isEmpty() && !noPreflightHeaders) { + preflightHeaders.put(Names.DATE, new DateValueGenerator()); + preflightHeaders.put(Names.CONTENT_LENGTH, new ConstantValueGenerator("0")); + } return new CorsConfig(this); } } + /** + * This class is used for preflight HTTP response values that do not need to be + * generated, but instead the value is "static" in that the same value will be returned + * for each call. + */ + private static final class ConstantValueGenerator implements Callable { + + private final Object value; + + /** + * Sole constructor. + * + * @param value the value that will be returned when the call method is invoked. + */ + private ConstantValueGenerator(final Object value) { + if (value == null) { + throw new IllegalArgumentException("value must not be null"); + } + this.value = value; + } + + @Override + public Object call() { + return value; + } + } + + /** + * This callable is used for the DATE preflight HTTP response HTTP header. + * It's value must be generated when the response is generated, hence will be + * different for every call. + */ + public static final class DateValueGenerator implements Callable { + + @Override + public Date call() throws Exception { + return new Date(); + } + } + } diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java index 35da890faa..f8bd594d73 100644 --- a/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java +++ b/codec-http/src/main/java/io/netty/handler/codec/http/cors/CorsHandler.java @@ -15,10 +15,6 @@ */ package io.netty.handler.codec.http.cors; -import static io.netty.handler.codec.http.HttpHeaders.Names.*; -import static io.netty.handler.codec.http.HttpMethod.*; -import static io.netty.handler.codec.http.HttpResponseStatus.OK; - import io.netty.channel.ChannelDuplexHandler; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; @@ -30,7 +26,9 @@ import io.netty.handler.codec.http.HttpResponse; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; -import java.util.Date; +import static io.netty.handler.codec.http.HttpHeaders.Names.*; +import static io.netty.handler.codec.http.HttpMethod.*; +import static io.netty.handler.codec.http.HttpResponseStatus.*; /** * Handles Cross Origin Resource Sharing (CORS) requests. @@ -64,16 +62,25 @@ public class CorsHandler extends ChannelDuplexHandler { private void handlePreflight(final ChannelHandlerContext ctx, final HttpRequest request) { final HttpResponse response = new DefaultHttpResponse(request.getProtocolVersion(), OK); if (setOrigin(response)) { - HttpHeaders.setContentLength(response, 0); - HttpHeaders.setDate(response, new Date()); setAllowMethods(response); setAllowHeaders(response); setAllowCredentials(response); setMaxAge(response); + setPreflightHeaders(response); } ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } + /** + * This is a non CORS specification feature which enables the setting of preflight + * response headers that might be required by intermediaries. + * + * @param response the HttpResponse to which the preflight response headers should be added. + */ + private void setPreflightHeaders(final HttpResponse response) { + response.headers().add(config.preflightResponseHeaders()); + } + private boolean setOrigin(final HttpResponse response) { final String origin = request.headers().get(ORIGIN); if (origin != null) { diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsConfigurationTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsConfigTest.java similarity index 61% rename from codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsConfigurationTest.java rename to codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsConfigTest.java index 293f1b0c78..6919b6fcf3 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsConfigurationTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsConfigTest.java @@ -15,17 +15,16 @@ */ package io.netty.handler.codec.http.cors; -import static io.netty.handler.codec.http.cors.CorsConfig.withOrigin; -import static io.netty.handler.codec.http.cors.CorsConfig.anyOrigin; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpHeaders.Names; import io.netty.handler.codec.http.HttpMethod; import org.junit.Test; -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.hasItems; -import static org.hamcrest.CoreMatchers.is; +import static io.netty.handler.codec.http.cors.CorsConfig.*; +import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.MatcherAssert.*; -public class CorsConfigurationTest { +public class CorsConfigTest { @Test public void disabled() { @@ -75,4 +74,34 @@ public class CorsConfigurationTest { assertThat(cors.allowedRequestHeaders(), hasItems("preflight-header1", "preflight-header2")); } + @Test + public void preflightResponseHeadersSingleValue() { + final CorsConfig cors = withOrigin("*").preflightResponseHeader("SingleValue", "value").build(); + assertThat(cors.preflightResponseHeaders().get("SingleValue"), equalTo("value")); + } + + @Test + public void preflightResponseHeadersMultipleValues() { + final CorsConfig cors = withOrigin("*").preflightResponseHeader("MultipleValues", "value1", "value2").build(); + assertThat(cors.preflightResponseHeaders().getAll("MultipleValues"), hasItems("value1", "value2")); + } + + @Test + public void defaultPreflightResponseHeaders() { + final CorsConfig cors = withOrigin("*").build(); + assertThat(cors.preflightResponseHeaders().get(Names.DATE), is(notNullValue())); + assertThat(cors.preflightResponseHeaders().get(Names.CONTENT_LENGTH), is("0")); + } + + @Test + public void emptyPreflightResponseHeaders() { + final CorsConfig cors = withOrigin("*").noPreflightResponseHeaders().build(); + assertThat(cors.preflightResponseHeaders(), equalTo(HttpHeaders.EMPTY_HEADERS)); + } + + @Test (expected = IllegalArgumentException.class) + public void shouldThrowIfValueIsNull() { + withOrigin("*").preflightResponseHeader("HeaderName", null).build(); + } + } diff --git a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java index dc7aa94db4..8dc2d83040 100644 --- a/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java +++ b/codec-http/src/test/java/io/netty/handler/codec/http/cors/CorsHandlerTest.java @@ -15,17 +15,9 @@ */ package io.netty.handler.codec.http.cors; -import static io.netty.handler.codec.http.HttpHeaders.Names.*; -import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; -import static io.netty.handler.codec.http.HttpMethod.GET; -import static io.netty.handler.codec.http.HttpMethod.OPTIONS; -import static org.hamcrest.CoreMatchers.*; -import static org.hamcrest.MatcherAssert.assertThat; - import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.channel.embedded.EmbeddedChannel; - import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.FullHttpRequest; @@ -34,6 +26,15 @@ import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; import org.junit.Test; +import java.util.Arrays; +import java.util.concurrent.Callable; + +import static io.netty.handler.codec.http.HttpHeaders.Names.*; +import static io.netty.handler.codec.http.HttpMethod.*; +import static io.netty.handler.codec.http.HttpVersion.*; +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; + public class CorsHandlerTest { @Test @@ -77,6 +78,54 @@ public class CorsHandlerTest { assertThat(response.headers().getAll(ACCESS_CONTROL_ALLOW_HEADERS), hasItems("content-type", "xheader1")); } + @Test + public void preflightRequestWithDefaultHeaders() { + final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888").build(); + final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); + assertThat(response.headers().get(CONTENT_LENGTH), is("0")); + assertThat(response.headers().get(DATE), is(notNullValue())); + } + + @Test + public void preflightRequestWithCustomHeader() { + final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888") + .preflightResponseHeader("CustomHeader", "somevalue") + .build(); + final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); + assertThat(response.headers().get("CustomHeader"), equalTo("somevalue")); + } + + @Test + public void preflightRequestWithCustomHeaders() { + final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888") + .preflightResponseHeader("CustomHeader", "value1", "value2") + .build(); + final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); + assertThat(response.headers().getAll("CustomHeader"), hasItems("value1", "value2")); + } + + @Test + public void preflightRequestWithCustomHeadersIterable() { + final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888") + .preflightResponseHeader("CustomHeader", Arrays.asList("value1", "value2")) + .build(); + final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); + assertThat(response.headers().getAll("CustomHeader"), hasItems("value1", "value2")); + } + + @Test + public void preflightRequestWithValueGenerator() { + final CorsConfig config = CorsConfig.withOrigin("http://localhost:8888") + .preflightResponseHeader("GenHeader", new Callable() { + @Override + public String call() throws Exception { + return "generatedValue"; + } + }).build(); + final HttpResponse response = preflightRequest(config, "http://localhost:8888", "content-type, xheader1"); + assertThat(response.headers().get("GenHeader"), equalTo("generatedValue")); + } + @Test public void preflightRequestWithNullOrigin() { final String origin = "null";