From 1f6d441de6a3de7c9b78868ff1b9f94dc213ebcf Mon Sep 17 00:00:00 2001 From: Rawven <121878866+Rawven@users.noreply.github.com> Date: Thu, 23 May 2024 11:05:17 +0800 Subject: [PATCH] [Feature 3.3] Triple Rest Cors Support (#14073) * feat(): add cors key * feat(): add base cors class * feat(): add cors class in rpc-triple and rest-spring * feat(): add cors class in rpc-triple and rest-spring * test(): add rpc-triple cors test * fix(): fix CorsUtil bug * fix(): fix CorsUtil bug * fix(): fix objectUtil bug * fix(): fix corsmeta set bug * fix(): fix config load fail bug * fix(): option method can not be look fail * fix(): CorsMeta method will null * fix(): request-header not set will fail * refactor(): improve CorsMeta CorsProcess some code * fix(): coreMeta combine priority * test(): remove rest-spring cors test to sample * docs(): add docs * revert(): test version * fix(): getCorsMeta can be null * fix(): combine can be null * fix(): save option and vary bug * fix(): pom version * fix(): spring version will cause allowPrivateWork resolve error * fix(): ci * refactor(): delete useless code * refactor(): accept some sonarcloud issue * refactor(): add @Nullable to point the CorsMeta Attributes * refactor(): style * fix(): fix prelight logic * fix(): remove credential & privateNetWork report * refactor(): Move globalMetaMerge in RequestMappingResolver * refactor(): use array replace corsConfig string * refactor(): move CorsProcessor to CorsHeaderFilterAdapter * fix(): fix unit test * fix(): fix test failure * fix(): delete useless param * fix(): fix sonarcloud * fix(): fix wrong class place & naming * fix(): fix wrong static global corsMeta * fix(): refactor CorsUtil from sonar issue * feat(rest): refine cors support * feat(rest): refine cors support * feat(rest): refine cors support bugfix * fix(): getBoolean will throw exception when null * fix(rest-spring): fix crossOrigin allowCredentials is string * fix(): fix globalCorsMeta load null * fix(): fix vary header bug * fix(): fix unit test && Fix cors specification * fix(): fix pom * fix(): fix combine bug * fix(): fix some sonar issue * fix(): fix style * feat(rest): refine cors support * fix(): fix style * fix(): fix needed sonar issue * refactor(): refactor CorsMeta.combine() and add comment * fix(): Replenish license * fix(): update test * test(): Refactor the test class and add credential test cases * test(): Refactor the test class and add credential test cases * fix(rest): revert api HeaderFilter * fix(): accept sonar issue --------- Co-authored-by: Sean Yang Co-authored-by: earthchen --- .licenserc.yaml | 1 + .../org/apache/dubbo/config/CorsConfig.java | 117 +++++ .../org/apache/dubbo/config/RestConfig.java | 16 + .../jaxrs/JaxrsRequestMappingResolver.java | 9 + .../SpringMvcRequestMappingResolver.java | 38 +- .../compatible/SpringDemoServiceImpl.java | 2 + .../AbstractServerHttpChannelObserver.java | 1 + .../dubbo/remoting/http12/HttpStatus.java | 1 + .../h12/AbstractServerTransportListener.java | 4 +- .../rpc/protocol/tri/rest/RestConstants.java | 9 + .../tri/rest/cors/CorsHeaderFilter.java | 273 +++++++++++ .../rpc/protocol/tri/rest/cors/CorsUtils.java | 53 +++ .../rest/filter/RestHeaderFilterAdapter.java | 8 +- .../tri/rest/mapping/RequestMapping.java | 55 ++- .../mapping/condition/MethodsCondition.java | 7 + .../tri/rest/mapping/meta/CorsMeta.java | 306 +++++++++++++ .../org.apache.dubbo.rpc.HeaderFilter | 1 + .../tri/rest/cors/CorsHeaderFilterTest.java | 423 ++++++++++++++++++ pom.xml | 2 + 19 files changed, 1301 insertions(+), 25 deletions(-) create mode 100644 dubbo-common/src/main/java/org/apache/dubbo/config/CorsConfig.java create mode 100644 dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java create mode 100644 dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsUtils.java create mode 100644 dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/mapping/meta/CorsMeta.java create mode 100644 dubbo-rpc/dubbo-rpc-triple/src/main/resources/META-INF/dubbo/internal/org.apache.dubbo.rpc.HeaderFilter create mode 100644 dubbo-rpc/dubbo-rpc-triple/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilterTest.java diff --git a/.licenserc.yaml b/.licenserc.yaml index e497f4b10e..98957a25f7 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -79,6 +79,7 @@ header: - 'dubbo-config/dubbo-config-spring/src/test/java/org/apache/dubbo/config/spring/EmbeddedZooKeeper.java' - 'dubbo-test/dubbo-test-common/src/main/java/org/apache/dubbo/test/common/utils/TestSocketUtils.java' - 'dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/TriHttp2RemoteFlowController.java' + - 'dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java' - 'dubbo-common/src/main/java/org/apache/dubbo/common/threadpool/serial/SerializingExecutor.java' - 'dubbo-maven-plugin/src/main/java/org/apache/dubbo/maven/plugin/aot/AbstractAotMojo.java' - 'dubbo-maven-plugin/src/main/java/org/apache/dubbo/maven/plugin/aot/AbstractDependencyFilterMojo.java' diff --git a/dubbo-common/src/main/java/org/apache/dubbo/config/CorsConfig.java b/dubbo-common/src/main/java/org/apache/dubbo/config/CorsConfig.java new file mode 100644 index 0000000000..657f20d039 --- /dev/null +++ b/dubbo-common/src/main/java/org/apache/dubbo/config/CorsConfig.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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.apache.dubbo.config; + +import java.io.Serializable; + +public class CorsConfig implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * A list of origins for which cross-origin requests are allowed. Values may be a specific domain, e.g. + * {@code "https://domain1.com"}, or the CORS defined special value {@code "*"} for all origins. + *

By default this is not set which means that no origins are allowed. + * However, an instance of this class is often initialized further, e.g. for {@code @CrossOrigin}, via + * {@code org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta.Builder#applyDefault()}. + */ + private String[] allowedOrigins; + + /** + * Set the HTTP methods to allow, e.g. {@code "GET"}, {@code "POST"}, + * {@code "PUT"}, etc. The special value {@code "*"} allows all methods. + *

If not set, only {@code "GET"} and {@code "HEAD"} are allowed. + *

By default this is not set. + */ + private String[] allowedMethods; + + /** + * /** + * Set the list of headers that a pre-flight request can list as allowed + * for use during an actual request. The special value {@code "*"} allows + * actual requests to send any header. + *

By default this is not set. + */ + private String[] allowedHeaders; + + /** + * Set the list of response headers that an actual response might have + * and can be exposed to the client. The special value {@code "*"} + * allows all headers to be exposed. + *

By default this is not set. + */ + private String[] exposedHeaders; + + /** + * Whether user credentials are supported. + *

By default this is not set (i.e. user credentials are not supported). + */ + private Boolean allowCredentials; + + /** + * Configure how long, as a duration, the response from a pre-flight request + * can be cached by clients. + */ + private Long maxAge; + + public String[] getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(String[] allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public String[] getAllowedMethods() { + return allowedMethods; + } + + public void setAllowedMethods(String[] allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public String[] getAllowedHeaders() { + return allowedHeaders; + } + + public void setAllowedHeaders(String[] allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public String[] getExposedHeaders() { + return exposedHeaders; + } + + public void setExposedHeaders(String[] exposedHeaders) { + this.exposedHeaders = exposedHeaders; + } + + public Boolean getAllowCredentials() { + return allowCredentials; + } + + public void setAllowCredentials(Boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } + + public Long getMaxAge() { + return maxAge; + } + + public void setMaxAge(Long maxAge) { + this.maxAge = maxAge; + } +} diff --git a/dubbo-common/src/main/java/org/apache/dubbo/config/RestConfig.java b/dubbo-common/src/main/java/org/apache/dubbo/config/RestConfig.java index 6a3674e119..dc7e988928 100644 --- a/dubbo-common/src/main/java/org/apache/dubbo/config/RestConfig.java +++ b/dubbo-common/src/main/java/org/apache/dubbo/config/RestConfig.java @@ -16,6 +16,8 @@ */ package org.apache.dubbo.config; +import org.apache.dubbo.config.support.Nested; + import java.io.Serializable; /** @@ -68,6 +70,12 @@ public class RestConfig implements Serializable { */ private String formatParameterName; + /** + * The config is used to set the Global CORS configuration properties. + */ + @Nested + private CorsConfig cors; + public Integer getMaxBodySize() { return maxBodySize; } @@ -115,4 +123,12 @@ public class RestConfig implements Serializable { public void setFormatParameterName(String formatParameterName) { this.formatParameterName = formatParameterName; } + + public CorsConfig getCors() { + return cors; + } + + public void setCors(CorsConfig cors) { + this.cors = cors; + } } diff --git a/dubbo-plugin/dubbo-rest-jaxrs/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/jaxrs/JaxrsRequestMappingResolver.java b/dubbo-plugin/dubbo-rest-jaxrs/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/jaxrs/JaxrsRequestMappingResolver.java index 9d7dc5a143..72cb325351 100644 --- a/dubbo-plugin/dubbo-rest-jaxrs/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/jaxrs/JaxrsRequestMappingResolver.java +++ b/dubbo-plugin/dubbo-rest-jaxrs/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/jaxrs/JaxrsRequestMappingResolver.java @@ -18,12 +18,14 @@ package org.apache.dubbo.rpc.protocol.tri.rest.support.jaxrs; import org.apache.dubbo.common.extension.Activate; import org.apache.dubbo.rpc.model.FrameworkModel; +import org.apache.dubbo.rpc.protocol.tri.rest.cors.CorsUtils; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMapping; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMapping.Builder; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMappingResolver; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.condition.ServiceVersionCondition; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.AnnotationMeta; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.AnnotationSupport; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.MethodMeta; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.ServiceMeta; import org.apache.dubbo.rpc.protocol.tri.rest.util.RestToolKit; @@ -31,9 +33,12 @@ import org.apache.dubbo.rpc.protocol.tri.rest.util.RestToolKit; @Activate(onClass = "javax.ws.rs.Path") public class JaxrsRequestMappingResolver implements RequestMappingResolver { + private final FrameworkModel frameworkModel; private final RestToolKit toolKit; + private CorsMeta globalCorsMeta; public JaxrsRequestMappingResolver(FrameworkModel frameworkModel) { + this.frameworkModel = frameworkModel; toolKit = new JaxrsRestToolKit(frameworkModel); } @@ -65,10 +70,14 @@ public class JaxrsRequestMappingResolver implements RequestMappingResolver { return null; } ServiceMeta serviceMeta = methodMeta.getServiceMeta(); + if (globalCorsMeta == null) { + globalCorsMeta = CorsUtils.getGlobalCorsMeta(frameworkModel); + } return builder(methodMeta, path, httpMethod) .name(methodMeta.getMethod().getName()) .contextPath(methodMeta.getServiceMeta().getContextPath()) .custom(new ServiceVersionCondition(serviceMeta.getServiceGroup(), serviceMeta.getServiceVersion())) + .cors(globalCorsMeta) .build(); } diff --git a/dubbo-plugin/dubbo-rest-spring/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/SpringMvcRequestMappingResolver.java b/dubbo-plugin/dubbo-rest-spring/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/SpringMvcRequestMappingResolver.java index ca4dd68a3b..e6de4f568b 100644 --- a/dubbo-plugin/dubbo-rest-spring/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/SpringMvcRequestMappingResolver.java +++ b/dubbo-plugin/dubbo-rest-spring/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/SpringMvcRequestMappingResolver.java @@ -16,14 +16,17 @@ */ package org.apache.dubbo.rpc.protocol.tri.rest.support.spring; +import org.apache.dubbo.common.constants.CommonConstants; import org.apache.dubbo.common.extension.Activate; import org.apache.dubbo.common.utils.StringUtils; import org.apache.dubbo.rpc.model.FrameworkModel; +import org.apache.dubbo.rpc.protocol.tri.rest.cors.CorsUtils; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMapping; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMapping.Builder; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMappingResolver; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.condition.ServiceVersionCondition; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.AnnotationMeta; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.MethodMeta; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.ServiceMeta; import org.apache.dubbo.rpc.protocol.tri.rest.util.RestToolKit; @@ -35,6 +38,7 @@ public class SpringMvcRequestMappingResolver implements RequestMappingResolver { private final FrameworkModel frameworkModel; private volatile RestToolKit toolKit; + private CorsMeta globalCorsMeta; public SpringMvcRequestMappingResolver(FrameworkModel frameworkModel) { this.frameworkModel = frameworkModel; @@ -62,9 +66,13 @@ public class SpringMvcRequestMappingResolver implements RequestMappingResolver { return null; } AnnotationMeta responseStatus = serviceMeta.findMergedAnnotation(Annotations.ResponseStatus); + AnnotationMeta crossOrigin = serviceMeta.findMergedAnnotation(Annotations.CrossOrigin); + String[] methods = requestMapping.getStringArray("method"); return builder(requestMapping, responseStatus) + .method(methods) .name(serviceMeta.getType().getSimpleName()) .contextPath(serviceMeta.getContextPath()) + .cors(buildCorsMeta(crossOrigin, methods)) .build(); } @@ -80,10 +88,14 @@ public class SpringMvcRequestMappingResolver implements RequestMappingResolver { } ServiceMeta serviceMeta = methodMeta.getServiceMeta(); AnnotationMeta responseStatus = methodMeta.findMergedAnnotation(Annotations.ResponseStatus); + AnnotationMeta crossOrigin = methodMeta.findMergedAnnotation(Annotations.CrossOrigin); + String[] methods = requestMapping.getStringArray("method"); return builder(requestMapping, responseStatus) + .method(methods) .name(methodMeta.getMethod().getName()) .contextPath(serviceMeta.getContextPath()) .custom(new ServiceVersionCondition(serviceMeta.getServiceGroup(), serviceMeta.getServiceVersion())) + .cors(buildCorsMeta(crossOrigin, methods)) .build(); } @@ -98,10 +110,34 @@ public class SpringMvcRequestMappingResolver implements RequestMappingResolver { } } return builder.path(requestMapping.getValueArray()) - .method(requestMapping.getStringArray("method")) .param(requestMapping.getStringArray("params")) .header(requestMapping.getStringArray("headers")) .consume(requestMapping.getStringArray("consumes")) .produce(requestMapping.getStringArray("produces")); } + + private CorsMeta buildCorsMeta(AnnotationMeta crossOrigin, String[] methods) { + if (globalCorsMeta == null) { + globalCorsMeta = CorsUtils.getGlobalCorsMeta(frameworkModel); + } + if (crossOrigin == null) { + return globalCorsMeta; + } + String[] allowedMethods = crossOrigin.getStringArray("methods"); + if (allowedMethods.length == 0) { + allowedMethods = methods; + if (allowedMethods.length == 0) { + allowedMethods = new String[] {CommonConstants.ANY_VALUE}; + } + } + CorsMeta corsMeta = CorsMeta.builder() + .allowedOrigins(crossOrigin.getStringArray("origins")) + .allowedMethods(allowedMethods) + .allowedHeaders(crossOrigin.getStringArray("allowedHeaders")) + .exposedHeaders(crossOrigin.getStringArray("exposedHeaders")) + .allowCredentials(crossOrigin.getString("allowCredentials")) + .maxAge(crossOrigin.getNumber("maxAge")) + .build(); + return globalCorsMeta.combine(corsMeta); + } } diff --git a/dubbo-plugin/dubbo-rest-spring/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/compatible/SpringDemoServiceImpl.java b/dubbo-plugin/dubbo-rest-spring/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/compatible/SpringDemoServiceImpl.java index bf235fed57..46498e5162 100644 --- a/dubbo-plugin/dubbo-rest-spring/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/compatible/SpringDemoServiceImpl.java +++ b/dubbo-plugin/dubbo-rest-spring/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/support/spring/compatible/SpringDemoServiceImpl.java @@ -23,8 +23,10 @@ import java.util.List; import java.util.Map; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.ExceptionHandler; +@CrossOrigin public class SpringDemoServiceImpl implements SpringRestDemoService { private static Map context; private boolean called; diff --git a/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/AbstractServerHttpChannelObserver.java b/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/AbstractServerHttpChannelObserver.java index 9883a8072e..26ce8b705e 100644 --- a/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/AbstractServerHttpChannelObserver.java +++ b/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/AbstractServerHttpChannelObserver.java @@ -87,6 +87,7 @@ public abstract class AbstractServerHttpChannelObserver implements CustomizableH public final void onError(Throwable throwable) { if (throwable instanceof HttpResultPayloadException) { onNext(((HttpResultPayloadException) throwable).getResult()); + doOnCompleted(null); return; } try { diff --git a/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/HttpStatus.java b/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/HttpStatus.java index f91b628028..f28eb9422f 100644 --- a/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/HttpStatus.java +++ b/dubbo-remoting/dubbo-remoting-http12/src/main/java/org/apache/dubbo/remoting/http12/HttpStatus.java @@ -20,6 +20,7 @@ public enum HttpStatus { OK(200), CREATED(201), ACCEPTED(202), + NO_CONTENT(204), FOUND(302), BAD_REQUEST(400), UNAUTHORIZED(401), diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/h12/AbstractServerTransportListener.java b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/h12/AbstractServerTransportListener.java index 970aa88fa7..fc0961fdc7 100644 --- a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/h12/AbstractServerTransportListener.java +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/h12/AbstractServerTransportListener.java @@ -162,10 +162,10 @@ public abstract class AbstractServerTransportListener

= HttpStatus.BAD_REQUEST.getCode()) { LOGGER.debug("http status exception", e); - return; } + return; } LOGGER.error(INTERNAL_ERROR, "", "", "server internal error", t); } diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/RestConstants.java b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/RestConstants.java index 7a431bd02a..7c512278bb 100644 --- a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/RestConstants.java +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/RestConstants.java @@ -57,5 +57,14 @@ public final class RestConstants { public static final String CASE_SENSITIVE_MATCH_KEY = CONFIG_PREFIX + "case-sensitive-match"; public static final String FORMAT_PARAMETER_NAME_KEY = CONFIG_PREFIX + "format-parameter-name"; + /* Cors Configuration Key */ + public static final String CORS_CONFIG_PREFIX = CONFIG_PREFIX + "cors."; + public static final String ALLOWED_ORIGINS = CORS_CONFIG_PREFIX + "allowed-origins"; + public static final String ALLOWED_METHODS = CORS_CONFIG_PREFIX + "allowed-methods"; + public static final String ALLOWED_HEADERS = CORS_CONFIG_PREFIX + "allowed-headers"; + public static final String ALLOW_CREDENTIALS = CORS_CONFIG_PREFIX + "allow-credentials"; + public static final String EXPOSED_HEADERS = CORS_CONFIG_PREFIX + "exposed-headers"; + public static final String MAX_AGE = CORS_CONFIG_PREFIX + "max-age"; + private RestConstants() {} } diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java new file mode 100644 index 0000000000..f3dbe3eb62 --- /dev/null +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java @@ -0,0 +1,273 @@ +/* + * Copyright 2002-2024 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 + * + * https://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.apache.dubbo.rpc.protocol.tri.rest.cors; + +import org.apache.dubbo.common.constants.CommonConstants; +import org.apache.dubbo.common.extension.Activate; +import org.apache.dubbo.common.utils.ArrayUtils; +import org.apache.dubbo.common.utils.StringUtils; +import org.apache.dubbo.remoting.http12.HttpMethods; +import org.apache.dubbo.remoting.http12.HttpRequest; +import org.apache.dubbo.remoting.http12.HttpResponse; +import org.apache.dubbo.remoting.http12.HttpResult; +import org.apache.dubbo.remoting.http12.HttpStatus; +import org.apache.dubbo.remoting.http12.exception.HttpResultPayloadException; +import org.apache.dubbo.rpc.Invoker; +import org.apache.dubbo.rpc.RpcException; +import org.apache.dubbo.rpc.RpcInvocation; +import org.apache.dubbo.rpc.protocol.tri.rest.RestConstants; +import org.apache.dubbo.rpc.protocol.tri.rest.filter.RestHeaderFilterAdapter; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMapping; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.apache.dubbo.common.constants.CommonConstants.ANY_VALUE; + +/** + * See: DefaultCorsProcessor + */ +@Activate(group = CommonConstants.PROVIDER, order = 1000) +public class CorsHeaderFilter extends RestHeaderFilterAdapter { + + public static final String VARY = "Vary"; + public static final String ORIGIN = "Origin"; + public static final String ACCESS_CONTROL_REQUEST_METHOD = "Access-Control-Request-Method"; + public static final String ACCESS_CONTROL_REQUEST_HEADERS = "Access-Control-Request-Headers"; + public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS = "Access-Control-Allow-Credentials"; + public static final String ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"; + public static final String ACCESS_CONTROL_MAX_AGE = "Access-Control-Max-Age"; + public static final String ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; + public static final String ACCESS_CONTROL_ALLOW_METHODS = "Access-Control-Allow-Methods"; + public static final String ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers"; + public static final String SEP = ", "; + + @Override + protected void invoke(Invoker invoker, RpcInvocation invocation, HttpRequest request, HttpResponse response) + throws RpcException { + RequestMapping mapping = request.attribute(RestConstants.MAPPING_ATTRIBUTE); + CorsMeta cors = mapping.getCors(); + String origin = request.header(ORIGIN); + if (cors == null) { + if (isPreFlightRequest(request, origin)) { + throw new HttpResultPayloadException(HttpResult.builder() + .status(HttpStatus.FORBIDDEN) + .body("Invalid CORS request") + .build()); + } + return; + } + + if (process(cors, request, response)) { + return; + } + + throw new HttpResultPayloadException(HttpResult.builder() + .status(HttpStatus.FORBIDDEN) + .body("Invalid CORS request") + .headers(response.headers()) + .build()); + } + + private boolean process(CorsMeta cors, HttpRequest request, HttpResponse response) { + setVaryHeader(response); + + String origin = request.header(ORIGIN); + if (isNotCorsRequest(request, origin)) { + return true; + } + + if (response.header(ACCESS_CONTROL_ALLOW_ORIGIN) != null) { + return true; + } + + String allowOrigin = checkOrigin(cors, origin); + if (allowOrigin == null) { + return false; + } + + boolean preFlight = isPreFlightRequest(request, origin); + + List allowMethods = + checkMethods(cors, preFlight ? request.header(ACCESS_CONTROL_REQUEST_METHOD) : request.method()); + if (allowMethods == null) { + return false; + } + + List allowHeaders = null; + if (preFlight) { + allowHeaders = checkHeaders(cors, request.headerValues(ACCESS_CONTROL_REQUEST_HEADERS)); + if (allowHeaders == null) { + return false; + } + } + + response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, allowOrigin); + + if (ArrayUtils.isNotEmpty(cors.getExposedHeaders())) { + response.setHeader(ACCESS_CONTROL_EXPOSE_HEADERS, StringUtils.join(cors.getExposedHeaders(), SEP)); + } + + if (Boolean.TRUE.equals(cors.getAllowCredentials())) { + response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, Boolean.TRUE.toString()); + } + + if (preFlight) { + response.setHeader(ACCESS_CONTROL_ALLOW_METHODS, StringUtils.join(allowMethods, SEP)); + + if (!allowHeaders.isEmpty()) { + response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, StringUtils.join(allowHeaders, SEP)); + } + if (cors.getMaxAge() != null) { + response.setHeader(ACCESS_CONTROL_MAX_AGE, cors.getMaxAge().toString()); + } + throw new HttpResultPayloadException(HttpResult.builder() + .status(HttpStatus.NO_CONTENT) + .headers(response.headers()) + .build()); + } + + return true; + } + + private static void setVaryHeader(HttpResponse response) { + List varyHeaders = response.headerValues(VARY); + String varyValue; + if (varyHeaders == null) { + varyValue = ORIGIN + SEP + ACCESS_CONTROL_REQUEST_METHOD + SEP + ACCESS_CONTROL_REQUEST_HEADERS; + } else { + Set varHeadersSet = new LinkedHashSet<>(varyHeaders); + varHeadersSet.add(ORIGIN); + varHeadersSet.add(ACCESS_CONTROL_REQUEST_METHOD); + varHeadersSet.add(ACCESS_CONTROL_REQUEST_HEADERS); + varyValue = StringUtils.join(varHeadersSet, SEP); + } + response.setHeader(VARY, varyValue); + } + + private static String checkOrigin(CorsMeta cors, String origin) { + if (StringUtils.isBlank(origin)) { + return null; + } + origin = CorsUtils.formatOrigin(origin); + String[] allowedOrigins = cors.getAllowedOrigins(); + if (ArrayUtils.isNotEmpty(allowedOrigins)) { + if (ArrayUtils.contains(allowedOrigins, ANY_VALUE)) { + if (Boolean.TRUE.equals(cors.getAllowCredentials())) { + throw new IllegalArgumentException( + "When allowCredentials is true, allowedOrigins cannot contain the special value \"*\""); + } + return ANY_VALUE; + } + for (String allowedOrigin : allowedOrigins) { + if (origin.equalsIgnoreCase(allowedOrigin)) { + return origin; + } + } + } + if (ArrayUtils.isNotEmpty(cors.getAllowedOriginsPatterns())) { + for (Pattern pattern : cors.getAllowedOriginsPatterns()) { + if (pattern.matcher(origin).matches()) { + return origin; + } + } + } + return null; + } + + private static List checkMethods(CorsMeta cors, String method) { + if (method == null) { + return null; + } + String[] allowedMethods = cors.getAllowedMethods(); + if (ArrayUtils.contains(allowedMethods, ANY_VALUE)) { + return Collections.singletonList(method); + } + for (String allowedMethod : allowedMethods) { + if (method.equalsIgnoreCase(allowedMethod)) { + return Arrays.asList(allowedMethods); + } + } + return null; + } + + private static List checkHeaders(CorsMeta cors, Collection headers) { + if (headers == null || headers.isEmpty()) { + return Collections.emptyList(); + } + String[] allowedHeaders = cors.getAllowedHeaders(); + if (ArrayUtils.isEmpty(allowedHeaders)) { + return null; + } + + boolean allowAny = ArrayUtils.contains(allowedHeaders, ANY_VALUE); + List result = new ArrayList<>(headers.size()); + for (String header : headers) { + if (allowAny) { + result.add(header); + continue; + } + for (String allowedHeader : allowedHeaders) { + if (header.equalsIgnoreCase(allowedHeader)) { + result.add(header); + break; + } + } + } + return result.isEmpty() ? null : result; + } + + private static boolean isNotCorsRequest(HttpRequest request, String origin) { + if (origin == null) { + return true; + } + try { + URI uri = new URI(origin); + return request.scheme().equals(uri.getScheme()) + && request.serverName().equals(uri.getHost()) + && getPort(request.scheme(), request.serverPort()) == getPort(uri.getScheme(), uri.getPort()); + } catch (URISyntaxException e) { + return false; + } + } + + private static boolean isPreFlightRequest(HttpRequest request, String origin) { + return request.method().equals(HttpMethods.OPTIONS.name()) + && origin != null + && request.hasHeader(ACCESS_CONTROL_REQUEST_METHOD); + } + + private static int getPort(String scheme, int port) { + if (port == -1) { + if ("http".equals(scheme)) { + return 80; + } + if ("https".equals(scheme)) { + return 443; + } + } + return port; + } +} diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsUtils.java b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsUtils.java new file mode 100644 index 0000000000..a9aab0bd91 --- /dev/null +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsUtils.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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.apache.dubbo.rpc.protocol.tri.rest.cors; + +import org.apache.dubbo.common.config.Configuration; +import org.apache.dubbo.common.config.ConfigurationUtils; +import org.apache.dubbo.common.utils.StringUtils; +import org.apache.dubbo.rpc.model.FrameworkModel; +import org.apache.dubbo.rpc.protocol.tri.rest.RestConstants; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta; + +public class CorsUtils { + + private CorsUtils() {} + + public static CorsMeta getGlobalCorsMeta(FrameworkModel frameworkModel) { + Configuration config = ConfigurationUtils.getGlobalConfiguration(frameworkModel.defaultApplication()); + + String maxAge = config.getString(RestConstants.MAX_AGE); + return CorsMeta.builder() + .allowedOrigins(getValues(config, RestConstants.ALLOWED_ORIGINS)) + .allowedMethods(getValues(config, RestConstants.ALLOWED_METHODS)) + .allowedHeaders(getValues(config, RestConstants.ALLOWED_HEADERS)) + .allowCredentials(config.getString(RestConstants.ALLOW_CREDENTIALS)) + .exposedHeaders(getValues(config, RestConstants.EXPOSED_HEADERS)) + .maxAge(maxAge == null ? null : Long.valueOf(maxAge)) + .build(); + } + + private static String[] getValues(Configuration config, String key) { + return StringUtils.tokenize(config.getString(key), ','); + } + + public static String formatOrigin(String value) { + value = value.trim(); + int last = value.length() - 1; + return last > -1 && value.charAt(last) == '/' ? value.substring(0, last) : value; + } +} diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/filter/RestHeaderFilterAdapter.java b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/filter/RestHeaderFilterAdapter.java index abbf4a7a5a..bb6f0927fb 100644 --- a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/filter/RestHeaderFilterAdapter.java +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/filter/RestHeaderFilterAdapter.java @@ -19,7 +19,6 @@ package org.apache.dubbo.rpc.protocol.tri.rest.filter; import org.apache.dubbo.remoting.http12.HttpRequest; import org.apache.dubbo.remoting.http12.HttpResponse; import org.apache.dubbo.rpc.HeaderFilter; -import org.apache.dubbo.rpc.Invocation; import org.apache.dubbo.rpc.Invoker; import org.apache.dubbo.rpc.RpcException; import org.apache.dubbo.rpc.RpcInvocation; @@ -32,11 +31,12 @@ public abstract class RestHeaderFilterAdapter implements HeaderFilter { if (TripleConstant.TRIPLE_HANDLER_TYPE_REST.equals(invocation.get(TripleConstant.HANDLER_TYPE_KEY))) { HttpRequest request = (HttpRequest) invocation.get(TripleConstant.HTTP_REQUEST_KEY); HttpResponse response = (HttpResponse) invocation.get(TripleConstant.HTTP_RESPONSE_KEY); - return invoke(invoker, invocation, request, response); + invoke(invoker, invocation, request, response); } return invocation; } - protected abstract RpcInvocation invoke( - Invoker invoker, Invocation invocation, HttpRequest request, HttpResponse response) throws RpcException; + protected abstract void invoke( + Invoker invoker, RpcInvocation invocation, HttpRequest request, HttpResponse response) + throws RpcException; } diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/mapping/RequestMapping.java b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/mapping/RequestMapping.java index 2c38367c71..5d00ad8eac 100644 --- a/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/mapping/RequestMapping.java +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/mapping/RequestMapping.java @@ -27,6 +27,7 @@ import org.apache.dubbo.rpc.protocol.tri.rest.mapping.condition.ParamsCondition; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.condition.PathCondition; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.condition.PathExpression; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.condition.ProducesCondition; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta; import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.ResponseMeta; import java.util.Objects; @@ -43,6 +44,7 @@ public final class RequestMapping implements Condition customCondition, + CorsMeta cors, ResponseMeta response) { this.name = name; this.pathCondition = pathCondition; @@ -65,6 +68,7 @@ public final class RequestMapping implements Condition> T combine(T value, T other) { - return value == null ? other : other == null ? value : value.combine(other); + private > T combine(T source, T other) { + return source == null ? other : other == null ? source : source.combine(other); + } + + private CorsMeta combine(CorsMeta source, CorsMeta other) { + return source == null || source.isEmpty() + ? other == null || other.isEmpty() ? null : other.applyDefault() + : source.combine(other).applyDefault(); } public RequestMapping match(HttpRequest request, PathExpression path) { @@ -156,7 +168,7 @@ public final class RequestMapping implements Condition Set merge(T[] source, T[] other) { + int size = source.length + other.length; + if (size == 0) { + return Collections.emptySet(); + } + Set merged = CollectionUtils.newLinkedHashSet(size); + Collections.addAll(merged, source); + Collections.addAll(merged, other); + return merged; + } + + @Override + public String toString() { + return "CorsMeta{" + + "allowedOrigins=" + Arrays.toString(allowedOrigins) + + ", allowedOriginsPatterns=" + Arrays.toString(allowedOriginsPatterns) + + ", allowedMethods=" + Arrays.toString(allowedMethods) + + ", allowedHeaders=" + Arrays.toString(allowedHeaders) + + ", exposedHeaders=" + Arrays.toString(exposedHeaders) + + ", allowCredentials=" + allowCredentials + + ", maxAge=" + maxAge + + '}'; + } + + public static final class Builder { + + private static final Pattern PORTS_PATTERN = Pattern.compile("(.*):\\[(\\*|\\d+(,\\d+)*)]"); + + private final Set allowedOrigins = new LinkedHashSet<>(); + private final Set allowedMethods = new LinkedHashSet<>(); + private final Set allowedHeaders = new LinkedHashSet<>(); + private final Set exposedHeaders = new LinkedHashSet<>(); + private Boolean allowCredentials; + private Long maxAge; + + public Builder allowedOrigins(String... origins) { + addValues(allowedOrigins, CorsUtils::formatOrigin, origins); + return this; + } + + public Builder allowedMethods(String... methods) { + addValues(allowedMethods, v -> v.trim().toUpperCase(), methods); + return this; + } + + public Builder allowedHeaders(String... headers) { + addValues(allowedHeaders, String::trim, headers); + return this; + } + + public Builder exposedHeaders(String... headers) { + addValues(exposedHeaders, String::trim, headers); + return this; + } + + private static void addValues(Set set, Function fn, String... values) { + if (values == null || set.contains(ANY_VALUE)) { + return; + } + for (String value : values) { + if (StringUtils.isNotEmpty(value)) { + value = fn.apply(value); + if (value.isEmpty()) { + continue; + } + if (ANY_VALUE.equals(value)) { + set.clear(); + set.add(ANY_VALUE); + return; + } + set.add(value); + } + } + } + + private static Pattern initPattern(String patternValue) { + String ports = null; + Matcher matcher = PORTS_PATTERN.matcher(patternValue); + if (matcher.matches()) { + patternValue = matcher.group(1); + ports = matcher.group(2); + } + patternValue = "\\Q" + patternValue + "\\E"; + patternValue = patternValue.replace("*", "\\E.*\\Q"); + if (ports != null) { + patternValue += (ANY_VALUE.equals(ports) ? "(:\\d+)?" : ":(" + ports.replace(',', '|') + ")"); + } + return Pattern.compile(patternValue); + } + + public Builder allowCredentials(Boolean allowCredentials) { + this.allowCredentials = allowCredentials; + return this; + } + + public Builder allowCredentials(String allowCredentials) { + if ("true".equals(allowCredentials)) { + this.allowCredentials = true; + } else if ("false".equals(allowCredentials)) { + this.allowCredentials = false; + } + return this; + } + + public Builder maxAge(Long maxAge) { + if (maxAge != null && maxAge > -1) { + this.maxAge = maxAge; + } + return this; + } + + public CorsMeta build() { + int len = allowedOrigins.size(); + String[] origins = new String[len]; + List originsPatterns = new ArrayList<>(len); + int i = 0; + for (String origin : allowedOrigins) { + origins[i++] = origin; + if (ANY_VALUE.equals(origin)) { + continue; + } + originsPatterns.add(initPattern(origin)); + } + return new CorsMeta( + origins, + originsPatterns.toArray(new Pattern[0]), + allowedMethods.toArray(EMPTY_STRING_ARRAY), + allowedHeaders.toArray(EMPTY_STRING_ARRAY), + exposedHeaders.toArray(EMPTY_STRING_ARRAY), + allowCredentials, + maxAge); + } + } +} diff --git a/dubbo-rpc/dubbo-rpc-triple/src/main/resources/META-INF/dubbo/internal/org.apache.dubbo.rpc.HeaderFilter b/dubbo-rpc/dubbo-rpc-triple/src/main/resources/META-INF/dubbo/internal/org.apache.dubbo.rpc.HeaderFilter new file mode 100644 index 0000000000..fa26599c25 --- /dev/null +++ b/dubbo-rpc/dubbo-rpc-triple/src/main/resources/META-INF/dubbo/internal/org.apache.dubbo.rpc.HeaderFilter @@ -0,0 +1 @@ +rest-cors=org.apache.dubbo.rpc.protocol.tri.rest.cors.CorsHeaderFilter \ No newline at end of file diff --git a/dubbo-rpc/dubbo-rpc-triple/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilterTest.java b/dubbo-rpc/dubbo-rpc-triple/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilterTest.java new file mode 100644 index 0000000000..70ef114483 --- /dev/null +++ b/dubbo-rpc/dubbo-rpc-triple/src/test/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilterTest.java @@ -0,0 +1,423 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF 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.apache.dubbo.rpc.protocol.tri.rest.cors; + +import org.apache.dubbo.remoting.http12.HttpMethods; +import org.apache.dubbo.remoting.http12.HttpRequest; +import org.apache.dubbo.remoting.http12.HttpResponse; +import org.apache.dubbo.remoting.http12.HttpStatus; +import org.apache.dubbo.remoting.http12.exception.HttpResultPayloadException; +import org.apache.dubbo.remoting.http12.message.DefaultHttpResponse; +import org.apache.dubbo.rpc.protocol.tri.rest.RestConstants; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.RequestMapping; +import org.apache.dubbo.rpc.protocol.tri.rest.mapping.meta.CorsMeta; + +import java.util.Arrays; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class CorsHeaderFilterTest { + + private HttpRequest request; + + private HttpResponse response; + + private MockCorsHeaderFilter processor; + + private RequestMapping build; + + static class MockCorsHeaderFilter extends CorsHeaderFilter { + public void process(HttpRequest request, HttpResponse response) { + invoke(null, null, request, response); + } + + public void preLightProcess(HttpRequest request, HttpResponse response, int code) { + try { + process(request, response); + Assertions.fail(); + } catch (HttpResultPayloadException e) { + Assertions.assertEquals(code, e.getStatusCode()); + } catch (Exception e) { + Assertions.fail(); + } + } + } + + private CorsMeta defaultCorsMeta() { + return CorsMeta.builder().maxAge(1000L).build(); + } + + @BeforeEach + public void setup() { + build = Mockito.mock(RequestMapping.class); + request = Mockito.mock(HttpRequest.class); + Mockito.when(request.attribute(RestConstants.MAPPING_ATTRIBUTE)).thenReturn(build); + Mockito.when(request.uri()).thenReturn("/test.html"); + Mockito.when(request.serverName()).thenReturn("domain1.example"); + Mockito.when(request.scheme()).thenReturn("http"); + Mockito.when(request.serverPort()).thenReturn(80); + Mockito.when(request.remoteHost()).thenReturn("127.0.0.1"); + response = new DefaultHttpResponse(); + response.setStatus(HttpStatus.OK.getCode()); + processor = new MockCorsHeaderFilter(); + } + + @Test + void requestWithoutOriginHeader() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(build.getCors()).thenReturn(CorsMeta.builder().build()); + Mockito.when(build.getCors()).thenReturn(defaultCorsMeta()); + processor.process(request, response); + Assertions.assertFalse(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertTrue(response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ORIGIN)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + } + + @Test + void sameOriginRequest() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("http://domain1.example"); + Mockito.when(build.getCors()).thenReturn(defaultCorsMeta()); + processor.process(request, response); + Assertions.assertFalse(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertTrue(response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ORIGIN)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + } + + @Test + void actualRequestWithOriginHeader() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(build.getCors()).thenReturn(defaultCorsMeta()); + Assertions.assertThrows(HttpResultPayloadException.class, () -> processor.process(request, response)); + } + + @Test + void actualRequestWithOriginHeaderAndNullConfig() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(build.getCors()).thenReturn(null); + processor.process(request, response); + Assertions.assertFalse(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + } + + @Test + void actualRequestWithOriginHeaderAndAllowedOrigin() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(build.getCors()).thenReturn(CorsMeta.builder().build().applyDefault()); + processor.process(request, response); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertEquals("*", response.header(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertFalse(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_MAX_AGE)); + Assertions.assertFalse(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_EXPOSE_HEADERS)); + Assertions.assertTrue(response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ORIGIN)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + } + + @Test + void actualRequestCaseInsensitiveOriginMatch() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder() + .allowedOrigins("https://DOMAIN2.com") + .build() + .applyDefault()); + processor.process(request, response); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + @Test + void actualRequestTrailingSlashOriginMatch() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder() + .allowedOrigins("https://domain2.com/") + .build() + .applyDefault()); + processor.process(request, response); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + @Test + void actualRequestExposedHeaders() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.doReturn(CorsMeta.builder() + .allowedOrigins("https://domain2.com") + .exposedHeaders("header1", "header2") + .build() + .applyDefault()) + .when(build) + .getCors(); + processor.process(request, response); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertEquals("https://domain2.com", response.header(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_EXPOSE_HEADERS)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.ACCESS_CONTROL_EXPOSE_HEADERS).contains("header1")); + Assertions.assertTrue( + response.header(CorsHeaderFilter.ACCESS_CONTROL_EXPOSE_HEADERS).contains("header2")); + Assertions.assertTrue(response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ORIGIN)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + } + + @Test + void actualRequestCredentials() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.doReturn(CorsMeta.builder() + .allowedOrigins("https://domain1.com", "https://domain2.com") + .allowCredentials(true) + .build() + .applyDefault()) + .when(build) + .getCors(); + processor.process(request, response); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertEquals("https://domain2.com", response.header(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_ORIGIN)); + Assertions.assertTrue(response.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + Assertions.assertEquals("true", response.header(CorsHeaderFilter.ACCESS_CONTROL_ALLOW_CREDENTIALS)); + Assertions.assertTrue(response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ORIGIN)); + Assertions.assertEquals(HttpStatus.OK.getCode(), response.status()); + } + + @Test + void actualRequestCredentialsWithWildcardOrigin() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.doReturn(CorsMeta.builder() + .allowedOrigins("*") + .allowCredentials(true) + .build() + .applyDefault()) + .when(build) + .getCors(); + + Assertions.assertThrows(IllegalArgumentException.class, () -> processor.process(request, response)); + } + + @Test + void preflightRequestWrongAllowedMethod() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("DELETE"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder().allowedOrigins("*").build()); + processor.preLightProcess(request, response, HttpStatus.FORBIDDEN.getCode()); + } + + @Test + void preflightRequestMatchedAllowedMethod() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(build.getCors()).thenReturn(CorsMeta.builder().build().applyDefault()); + processor.preLightProcess(request, response, HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void preflightRequestTestWithOriginButWithoutOtherHeaders() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(build.getCors()).thenReturn(defaultCorsMeta()); + processor.preLightProcess(request, response, HttpStatus.FORBIDDEN.getCode()); + } + + @Test + void preflightRequestWithoutRequestMethod() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn("Header1"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(build.getCors()).thenReturn(defaultCorsMeta()); + processor.preLightProcess(request, response, HttpStatus.FORBIDDEN.getCode()); + } + + @Test + void preflightRequestWithRequestAndMethodHeaderButNoConfig() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn("Header1"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(build.getCors()).thenReturn(defaultCorsMeta()); + processor.preLightProcess(request, response, HttpStatus.FORBIDDEN.getCode()); + } + + @Test + void preflightRequestValidRequestAndConfig() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn("Header1"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder() + .allowedOrigins("*") + .allowedMethods("GET", "PUT") + .allowedHeaders("Header1", "Header2") + .build()); + processor.preLightProcess(request, response, HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void preflightRequestAllowedHeaders() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.doReturn(Arrays.asList("Header1", "Header2")) + .when(request) + .headerValues(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS); + Mockito.doReturn(CorsMeta.builder() + .allowedOrigins("https://domain2.com") + .allowedHeaders("Header1", "Header2") + .build() + .applyDefault()) + .when(build) + .getCors(); + processor.preLightProcess(request, response, HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void preflightRequestAllowsAllHeaders() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(request.headerValues(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn(Arrays.asList("Header1", "Header2")); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder() + .allowedOrigins("https://domain2.com") + .allowedHeaders("*") + .build() + .applyDefault()); + processor.preLightProcess(request, response, HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void preflightRequestWithEmptyHeaders() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.when(request.hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn(true); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn(""); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder() + .allowedOrigins("https://domain2.com") + .allowedHeaders("*") + .build() + .applyDefault()); + processor.preLightProcess(request, response, HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void preflightRequestWithNullConfig() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.when(build.getCors()) + .thenReturn(CorsMeta.builder().allowedOrigins("*").build()); + processor.preLightProcess(request, response, HttpStatus.FORBIDDEN.getCode()); + } + + @Test + void preflightRequestCredentials() { + Mockito.when(request.method()).thenReturn(HttpMethods.OPTIONS.name()); + Mockito.when(request.header(CorsHeaderFilter.ORIGIN)).thenReturn("https://domain2.com"); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + Mockito.doReturn(true).when(request).hasHeader(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD); + Mockito.when(request.header(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn("Header1"); + Mockito.doReturn(CorsMeta.builder() + .allowedOrigins("https://domain1.com", "https://domain2.com", "http://domain3.example") + .allowedHeaders("Header1") + .allowCredentials(true) + .build() + .applyDefault()) + .when(build) + .getCors(); + processor.preLightProcess(request, response, HttpStatus.NO_CONTENT.getCode()); + } + + @Test + void preventDuplicatedVaryHeaders() { + Mockito.when(request.method()).thenReturn(HttpMethods.GET.name()); + response.setHeader( + CorsHeaderFilter.VARY, + CorsHeaderFilter.ORIGIN + "," + CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD + "," + + CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS); + processor.process(request, response); + Assertions.assertTrue(response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ORIGIN)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_METHOD)); + Assertions.assertTrue( + response.header(CorsHeaderFilter.VARY).contains(CorsHeaderFilter.ACCESS_CONTROL_REQUEST_HEADERS)); + } +} diff --git a/pom.xml b/pom.xml index b93069ee27..a8a3b1d694 100644 --- a/pom.xml +++ b/pom.xml @@ -571,6 +571,7 @@ **/org/apache/dubbo/test/common/utils/TestSocketUtils.java, **/org/apache/dubbo/triple/TripleWrapper.java, **/org/apache/dubbo/rpc/protocol/tri/TriHttp2RemoteFlowController.java, + **/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java, **/org/apache/dubbo/metrics/aggregate/DubboMergingDigest.java, **/org/apache/dubbo/metrics/aggregate/DubboAbstractTDigest.java, **/org/apache/dubbo/common/logger/helpers/FormattingTuple.java, @@ -842,6 +843,7 @@ src/test/java/org/apache/dubbo/config/spring/EmbeddedZooKeeper.java src/main/java/org/apache/dubbo/test/common/utils/TestSocketUtils.java src/main/java/org/apache/dubbo/rpc/protocol/tri/TriHttp2RemoteFlowController.java + src/main/java/org/apache/dubbo/rpc/protocol/tri/rest/cors/CorsHeaderFilter.java src/main/java/org/apache/dubbo/common/threadpool/serial/SerializingExecutor.java src/main/java/org/apache/dubbo/maven/plugin/aot/AbstractAotMojo.java src/main/java/org/apache/dubbo/maven/plugin/aot/AbstractDependencyFilterMojo.java