This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch karaf-4.4.x
in repository https://gitbox.apache.org/repos/asf/karaf.git


The following commit(s) were added to refs/heads/karaf-4.4.x by this push:
     new 024e4cbf3d Ensure configuration files are always written within 
${karaf.etc} (#2868)
024e4cbf3d is described below

commit 024e4cbf3d7b946e79d18792788ed68347982b02
Author: JB Onofré <[email protected]>
AuthorDate: Sat Sep 12 07:55:16 2026 +0200

    Ensure configuration files are always written within ${karaf.etc} (#2868)
    
    ConfigRepositoryImpl derived the target file from caller-supplied input
    (the felix.fileinstall.filename property and the PID / factory alias used
    to build the file name) without checking that the result stayed inside
    ${karaf.etc}. Add a canonical-path containment check, shared through a new
    org.apache.karaf.util.PathUtils helper, and reuse it in
    ConfigMBeanImpl.install() and the config:install command in place of the
    weaker finalname.contains("..") string check.
---
 .../karaf/config/command/InstallCommand.java       |   5 +-
 .../karaf/config/core/impl/ConfigMBeanImpl.java    |   5 +-
 .../config/core/impl/ConfigRepositoryImpl.java     |  23 +++-
 .../config/core/impl/ConfigMBeanImplTest.java      |   4 +-
 .../impl/ConfigRepositoryImplContainmentTest.java  | 137 +++++++++++++++++++++
 .../main/java/org/apache/karaf/util/PathUtils.java |  50 ++++++++
 .../java/org/apache/karaf/util/PathUtilsTest.java  |  67 ++++++++++
 7 files changed, 281 insertions(+), 10 deletions(-)

diff --git 
a/config/command/src/main/java/org/apache/karaf/config/command/InstallCommand.java
 
b/config/command/src/main/java/org/apache/karaf/config/command/InstallCommand.java
index ea55226765..fad01ed2fb 100644
--- 
a/config/command/src/main/java/org/apache/karaf/config/command/InstallCommand.java
+++ 
b/config/command/src/main/java/org/apache/karaf/config/command/InstallCommand.java
@@ -21,6 +21,7 @@ import org.apache.karaf.shell.api.action.Argument;
 import org.apache.karaf.shell.api.action.Command;
 import org.apache.karaf.shell.api.action.Option;
 import org.apache.karaf.shell.api.action.lifecycle.Service;
+import org.apache.karaf.util.PathUtils;
 import org.apache.karaf.util.StreamUtils;
 
 import java.io.BufferedInputStream;
@@ -45,11 +46,9 @@ public class InstallCommand implements Action {
 
     @Override
     public Object execute() throws Exception {
-        if (finalname.contains("..")) {
-            throw new IllegalArgumentException("For security reason, relative 
path is not allowed in config file final name");
-        }
         File etcFolder = new File(System.getProperty("karaf.etc"));
         File file = new File(etcFolder, finalname);
+        PathUtils.checkWithin(etcFolder, file);
         if (file.exists()) {
             if (!override) {
                 throw new IllegalArgumentException("Configuration file {} 
already exists " + finalname);
diff --git 
a/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigMBeanImpl.java
 
b/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigMBeanImpl.java
index 81e1fed4c9..889cf7e0e6 100644
--- 
a/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigMBeanImpl.java
+++ 
b/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigMBeanImpl.java
@@ -31,6 +31,7 @@ import javax.management.StandardMBean;
 import org.apache.felix.utils.properties.TypedProperties;
 import org.apache.karaf.config.core.ConfigMBean;
 import org.apache.karaf.config.core.ConfigRepository;
+import org.apache.karaf.util.PathUtils;
 import org.apache.karaf.util.StreamUtils;
 import org.osgi.framework.InvalidSyntaxException;
 import org.osgi.service.cm.Configuration;
@@ -84,12 +85,10 @@ public class ConfigMBeanImpl extends StandardMBean 
implements ConfigMBean {
 
     @Override
     public void install(String url, String finalname, boolean override) throws 
MBeanException {
-        if (finalname.contains("..")) {
-            throw new IllegalArgumentException("For security reason, relative 
path is not allowed in config file final name");
-        }
         try {
             File etcFolder = new File(System.getProperty("karaf.etc"));
             File file = new File(etcFolder, finalname);
+            PathUtils.checkWithin(etcFolder, file);
             if (file.exists()) {
                 if (!override) {
                     throw new IllegalArgumentException("Configuration file {} 
already exists " + finalname);
diff --git 
a/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigRepositoryImpl.java
 
b/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigRepositoryImpl.java
index 3f057863e3..470575377e 100644
--- 
a/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigRepositoryImpl.java
+++ 
b/config/core/src/main/java/org/apache/karaf/config/core/impl/ConfigRepositoryImpl.java
@@ -33,6 +33,7 @@ import java.util.Map;
 import org.apache.felix.cm.json.Configurations;
 import org.apache.felix.utils.properties.TypedProperties;
 import org.apache.karaf.config.core.ConfigRepository;
+import org.apache.karaf.util.PathUtils;
 import org.osgi.framework.Constants;
 import org.osgi.framework.InvalidSyntaxException;
 import org.osgi.service.cm.Configuration;
@@ -83,6 +84,7 @@ public class ConfigRepositoryImpl implements ConfigRepository 
{
                 if (file == null) {
                     file = generateConfigFilename(cfg, suffix);
                 }
+                checkConfigFileLocation(file);
                 props.putAll(properties);
                 props.keySet().retainAll(properties.keySet());
                 store(props, file);
@@ -109,6 +111,19 @@ public class ConfigRepositoryImpl implements 
ConfigRepository {
         return new File(System.getProperty("karaf.etc"), fName);
     }
 
+    /**
+     * Ensure that the configuration file we are about to write stays within 
the Karaf etc folder.
+     *
+     * <p>The target file is derived from caller-controlled input: a {@code 
felix.fileinstall.filename}
+     * entry in the property map can point to an arbitrary path, and a PID (or 
factory alias) containing
+     * {@code ..} segments escapes {@code ${karaf.etc}} through {@link 
#generateConfigFilename}. Without
+     * this check a user allowed to update configurations could overwrite 
files reserved to more
+     * privileged users (e.g. {@code etc/users.properties} or the command/JMX 
ACL files).</p>
+     */
+    private static void checkConfigFileLocation(File file) throws IOException {
+        PathUtils.checkWithin(new File(System.getProperty("karaf.etc")), file);
+    }
+
     /* (non-Javadoc)
      * @see 
org.apache.karaf.shell.config.impl.ConfigRepository#delete(java.lang.String)
      */
@@ -204,12 +219,14 @@ public class ConfigRepositoryImpl implements 
ConfigRepository {
 
     @Override
     public String createFactoryConfiguration(String factoryPid, String alias, 
Map<String, Object> properties, String suffix) throws IOException {
-        Configuration config = 
configAdmin.createFactoryConfiguration(factoryPid, "?");
-        TypedProperties props = new TypedProperties();
         File file = null;
         if (alias != null && !"".equals(alias.trim())) {
             file = new File(new File(System.getProperty("karaf.etc")), 
factoryPid + "-" + alias + "." + suffix);
-        } else {
+            checkConfigFileLocation(file);
+        }
+        Configuration config = 
configAdmin.createFactoryConfiguration(factoryPid, "?");
+        TypedProperties props = new TypedProperties();
+        if (file == null) {
             file = Files.createTempFile(new 
File(System.getProperty("karaf.etc")).toPath(), factoryPid + "-", "." + 
suffix).toFile();
         }
         props.putAll(properties);
diff --git 
a/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigMBeanImplTest.java
 
b/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigMBeanImplTest.java
index 4bdf08abf9..908f30594d 100644
--- 
a/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigMBeanImplTest.java
+++ 
b/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigMBeanImplTest.java
@@ -16,13 +16,15 @@ package org.apache.karaf.config.core.impl;
 import org.junit.Assert;
 import org.junit.Test;
 
+import javax.management.MBeanException;
+
 import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileReader;
 
 public class ConfigMBeanImplTest {
 
-    @Test(expected = IllegalArgumentException.class)
+    @Test(expected = MBeanException.class)
     public void testInstallWithNonAuthorizePath() throws Exception {
         System.setProperty("karaf.etc", ".");
 
diff --git 
a/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigRepositoryImplContainmentTest.java
 
b/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigRepositoryImplContainmentTest.java
new file mode 100644
index 0000000000..a1f0f7f586
--- /dev/null
+++ 
b/config/core/src/test/java/org/apache/karaf/config/core/impl/ConfigRepositoryImplContainmentTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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
+ *
+ *       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.karaf.config.core.impl;
+
+import static org.easymock.EasyMock.anyObject;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.expect;
+import static org.easymock.EasyMock.expectLastCall;
+import static org.easymock.EasyMock.replay;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Hashtable;
+
+import org.apache.felix.utils.properties.TypedProperties;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * Verifies that {@link ConfigRepositoryImpl} refuses to persist a 
configuration outside of
+ * {@code ${karaf.etc}}, whether the escape is attempted through a crafted PID 
or through a
+ * caller-supplied {@code felix.fileinstall.filename} property.
+ */
+public class ConfigRepositoryImplContainmentTest {
+
+    private Path etc;
+    private Path outside;
+    private String previousEtc;
+
+    @Before
+    public void setUp() throws Exception {
+        Path base = Files.createTempDirectory("karaf-config-containment");
+        etc = Files.createDirectories(base.resolve("etc"));
+        outside = Files.createDirectories(base.resolve("outside"));
+        previousEtc = System.getProperty("karaf.etc");
+        System.setProperty("karaf.etc", etc.toFile().getCanonicalPath());
+    }
+
+    @After
+    public void tearDown() {
+        if (previousEtc == null) {
+            System.clearProperty("karaf.etc");
+        } else {
+            System.setProperty("karaf.etc", previousEtc);
+        }
+    }
+
+    private ConfigRepositoryImpl repository(String pid) throws Exception {
+        ConfigurationAdmin admin = createMock(ConfigurationAdmin.class);
+        Configuration config = createMock(Configuration.class);
+        expect(admin.getConfiguration(pid, "?")).andReturn(config).anyTimes();
+        expect(config.getProcessedProperties(anyObject())).andReturn(new 
Hashtable<>()).anyTimes();
+        expect(config.getPid()).andReturn(pid).anyTimes();
+        expect(config.getFactoryPid()).andReturn(null).anyTimes();
+        config.update(anyObject());
+        expectLastCall().anyTimes();
+        replay(admin, config);
+        return new ConfigRepositoryImpl(admin);
+    }
+
+    @Test
+    public void traversalPidIsRejected() throws Exception {
+        String pid = "../outside/evil";
+        try {
+            repository(pid).update(pid, new TypedProperties());
+            fail("expected the traversal PID to be rejected");
+        } catch (IOException e) {
+            // expected
+        }
+        assertFalse("no file must be written outside etc", 
Files.exists(outside.resolve("evil.cfg")));
+    }
+
+    @Test
+    public void fileinstallFilenameOutsideEtcIsRejected() throws Exception {
+        String pid = "my.legit.pid";
+        File escape = outside.resolve("evil.cfg").toFile();
+        TypedProperties properties = new TypedProperties();
+        properties.put("felix.fileinstall.filename", 
escape.toURI().toString());
+        try {
+            repository(pid).update(pid, properties);
+            fail("expected the out-of-etc felix.fileinstall.filename to be 
rejected");
+        } catch (IOException e) {
+            // expected
+        }
+        assertFalse("no file must be written outside etc", escape.exists());
+    }
+
+    @Test
+    public void factoryAliasTraversalIsRejected() throws Exception {
+        ConfigurationAdmin admin = createMock(ConfigurationAdmin.class);
+        Configuration config = createMock(Configuration.class);
+        expect(admin.createFactoryConfiguration("my.factory", 
"?")).andReturn(config).anyTimes();
+        expect(config.getPid()).andReturn("my.factory.generated").anyTimes();
+        config.update(anyObject());
+        expectLastCall().anyTimes();
+        replay(admin, config);
+
+        // the alias is concatenated as "<factoryPid>-<alias>.<suffix>", so an 
escaping payload
+        // needs a leading path segment before the ".." sequence
+        try {
+            new ConfigRepositoryImpl(admin)
+                    .createFactoryConfiguration("my.factory", 
"x/../../outside/evil", new TypedProperties());
+            fail("expected the traversal alias to be rejected");
+        } catch (IOException e) {
+            // expected
+        }
+        assertFalse(Files.exists(outside.resolve("evil.cfg")));
+    }
+
+    @Test
+    public void regularUpdateStillWritesInsideEtc() throws Exception {
+        String pid = "my.regular.pid";
+        TypedProperties properties = new TypedProperties();
+        properties.put("hello", "world");
+        repository(pid).update(pid, properties);
+        assertTrue("the cfg file must land in etc", 
Files.exists(etc.resolve("my.regular.pid.cfg")));
+    }
+}
diff --git a/util/src/main/java/org/apache/karaf/util/PathUtils.java 
b/util/src/main/java/org/apache/karaf/util/PathUtils.java
new file mode 100644
index 0000000000..76e2b85eeb
--- /dev/null
+++ b/util/src/main/java/org/apache/karaf/util/PathUtils.java
@@ -0,0 +1,50 @@
+/*
+ * 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.karaf.util;
+
+import java.io.File;
+import java.io.IOException;
+
+public final class PathUtils {
+
+    private PathUtils() {
+    }
+
+    /**
+     * Check whether {@code child} resolves to a location inside {@code 
parent}, following symbolic
+     * links and normalizing {@code ..} segments through canonical paths.
+     */
+    public static boolean isWithin(File parent, File child) throws IOException 
{
+        return 
child.getCanonicalFile().toPath().startsWith(parent.getCanonicalFile().toPath());
+    }
+
+    /**
+     * Ensure that {@code child} resolves to a location inside {@code parent}, 
throwing an
+     * {@link IOException} otherwise.
+     *
+     * <p>Use this to stop a caller-controlled file name or path from escaping 
the directory it is
+     * meant to stay within (path traversal, CWE-22) -- a plain {@code 
contains("..")} check does
+     * not catch absolute paths or symbolic links.</p>
+     */
+    public static void checkWithin(File parent, File child) throws IOException 
{
+        if (!isWithin(parent, child)) {
+            throw new IOException("For security reasons, " + child + " has to 
be located inside "
+                    + parent.getCanonicalFile());
+        }
+    }
+
+}
diff --git a/util/src/test/java/org/apache/karaf/util/PathUtilsTest.java 
b/util/src/test/java/org/apache/karaf/util/PathUtilsTest.java
new file mode 100644
index 0000000000..6c3e8f1718
--- /dev/null
+++ b/util/src/test/java/org/apache/karaf/util/PathUtilsTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.karaf.util;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.Test;
+
+public class PathUtilsTest {
+
+    @Test
+    public void childInsideParentIsAccepted() throws Exception {
+        Path parent = Files.createTempDirectory("pathutils");
+        File child = new File(parent.toFile(), "sub/child.cfg");
+        assertTrue(PathUtils.isWithin(parent.toFile(), child));
+        PathUtils.checkWithin(parent.toFile(), child);
+    }
+
+    @Test
+    public void relativeTraversalIsRejected() throws Exception {
+        Path parent = Files.createTempDirectory("pathutils");
+        File child = new File(parent.toFile(), "../escape.cfg");
+        assertFalse(PathUtils.isWithin(parent.toFile(), child));
+        try {
+            PathUtils.checkWithin(parent.toFile(), child);
+            fail("expected traversal to be rejected");
+        } catch (IOException e) {
+            // expected
+        }
+    }
+
+    @Test
+    public void absolutePathOutsideParentIsRejected() throws Exception {
+        Path parent = Files.createTempDirectory("pathutils");
+        File child = new File(System.getProperty("java.io.tmpdir"), 
"definitely-not-under-parent.cfg");
+        assertFalse(PathUtils.isWithin(parent.toFile(), child));
+    }
+
+    @Test
+    public void siblingWithSharedPrefixIsRejected() throws Exception {
+        Path base = Files.createTempDirectory("pathutils");
+        File parent = new File(base.toFile(), "etc");
+        File child = new File(base.toFile(), "etc-evil/child.cfg");
+        assertFalse(PathUtils.isWithin(parent, child));
+    }
+}

Reply via email to