diff --git a/java/org/apache/catalina/core/LocalStrings.properties b/java/org/apache/catalina/core/LocalStrings.properties index 66e5067aac0d..aa6e810ba72e 100644 --- a/java/org/apache/catalina/core/LocalStrings.properties +++ b/java/org/apache/catalina/core/LocalStrings.properties @@ -164,6 +164,13 @@ noPluggabilityServletContext.notAllowed=Section 4.4 of the Servlet 3.0 specifica pushBuilder.noPath=It is illegal to call push() before setting a path +propertiesRoleMappingListener.roleMappingFileNull=Role mapping file cannot be null +propertiesRoleMappingListener.roleMappingFileEmpty=Role mapping file cannot be empty +propertiesRoleMappingListener.roleMappingFileNotFound=Role mapping file [{0}] not found +propertiesRoleMappingListener.roleMappingFileFail=Failed to load role mapping file [{0}] +propertiesRoleMappingListener.linkedRole=Successfully linked application role [{0}] to technical role [{1}] +propertiesRoleMappingListener.linkedRoleCount=Linked [{0}] application roles to technical roles + standardContext.applicationListener=Error configuring application listener of class [{0}] standardContext.applicationSkipped=Skipped installing application listeners due to previous error(s) standardContext.backgroundProcess.instanceManager=Exception processing instance manager [{0}] background process diff --git a/java/org/apache/catalina/core/PropertiesRoleMappingListener.java b/java/org/apache/catalina/core/PropertiesRoleMappingListener.java new file mode 100644 index 000000000000..60b135d27f9c --- /dev/null +++ b/java/org/apache/catalina/core/PropertiesRoleMappingListener.java @@ -0,0 +1,168 @@ +/* + * 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.catalina.core; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Properties; + +import org.apache.catalina.Context; +import org.apache.catalina.Lifecycle; +import org.apache.catalina.LifecycleEvent; +import org.apache.catalina.LifecycleListener; +import org.apache.juli.logging.Log; +import org.apache.juli.logging.LogFactory; +import org.apache.tomcat.util.file.ConfigFileLoader; +import org.apache.tomcat.util.res.StringManager; + +/** + * Implementation of {@code LifecycleListener} that will populate the context's role mapping from a properties file. + *

+ * This listener must only be nested within {@link Context} elements. + *

+ * The keys represent application roles (e.g., admin, user, uservisor, etc.) while the values represent technical roles + * (e.g., DNs, SIDs, UUIDs, etc.). A key can also be prefixed if, e.g., the properties file contains generic + * application configuration as well: {@code app-roles.}. + *

+ * Note: The default value for the {@code roleMappingFile} is {@code webapp:/WEB-INF/role-mapping.properties}. + */ +public class PropertiesRoleMappingListener implements LifecycleListener { + + private static final String WEBAPP_PROTOCOL = "webapp:"; + + private static final Log log = LogFactory.getLog(PropertiesRoleMappingListener.class); + /** + * The string manager for this package. + */ + private static final StringManager sm = StringManager.getManager(ContextNamingInfoListener.class); + + private String roleMappingFile = WEBAPP_PROTOCOL + "/WEB-INF/role-mapping.properties"; + private String keyPrefix; + + /** + * Sets the path to the role mapping properties file. You can use protocol {@code webapp:} and whatever + * {@link ConfigFileLoader} supports. + * + * @param roleMappingFile the role mapping properties file to load from + * @throws NullPointerException if roleMappingFile is null + * @throws IllegalArgumentException if roleMappingFile is empty + */ + public void setRoleMappingFile(String roleMappingFile) { + Objects.requireNonNull(roleMappingFile, sm.getString("propertiesRoleMappingListener.roleMappingFileNull")); + if (roleMappingFile.isEmpty()) { + throw new IllegalArgumentException(sm.getString("propertiesRoleMappingListener.roleMappingFileEmpty")); + } + + this.roleMappingFile = roleMappingFile; + } + + /** + * Gets the path to the role mapping properties file. + * + * @return the path to the role mapping properties file + */ + public String getRoleMappingFile() { + return roleMappingFile; + } + + /** + * Sets the prefix to filter from property keys. All other keys will be ignored which do not have the prefix. + * + * @param keyPrefix the properties key prefix + */ + public void setKeyPrefix(String keyPrefix) { + this.keyPrefix = keyPrefix; + } + + /** + * Gets the prefix to filter from property keys. + * + * @return the properties key prefix + */ + public String getKeyPrefix() { + return keyPrefix; + } + + @Override + public void lifecycleEvent(LifecycleEvent event) { + if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) { + if (!(event.getLifecycle() instanceof Context)) { + log.warn(sm.getString("listener.notContext", event.getLifecycle().getClass().getSimpleName())); + return; + } + Context context = (Context) event.getLifecycle(); + + InputStream is; + if (roleMappingFile.startsWith(WEBAPP_PROTOCOL)) { + String path = roleMappingFile.substring(WEBAPP_PROTOCOL.length()); + is = context.getServletContext().getResourceAsStream(path); + } else { + try { + is = ConfigFileLoader.getSource().getResource(roleMappingFile).getInputStream(); + } catch (FileNotFoundException e1) { + is = null; + } catch (IOException e2) { + throw new IllegalStateException( + sm.getString("propertiesRoleMappingListener.roleMappingFileFail", roleMappingFile), e2); + } + } + + if (is == null) { + throw new IllegalStateException( + sm.getString("propertiesRoleMappingListener.roleMappingFileNotFound", roleMappingFile)); + } + + Properties props = new Properties(); + + try (InputStream _is = is) { + props.load(_is); + } catch (IOException e) { + throw new IllegalStateException( + sm.getString("propertiesRoleMappingListener.roleMappingFileFail", roleMappingFile), e); + } + + int linkCount = 0; + for (Entry prop : props.entrySet()) { + String role = (String) prop.getKey(); + + if (keyPrefix != null) { + if (role.startsWith(keyPrefix)) { + role = role.substring(keyPrefix.length()); + } else { + continue; + } + } + + String link = (String) prop.getValue(); + + if (log.isTraceEnabled()) { + log.trace(sm.getString("propertiesRoleMappingListener.linkedRole", role, link)); + } + context.addRoleMapping(role, link); + linkCount++; + } + + if (log.isDebugEnabled()) { + log.debug(sm.getString("propertiesRoleMappingListener.linkedRoleCount", linkCount)); + } + } + } + +} diff --git a/test/org/apache/catalina/core/TestPropertiesRoleMappingListener.java b/test/org/apache/catalina/core/TestPropertiesRoleMappingListener.java new file mode 100644 index 000000000000..9ba4fa38cef8 --- /dev/null +++ b/test/org/apache/catalina/core/TestPropertiesRoleMappingListener.java @@ -0,0 +1,168 @@ +/* + * 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.catalina.core; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.authenticator.BasicAuthenticator; +import org.apache.catalina.servlets.DefaultServlet; +import org.apache.catalina.startup.TesterMapRealm; +import org.apache.catalina.startup.Tomcat; +import org.apache.catalina.startup.TomcatBaseTest; +import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.codec.binary.Base64; +import org.apache.tomcat.util.descriptor.web.LoginConfig; +import org.apache.tomcat.util.descriptor.web.SecurityCollection; +import org.apache.tomcat.util.descriptor.web.SecurityConstraint; +import org.junit.Assert; +import org.junit.Test; + +import jakarta.servlet.http.HttpServletRequest; + +public class TestPropertiesRoleMappingListener extends TomcatBaseTest { + + @Test(expected = NullPointerException.class) + public void testNullRoleMappingFile() throws Exception { + PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener(); + listener.setRoleMappingFile(null); + } + + @Test(expected = IllegalArgumentException.class) + public void testEmptyRoleMappingFile() throws Exception { + PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener(); + listener.setRoleMappingFile(""); + } + + @Test(expected = LifecycleException.class) + public void testNotFoundRoleMappingFile() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + Context ctx = tomcat.addContext("", null); + + PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener(); + ctx.addLifecycleListener(listener); + + try { + tomcat.start(); + } finally { + tomcat.stop(); + } + } + + @Test + public void testFileFromServletContext() throws Exception { + doTest("webapp:/WEB-INF/role-mapping.properties", null); + } + + @Test + public void testFileFromServletContextWithKeyPrefix() throws Exception { + doTest("webapp:/WEB-INF/prefixed-role-mapping.properties", "app-roles."); + } + + @Test + public void testFileFromClasspath() throws Exception { + doTest("classpath:/com/example/role-mapping.properties", null); + } + + @Test + public void testFileFromClasspathWithKeyPrefix() throws Exception { + doTest("classpath:/com/example/prefixed-role-mapping.properties", "app-roles."); + } + + @Test + public void testFileFromFile() throws Exception { + File appDir = new File("test/webapp-role-mapping"); + File file = new File(appDir, "WEB-INF/role-mapping.properties"); + doTest(file.getAbsoluteFile().toURI().toASCIIString(), null); + } + + @Test + public void testFileFromFileWithKeyPrefix() throws Exception { + File appDir = new File("test/webapp-role-mapping"); + File file = new File(appDir, "WEB-INF/prefixed-role-mapping.properties"); + doTest(file.getAbsoluteFile().toURI().toASCIIString(), "app-roles."); + } + + private void doTest(String roleMappingFile, String keyPrefix) throws Exception { + Tomcat tomcat = getTomcatInstance(); + + File appDir = new File("test/webapp-role-mapping"); + Context ctx = tomcat.addContext("", appDir.getAbsolutePath()); + + PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener(); + listener.setRoleMappingFile(roleMappingFile); + listener.setKeyPrefix(keyPrefix); + ctx.addLifecycleListener(listener); + + Tomcat.addServlet(ctx, "default", new DefaultServlet()); + ctx.addServletMappingDecoded("/", "default"); + + LoginConfig loginConfig = new LoginConfig(); + loginConfig.setAuthMethod(HttpServletRequest.BASIC_AUTH); + ctx.setLoginConfig(loginConfig); + ctx.getPipeline().addValve(new BasicAuthenticator()); + + TesterMapRealm realm = new TesterMapRealm(); + realm.addUser("foo", "bar"); + // role 'admin' + realm.addUserRole("foo", "de25f8f5-e534-4980-9351-e316384b1127"); + realm.addUser("waldo", "fred"); + // role 'user' + realm.addUserRole("waldo", "13f6b886-cba8-4b5b-9a1b-06a6fe533356"); + // role 'supervisor' + realm.addUserRole("waldo", "45071e9a-13ef-11ee-89dc-20677cd45840"); + ctx.setRealm(realm); + + for (String role : Arrays.asList("admin", "user", "unmapped")) { + SecurityCollection securityCollection = new SecurityCollection(); + securityCollection.addPattern("/" + role); + SecurityConstraint constraint = new SecurityConstraint(); + constraint.addAuthRole(role); + constraint.addCollection(securityCollection); + ctx.addConstraint(constraint); + ctx.addSecurityRole(role); + } + + tomcat.start(); + + testRequest("foo:bar", "/admin", 200); + testRequest("waldo:fred", "/user", 200); + testRequest("waldo:fred", "/unmapped", 403); + testRequest("bar:baz", "/user", 401); + } + + private void testRequest(String credentials, String path, int statusCode) throws IOException { + ByteChunk out = new ByteChunk(); + Map> reqHead = new HashMap<>(); + List head = new ArrayList<>(); + head.add(HttpServletRequest.BASIC_AUTH + " " + + Base64.encodeBase64String(credentials.getBytes(StandardCharsets.ISO_8859_1))); + reqHead.put("Authorization", head); + int rc = getUrl("http://localhost:" + getPort() + path, out, reqHead, null); + Assert.assertEquals(statusCode, rc); + } + +} diff --git a/test/webapp-role-mapping/WEB-INF/classes/com/example/prefixed-role-mapping.properties b/test/webapp-role-mapping/WEB-INF/classes/com/example/prefixed-role-mapping.properties new file mode 100644 index 000000000000..f2510d784158 --- /dev/null +++ b/test/webapp-role-mapping/WEB-INF/classes/com/example/prefixed-role-mapping.properties @@ -0,0 +1,2 @@ +app-roles.admin=de25f8f5-e534-4980-9351-e316384b1127 +app-roles.user=13f6b886-cba8-4b5b-9a1b-06a6fe533356 diff --git a/test/webapp-role-mapping/WEB-INF/classes/com/example/role-mapping.properties b/test/webapp-role-mapping/WEB-INF/classes/com/example/role-mapping.properties new file mode 100644 index 000000000000..b186493cf5ee --- /dev/null +++ b/test/webapp-role-mapping/WEB-INF/classes/com/example/role-mapping.properties @@ -0,0 +1,2 @@ +admin=de25f8f5-e534-4980-9351-e316384b1127 +user=13f6b886-cba8-4b5b-9a1b-06a6fe533356 diff --git a/test/webapp-role-mapping/WEB-INF/prefixed-role-mapping.properties b/test/webapp-role-mapping/WEB-INF/prefixed-role-mapping.properties new file mode 100644 index 000000000000..f2510d784158 --- /dev/null +++ b/test/webapp-role-mapping/WEB-INF/prefixed-role-mapping.properties @@ -0,0 +1,2 @@ +app-roles.admin=de25f8f5-e534-4980-9351-e316384b1127 +app-roles.user=13f6b886-cba8-4b5b-9a1b-06a6fe533356 diff --git a/test/webapp-role-mapping/WEB-INF/role-mapping.properties b/test/webapp-role-mapping/WEB-INF/role-mapping.properties new file mode 100644 index 000000000000..b186493cf5ee --- /dev/null +++ b/test/webapp-role-mapping/WEB-INF/role-mapping.properties @@ -0,0 +1,2 @@ +admin=de25f8f5-e534-4980-9351-e316384b1127 +user=13f6b886-cba8-4b5b-9a1b-06a6fe533356 diff --git a/test/webapp-role-mapping/admin b/test/webapp-role-mapping/admin new file mode 100644 index 000000000000..7fbe952b76a2 --- /dev/null +++ b/test/webapp-role-mapping/admin @@ -0,0 +1 @@ +admin diff --git a/test/webapp-role-mapping/unmapped b/test/webapp-role-mapping/unmapped new file mode 100644 index 000000000000..80a617a00e1d --- /dev/null +++ b/test/webapp-role-mapping/unmapped @@ -0,0 +1 @@ +unmapped diff --git a/test/webapp-role-mapping/user b/test/webapp-role-mapping/user new file mode 100644 index 000000000000..4eb8387fedc0 --- /dev/null +++ b/test/webapp-role-mapping/user @@ -0,0 +1 @@ +user diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index dd909b6b1aca..dbe8c5bc73cb 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -113,6 +113,12 @@ a listener which creates context naming information environment entries. (michaelo) + + 66665: Add + org.apache.catalina.core.PropertiesRoleMappingListener, + a listener which populates the context's role mapping from a properties + file. (michaelo) + Fix an edge case where intra-web application symlinks would be followed if the web applications were deliberately crafted to allow it even when diff --git a/webapps/docs/config/listeners.xml b/webapps/docs/config/listeners.xml index 9f0dd02eaee9..3e84cea656e1 100644 --- a/webapps/docs/config/listeners.xml +++ b/webapps/docs/config/listeners.xml @@ -295,6 +295,37 @@ + + +

The Properties Role Mapping Listener populates the context's role mapping + from a properties file. The keys represent application roles (e.g., admin, user, uservisor, + etc.) while the values represent technical roles (e.g., DNs, SIDs, UUIDs, etc.). A key can + also be prefixed if, e.g., the properties file contains generic application configuration + as well: app-roles..

+ +

This listener must only be nested within + Context elements.

+ +

The following additional attributes are supported by the + Properties Role Mapping Listener:

+ + + + +

The path to the role mapping properties file. You can use protocol webapp: + and whatever ConfigFileLoader supports.

+

The default value is webapp:/WEB-INF/role-mapping.properties.

+
+ + +

The prefix to filter from property keys. All other keys will be ignored which do + not have the prefix.

+
+ +
+ + +

The Security Lifecycle Listener performs a number of