From 41aa4badf8270a8e7d4321809fe8b8a34db98968 Mon Sep 17 00:00:00 2001 From: Zhijie Shen Date: Wed, 13 Aug 2014 20:29:23 +0000 Subject: [PATCH] YARN-2277. Added cross-origin support for the timeline server web services. Contributed by Jonathan Eagles. git-svn-id: https://svn.apache.org/repos/asf/hadoop/common/trunk@1617832 13f79535-47bb-0310-9956-ffa450edef68 --- hadoop-yarn-project/CHANGES.txt | 3 + .../timeline/webapp/CrossOriginFilter.java | 220 ++++++++++++++++++ .../webapp/CrossOriginFilterInitializer.java | 42 ++++ .../webapp/TestCrossOriginFilter.java | 214 +++++++++++++++++ .../TestCrossOriginFilterInitializer.java | 60 +++++ 5 files changed, 539 insertions(+) create mode 100644 hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilter.java create mode 100644 hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilterInitializer.java create mode 100644 hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilter.java create mode 100644 hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilterInitializer.java diff --git a/hadoop-yarn-project/CHANGES.txt b/hadoop-yarn-project/CHANGES.txt index 55a61126fe2..99b2d612229 100644 --- a/hadoop-yarn-project/CHANGES.txt +++ b/hadoop-yarn-project/CHANGES.txt @@ -41,6 +41,9 @@ Release 2.6.0 - UNRELEASED YARN-1337. Recover containers upon nodemanager restart. (Jason Lowe via junping_du) + YARN-2277. Added cross-origin support for the timeline server web services. + (Jonathan Eagles via zjshen) + IMPROVEMENTS YARN-2242. Improve exception information on AM launch crashes. (Li Lu diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilter.java b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilter.java new file mode 100644 index 00000000000..a9fb3e875f5 --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilter.java @@ -0,0 +1,220 @@ +/** + * 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.hadoop.yarn.server.timeline.webapp; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import com.google.common.annotations.VisibleForTesting; + +public class CrossOriginFilter implements Filter { + + private static final Log LOG = LogFactory.getLog(CrossOriginFilter.class); + + // HTTP CORS Request Headers + static final String ORIGIN = "Origin"; + static final String ACCESS_CONTROL_REQUEST_METHOD = + "Access-Control-Request-Method"; + static final String ACCESS_CONTROL_REQUEST_HEADERS = + "Access-Control-Request-Headers"; + + // HTTP CORS Response Headers + static final String ACCESS_CONTROL_ALLOW_ORIGIN = + "Access-Control-Allow-Origin"; + static final String ACCESS_CONTROL_ALLOW_CREDENTIALS = + "Access-Control-Allow-Credentials"; + static final String ACCESS_CONTROL_ALLOW_METHODS = + "Access-Control-Allow-Methods"; + static final String ACCESS_CONTROL_ALLOW_HEADERS = + "Access-Control-Allow-Headers"; + static final String ACCESS_CONTROL_MAX_AGE = "Access-Control-Max-Age"; + + // Filter configuration + public static final String ALLOWED_ORIGINS = "allowed-origins"; + public static final String ALLOWED_ORIGINS_DEFAULT = "*"; + public static final String ALLOWED_METHODS = "allowed-methods"; + public static final String ALLOWED_METHODS_DEFAULT = "GET,POST,HEAD"; + public static final String ALLOWED_HEADERS = "allowed-headers"; + public static final String ALLOWED_HEADERS_DEFAULT = + "X-Requested-With,Content-Type,Accept,Origin"; + public static final String MAX_AGE = "max-age"; + public static final String MAX_AGE_DEFAULT = "1800"; + + private List allowedMethods = new ArrayList(); + private List allowedHeaders = new ArrayList(); + private List allowedOrigins = new ArrayList(); + private String maxAge; + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + initializeAllowedMethods(filterConfig); + initializeAllowedHeaders(filterConfig); + initializeAllowedOrigins(filterConfig); + initializeMaxAge(filterConfig); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, + FilterChain chain) + throws IOException, ServletException { + doCrossFilter((HttpServletRequest) req, (HttpServletResponse) res); + chain.doFilter(req, res); + } + + @Override + public void destroy() { + allowedMethods.clear(); + allowedHeaders.clear(); + allowedOrigins.clear(); + } + + private void doCrossFilter(HttpServletRequest req, HttpServletResponse res) { + + String origin = encodeHeader(req.getHeader(ORIGIN)); + if (!isCrossOrigin(origin)) { + return; + } + + if (!isOriginAllowed(origin)) { + return; + } + + String accessControlRequestMethod = + req.getHeader(ACCESS_CONTROL_REQUEST_METHOD); + if (!isMethodAllowed(accessControlRequestMethod)) { + return; + } + + String accessControlRequestHeaders = + req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS); + if (!areHeadersAllowed(accessControlRequestHeaders)) { + return; + } + + res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin); + res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, Boolean.TRUE.toString()); + res.setHeader(ACCESS_CONTROL_ALLOW_METHODS, getAllowedMethodsHeader()); + res.setHeader(ACCESS_CONTROL_ALLOW_HEADERS, getAllowedHeadersHeader()); + res.setHeader(ACCESS_CONTROL_MAX_AGE, maxAge); + } + + @VisibleForTesting + String getAllowedHeadersHeader() { + return StringUtils.join(allowedHeaders, ','); + } + + @VisibleForTesting + String getAllowedMethodsHeader() { + return StringUtils.join(allowedMethods, ','); + } + + private void initializeAllowedMethods(FilterConfig filterConfig) { + String allowedMethodsConfig = + filterConfig.getInitParameter(ALLOWED_METHODS); + if (allowedMethodsConfig == null) { + allowedMethodsConfig = ALLOWED_METHODS_DEFAULT; + } + allowedMethods = + Arrays.asList(allowedMethodsConfig.trim().split("\\s*,\\s*")); + LOG.info("Allowed Methods: " + getAllowedMethodsHeader()); + } + + private void initializeAllowedHeaders(FilterConfig filterConfig) { + String allowedHeadersConfig = + filterConfig.getInitParameter(ALLOWED_HEADERS); + if (allowedHeadersConfig == null) { + allowedHeadersConfig = ALLOWED_HEADERS_DEFAULT; + } + allowedHeaders = + Arrays.asList(allowedHeadersConfig.trim().split("\\s*,\\s*")); + LOG.info("Allowed Headers: " + getAllowedHeadersHeader()); + } + + private void initializeAllowedOrigins(FilterConfig filterConfig) { + String allowedOriginsConfig = + filterConfig.getInitParameter(ALLOWED_ORIGINS); + if (allowedOriginsConfig == null) { + allowedOriginsConfig = ALLOWED_ORIGINS_DEFAULT; + } + allowedOrigins = + Arrays.asList(allowedOriginsConfig.trim().split("\\s*,\\s*")); + LOG.info("Allowed Origins: " + StringUtils.join(allowedOrigins, ',')); + } + + private void initializeMaxAge(FilterConfig filterConfig) { + maxAge = filterConfig.getInitParameter(MAX_AGE); + if (maxAge == null) { + maxAge = MAX_AGE_DEFAULT; + } + LOG.info("Max Age: " + maxAge); + } + + static String encodeHeader(final String header) { + if (header == null) { + return null; + } + try { + // Protect against HTTP response splitting vulnerability + // since value is written as part of the response header + return URLEncoder.encode(header, "ASCII"); + } catch (UnsupportedEncodingException e) { + return null; + } + } + + static boolean isCrossOrigin(String origin) { + return origin != null; + } + + private boolean isOriginAllowed(String origin) { + return allowedOrigins.contains(origin); + } + + private boolean areHeadersAllowed(String accessControlRequestHeaders) { + if (accessControlRequestHeaders == null) { + return true; + } + String headers[] = accessControlRequestHeaders.trim().split("\\s*,\\s*"); + return allowedHeaders.containsAll(Arrays.asList(headers)); + } + + private boolean isMethodAllowed(String accessControlRequestMethod) { + if (accessControlRequestMethod == null) { + return false; + } + return allowedMethods.contains(accessControlRequestMethod); + } +} diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilterInitializer.java b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilterInitializer.java new file mode 100644 index 00000000000..69e0188137a --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/main/java/org/apache/hadoop/yarn/server/timeline/webapp/CrossOriginFilterInitializer.java @@ -0,0 +1,42 @@ +/** + * 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.hadoop.yarn.server.timeline.webapp; + +import java.util.Map; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.http.FilterContainer; +import org.apache.hadoop.http.FilterInitializer; + +public class CrossOriginFilterInitializer extends FilterInitializer { + + public static final String PREFIX = + "yarn.timeline-service.http-cross-origin."; + + @Override + public void initFilter(FilterContainer container, Configuration conf) { + + container.addGlobalFilter("Cross Origin Filter", + CrossOriginFilter.class.getName(), getFilterParameters(conf)); + } + + static Map getFilterParameters(Configuration conf) { + return conf.getValByRegex(PREFIX); + } +} diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilter.java b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilter.java new file mode 100644 index 00000000000..a29e4a0aa6d --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilter.java @@ -0,0 +1,214 @@ +/** + * 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.hadoop.yarn.server.timeline.webapp; + +import java.io.IOException; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.junit.Test; + +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +public class TestCrossOriginFilter { + + @Test + public void testSameOrigin() throws ServletException, IOException { + + // Setup the configuration settings of the server + Map conf = new HashMap(); + conf.put(CrossOriginFilter.ALLOWED_ORIGINS, ""); + FilterConfig filterConfig = new FilterConfigTest(conf); + + // Origin is not specified for same origin requests + HttpServletRequest mockReq = mock(HttpServletRequest.class); + when(mockReq.getHeader(CrossOriginFilter.ORIGIN)).thenReturn(null); + + // Objects to verify interactions based on request + HttpServletResponse mockRes = mock(HttpServletResponse.class); + FilterChain mockChain = mock(FilterChain.class); + + // Object under test + CrossOriginFilter filter = new CrossOriginFilter(); + filter.init(filterConfig); + filter.doFilter(mockReq, mockRes, mockChain); + + verifyZeroInteractions(mockRes); + verify(mockChain).doFilter(mockReq, mockRes); + } + + @Test + public void testDisallowedOrigin() throws ServletException, IOException { + + // Setup the configuration settings of the server + Map conf = new HashMap(); + conf.put(CrossOriginFilter.ALLOWED_ORIGINS, "example.com"); + FilterConfig filterConfig = new FilterConfigTest(conf); + + // Origin is not specified for same origin requests + HttpServletRequest mockReq = mock(HttpServletRequest.class); + when(mockReq.getHeader(CrossOriginFilter.ORIGIN)).thenReturn("example.org"); + + // Objects to verify interactions based on request + HttpServletResponse mockRes = mock(HttpServletResponse.class); + FilterChain mockChain = mock(FilterChain.class); + + // Object under test + CrossOriginFilter filter = new CrossOriginFilter(); + filter.init(filterConfig); + filter.doFilter(mockReq, mockRes, mockChain); + + verifyZeroInteractions(mockRes); + verify(mockChain).doFilter(mockReq, mockRes); + } + + @Test + public void testDisallowedMethod() throws ServletException, IOException { + + // Setup the configuration settings of the server + Map conf = new HashMap(); + conf.put(CrossOriginFilter.ALLOWED_ORIGINS, "example.com"); + FilterConfig filterConfig = new FilterConfigTest(conf); + + // Origin is not specified for same origin requests + HttpServletRequest mockReq = mock(HttpServletRequest.class); + when(mockReq.getHeader(CrossOriginFilter.ORIGIN)).thenReturn("example.com"); + when(mockReq.getHeader(CrossOriginFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("DISALLOWED_METHOD"); + + // Objects to verify interactions based on request + HttpServletResponse mockRes = mock(HttpServletResponse.class); + FilterChain mockChain = mock(FilterChain.class); + + // Object under test + CrossOriginFilter filter = new CrossOriginFilter(); + filter.init(filterConfig); + filter.doFilter(mockReq, mockRes, mockChain); + + verifyZeroInteractions(mockRes); + verify(mockChain).doFilter(mockReq, mockRes); + } + + @Test + public void testDisallowedHeader() throws ServletException, IOException { + + // Setup the configuration settings of the server + Map conf = new HashMap(); + conf.put(CrossOriginFilter.ALLOWED_ORIGINS, "example.com"); + FilterConfig filterConfig = new FilterConfigTest(conf); + + // Origin is not specified for same origin requests + HttpServletRequest mockReq = mock(HttpServletRequest.class); + when(mockReq.getHeader(CrossOriginFilter.ORIGIN)).thenReturn("example.com"); + when(mockReq.getHeader(CrossOriginFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + when(mockReq.getHeader(CrossOriginFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn("Disallowed-Header"); + + // Objects to verify interactions based on request + HttpServletResponse mockRes = mock(HttpServletResponse.class); + FilterChain mockChain = mock(FilterChain.class); + + // Object under test + CrossOriginFilter filter = new CrossOriginFilter(); + filter.init(filterConfig); + filter.doFilter(mockReq, mockRes, mockChain); + + verifyZeroInteractions(mockRes); + verify(mockChain).doFilter(mockReq, mockRes); + } + + @Test + public void testCrossOriginFilter() throws ServletException, IOException { + + // Setup the configuration settings of the server + Map conf = new HashMap(); + conf.put(CrossOriginFilter.ALLOWED_ORIGINS, "example.com"); + FilterConfig filterConfig = new FilterConfigTest(conf); + + // Origin is not specified for same origin requests + HttpServletRequest mockReq = mock(HttpServletRequest.class); + when(mockReq.getHeader(CrossOriginFilter.ORIGIN)).thenReturn("example.com"); + when(mockReq.getHeader(CrossOriginFilter.ACCESS_CONTROL_REQUEST_METHOD)) + .thenReturn("GET"); + when(mockReq.getHeader(CrossOriginFilter.ACCESS_CONTROL_REQUEST_HEADERS)) + .thenReturn("X-Requested-With"); + + // Objects to verify interactions based on request + HttpServletResponse mockRes = mock(HttpServletResponse.class); + FilterChain mockChain = mock(FilterChain.class); + + // Object under test + CrossOriginFilter filter = new CrossOriginFilter(); + filter.init(filterConfig); + filter.doFilter(mockReq, mockRes, mockChain); + + verify(mockRes).setHeader(CrossOriginFilter.ACCESS_CONTROL_ALLOW_ORIGIN, + "example.com"); + verify(mockRes).setHeader( + CrossOriginFilter.ACCESS_CONTROL_ALLOW_CREDENTIALS, + Boolean.TRUE.toString()); + verify(mockRes).setHeader(CrossOriginFilter.ACCESS_CONTROL_ALLOW_METHODS, + filter.getAllowedMethodsHeader()); + verify(mockRes).setHeader(CrossOriginFilter.ACCESS_CONTROL_ALLOW_HEADERS, + filter.getAllowedHeadersHeader()); + verify(mockChain).doFilter(mockReq, mockRes); + } + + private static class FilterConfigTest implements FilterConfig { + + final Map map; + + FilterConfigTest(Map map) { + this.map = map; + } + + @Override + public String getFilterName() { + return "test-filter"; + } + + @Override + public String getInitParameter(String key) { + return map.get(key); + } + + @Override + public Enumeration getInitParameterNames() { + return Collections.enumeration(map.keySet()); + } + + @Override + public ServletContext getServletContext() { + return null; + } + } +} diff --git a/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilterInitializer.java b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilterInitializer.java new file mode 100644 index 00000000000..3199aac5089 --- /dev/null +++ b/hadoop-yarn-project/hadoop-yarn/hadoop-yarn-server/hadoop-yarn-server-applicationhistoryservice/src/test/java/org/apache/hadoop/yarn/server/timeline/webapp/TestCrossOriginFilterInitializer.java @@ -0,0 +1,60 @@ +/** + * 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.hadoop.yarn.server.timeline.webapp; + +import java.util.Map; + +import org.apache.hadoop.conf.Configuration; + +import org.junit.Assert; +import org.junit.Test; + +public class TestCrossOriginFilterInitializer { + + @Test + public void testGetFilterParameters() { + + // Initialize configuration object + Configuration conf = new Configuration(); + conf.set(CrossOriginFilterInitializer.PREFIX + "rootparam", "rootvalue"); + conf.set(CrossOriginFilterInitializer.PREFIX + "nested.param", + "nestedvalue"); + conf.set("outofscopeparam", "outofscopevalue"); + + // call function under test + Map filterParameters = + CrossOriginFilterInitializer.getFilterParameters(conf); + + // retrieve values + String rootvalue = + filterParameters.get(CrossOriginFilterInitializer.PREFIX + "rootparam"); + String nestedvalue = + filterParameters.get(CrossOriginFilterInitializer.PREFIX + + "nested.param"); + String outofscopeparam = filterParameters.get("outofscopeparam"); + + // verify expected values are in place + Assert.assertEquals("Could not find filter parameter", "rootvalue", + rootvalue); + Assert.assertEquals("Could not find filter parameter", "nestedvalue", + nestedvalue); + Assert.assertNull("Found unexpected value in filter parameters", + outofscopeparam); + } +}