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

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


The following commit(s) were added to refs/heads/main by this push:
     new 94a99b4e4 fix(#730): keep control characters out of shell endpoint uri 
output (#735)
94a99b4e4 is described below

commit 94a99b4e4e7672c8d2e4dad0b8bbfbfa4bef7fd4
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Aug 25 09:38:19 2026 +0200

    fix(#730): keep control characters out of shell endpoint uri output (#735)
    
    camel:endpoint-list, camel:endpoint-stats and camel:rest-registry-list all
    url decode the endpoint uri before printing it, and --decode defaults to
    true. URISupport.sanitizeUri masks credentials but leaves control
    characters alone, so percent encoded ESC, LF or BEL were re-armed by the
    decode and passed to ShellTable as raw bytes.
    
    The uris are not all route-author literals: getEndpoints() includes
    endpoints materialized by the dynamic EIPs and RuntimeEndpointRegistry
    tracks dynamic endpoints by design, so a uri can carry a substring that
    arrived on the wire. A raw LF splits the row, and ESC sequences are acted
    on by the terminal emulator, in the exact view an operator uses to see
    what a context is connected to.
    
    Fold the shared decode/sanitize into ShellUriHelper.prepareUriForDisplay
    and put control characters back into percent encoded form as the last
    step. Nothing is lost from the display: the operator sees %1B where the
    uri really has an ESC, and readability of ordinary escapes is unchanged.
    Also applies when --decode is false, so a literal control character in a
    uri is handled too.
    
    While here, an undecodable uri no longer aborts the whole listing: a
    malformed % sequence made URLDecoder throw and took out every row, not
    just the offending one.
    
    Adds ShellUriHelperTest and the junit-jupiter test dependencies the shell
    module did not have yet.
---
 shell/pom.xml                                      |  20 ++++
 .../org/apache/camel/karaf/shell/EndpointList.java |  10 +-
 .../apache/camel/karaf/shell/EndpointStats.java    |   9 +-
 .../apache/camel/karaf/shell/RestRegistryList.java |  10 +-
 .../apache/camel/karaf/shell/ShellUriHelper.java   | 102 ++++++++++++++++++++
 .../camel/karaf/shell/ShellUriHelperTest.java      | 104 +++++++++++++++++++++
 6 files changed, 229 insertions(+), 26 deletions(-)

diff --git a/shell/pom.xml b/shell/pom.xml
index fa0cb0357..9bff7bbbb 100644
--- a/shell/pom.xml
+++ b/shell/pom.xml
@@ -46,6 +46,26 @@
             <artifactId>camel-core</artifactId>
             <version>${camel-version}</version>
         </dependency>
+
+        <!-- test dependencies -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <version>${junit-jupiter-version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-engine</artifactId>
+            <version>${junit-jupiter-version}</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+            <version>${junit-jupiter-version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git a/shell/src/main/java/org/apache/camel/karaf/shell/EndpointList.java 
b/shell/src/main/java/org/apache/camel/karaf/shell/EndpointList.java
index ff8a57c3b..77a510493 100644
--- a/shell/src/main/java/org/apache/camel/karaf/shell/EndpointList.java
+++ b/shell/src/main/java/org/apache/camel/karaf/shell/EndpointList.java
@@ -21,12 +21,10 @@ import org.apache.camel.Endpoint;
 import org.apache.camel.ServiceStatus;
 import org.apache.camel.StatefulService;
 import org.apache.camel.karaf.shell.completers.CamelContextCompleter;
-import org.apache.camel.util.URISupport;
 import org.apache.karaf.shell.api.action.*;
 import org.apache.karaf.shell.api.action.lifecycle.Service;
 import org.apache.karaf.shell.support.table.ShellTable;
 
-import java.net.URLDecoder;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -63,13 +61,7 @@ public class EndpointList extends CamelCommandSupport 
implements Action {
                 }
             });
             for (Endpoint endpoint : endpoints) {
-                String uri = endpoint.getEndpointUri();
-                if (decode) {
-                    // decode uri so its more human readable
-                    uri = URLDecoder.decode(uri, "UTF-8");
-                }
-                // sanitize and mask uri so we don't see passwords
-                uri = URISupport.sanitizeUri(uri);
+                String uri = 
ShellUriHelper.prepareUriForDisplay(endpoint.getEndpointUri(), decode);
                 table.addRow().addContent(camelContext.getName(), uri, 
getEndpointState(endpoint));
             }
         }
diff --git 
a/shell/src/main/java/org/apache/camel/karaf/shell/EndpointStats.java 
b/shell/src/main/java/org/apache/camel/karaf/shell/EndpointStats.java
index 5b6fbb805..0a420c3c1 100644
--- a/shell/src/main/java/org/apache/camel/karaf/shell/EndpointStats.java
+++ b/shell/src/main/java/org/apache/camel/karaf/shell/EndpointStats.java
@@ -20,12 +20,10 @@ import org.apache.camel.CamelContext;
 import org.apache.camel.karaf.shell.completers.CamelContextCompleter;
 import org.apache.camel.spi.EndpointRegistry;
 import org.apache.camel.spi.RuntimeEndpointRegistry;
-import org.apache.camel.util.URISupport;
 import org.apache.karaf.shell.api.action.*;
 import org.apache.karaf.shell.api.action.lifecycle.Service;
 import org.apache.karaf.shell.support.table.ShellTable;
 
-import java.net.URLDecoder;
 import java.util.List;
 
 @Command(scope = "camel", name = "endpoint-stats", description = "List the 
statistics of the Camel endpoints")
@@ -66,12 +64,7 @@ public class EndpointStats extends CamelCommandSupport 
implements Action {
                     boolean isDynamic = endpointRegistry.isDynamic(uri);
                     long hits = stat.getHits();
 
-                    if (decode) {
-                        // decode uri so it's more human readable
-                        uri = URLDecoder.decode(uri, "UTF-8");
-                    }
-                    // sanitize and mask uri so we don't see passwords
-                    uri = URISupport.sanitizeUri(uri);
+                    uri = ShellUriHelper.prepareUriForDisplay(uri, decode);
 
                     // should we filter ?
                     if (isValidRow(direction, Boolean.toString(isStatic), 
Boolean.toString(isDynamic))) {
diff --git 
a/shell/src/main/java/org/apache/camel/karaf/shell/RestRegistryList.java 
b/shell/src/main/java/org/apache/camel/karaf/shell/RestRegistryList.java
index b1beffdb3..a1335dc5d 100644
--- a/shell/src/main/java/org/apache/camel/karaf/shell/RestRegistryList.java
+++ b/shell/src/main/java/org/apache/camel/karaf/shell/RestRegistryList.java
@@ -19,12 +19,10 @@ package org.apache.camel.karaf.shell;
 import org.apache.camel.CamelContext;
 import org.apache.camel.karaf.shell.completers.CamelContextCompleter;
 import org.apache.camel.spi.RestRegistry;
-import org.apache.camel.util.URISupport;
 import org.apache.karaf.shell.api.action.*;
 import org.apache.karaf.shell.api.action.lifecycle.Service;
 import org.apache.karaf.shell.support.table.ShellTable;
 
-import java.net.URLDecoder;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -64,13 +62,7 @@ public class RestRegistryList extends CamelCommandSupport 
implements Action {
             }
         });
         for (RestRegistry.RestService service : services) {
-            String uri = service.getUrl();
-            if (decode) {
-                // decode uri so it's more human readable
-                uri = URLDecoder.decode(uri, "UTF-8");
-            }
-            // sanitize and mask uri so we don't see passwords
-            uri = URISupport.sanitizeUri(uri);
+            String uri = ShellUriHelper.prepareUriForDisplay(service.getUrl(), 
decode);
             table.addRow().addContent(uri,
                     service.getBasePath(),
                     service.getUriTemplate(),
diff --git 
a/shell/src/main/java/org/apache/camel/karaf/shell/ShellUriHelper.java 
b/shell/src/main/java/org/apache/camel/karaf/shell/ShellUriHelper.java
new file mode 100644
index 000000000..34f466870
--- /dev/null
+++ b/shell/src/main/java/org/apache/camel/karaf/shell/ShellUriHelper.java
@@ -0,0 +1,102 @@
+/*
+ * 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.camel.karaf.shell;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.camel.util.URISupport;
+
+/**
+ * Helper to prepare endpoint uris for display by the Camel shell commands.
+ */
+public final class ShellUriHelper {
+
+    private ShellUriHelper() {
+    }
+
+    /**
+     * Prepares an endpoint uri so it can be safely written to the console.
+     * <p/>
+     * The uri is optionally decoded so it is human readable, any credentials 
are masked, and control characters are
+     * left in (or put back into) their percent encoded form. Endpoint uris 
are not always authored in the route: the
+     * dynamic EIPs materialize endpoints whose uri can embed message content, 
so a decoded uri may carry characters
+     * the terminal would otherwise act on.
+     *
+     * @param  uri    the endpoint uri, may be <tt>null</tt>
+     * @param  decode whether to url decode the uri so it is more human 
readable
+     * @return        the uri to display, or <tt>null</tt> if the given uri 
was <tt>null</tt>
+     */
+    public static String prepareUriForDisplay(String uri, boolean decode) {
+        if (uri == null) {
+            return null;
+        }
+        if (decode) {
+            // decode uri so its more human readable
+            uri = decodeQuietly(uri);
+        }
+        // sanitize and mask uri so we don't see passwords
+        uri = URISupport.sanitizeUri(uri);
+        // must be done last so nothing can put a control character back 
afterwards
+        return encodeControlCharacters(uri);
+    }
+
+    /**
+     * Url decodes the uri, returning it unchanged when it is not decodable, 
so that a single malformed escape does not
+     * fail the whole command.
+     */
+    private static String decodeQuietly(String uri) {
+        try {
+            return URLDecoder.decode(uri, StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException e) {
+            return uri;
+        }
+    }
+
+    /**
+     * Puts any control character back into its percent encoded form. A uri 
has no need for control characters, and
+     * decoding them would let them reach the terminal, where they can move 
the cursor, forge additional rows in the
+     * output, or be interpreted as an escape sequence.
+     */
+    private static String encodeControlCharacters(String uri) {
+        int first = indexOfControlCharacter(uri, 0);
+        if (first == -1) {
+            // by far the common case, so do not build anything
+            return uri;
+        }
+        StringBuilder sb = new StringBuilder(uri.length() + 16);
+        sb.append(uri, 0, first);
+        for (int i = first; i < uri.length(); i++) {
+            char ch = uri.charAt(i);
+            if (Character.isISOControl(ch)) {
+                sb.append('%').append(String.format("%02X", (int) ch));
+            } else {
+                sb.append(ch);
+            }
+        }
+        return sb.toString();
+    }
+
+    private static int indexOfControlCharacter(String uri, int from) {
+        for (int i = from; i < uri.length(); i++) {
+            if (Character.isISOControl(uri.charAt(i))) {
+                return i;
+            }
+        }
+        return -1;
+    }
+}
diff --git 
a/shell/src/test/java/org/apache/camel/karaf/shell/ShellUriHelperTest.java 
b/shell/src/test/java/org/apache/camel/karaf/shell/ShellUriHelperTest.java
new file mode 100644
index 000000000..890456c31
--- /dev/null
+++ b/shell/src/test/java/org/apache/camel/karaf/shell/ShellUriHelperTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.camel.karaf.shell;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ShellUriHelperTest {
+
+    private static final char ESC = 0x1B;
+
+    @Test
+    public void testNullUri() {
+        assertNull(ShellUriHelper.prepareUriForDisplay(null, true));
+        assertNull(ShellUriHelper.prepareUriForDisplay(null, false));
+    }
+
+    @Test
+    public void testPlainUriIsUnchanged() {
+        String uri = "http://localhost:8080/push";;
+        assertEquals(uri, ShellUriHelper.prepareUriForDisplay(uri, true));
+        assertEquals(uri, ShellUriHelper.prepareUriForDisplay(uri, false));
+    }
+
+    @Test
+    public void testDecodeStillMakesUriReadable() {
+        assertEquals("timer://foo?period=5000&message=hello world",
+                
ShellUriHelper.prepareUriForDisplay("timer://foo?period=5000&message=hello%20world",
 true));
+    }
+
+    @Test
+    public void testNoDecodeLeavesUriEncoded() {
+        assertEquals("timer://foo?message=hello%20world",
+                
ShellUriHelper.prepareUriForDisplay("timer://foo?message=hello%20world", 
false));
+    }
+
+    @Test
+    public void testCredentialsAreStillMasked() {
+        String uri = 
ShellUriHelper.prepareUriForDisplay("ftp://host/dir?username=scott&password=tiger&binary=true";,
 true);
+        assertFalse(uri.contains("tiger"), "password must be masked, was: " + 
uri);
+        assertFalse(uri.contains("scott"), "username must be masked, was: " + 
uri);
+        assertTrue(uri.contains("binary=true"), "non credential options are 
kept, was: " + uri);
+        assertTrue(uri.startsWith("ftp://host/dir?";), "the endpoint itself is 
still readable, was: " + uri);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"%1b", "%1B", "%0a", "%0d", "%07", "%00", "%7f"})
+    public void testEncodedControlCharacterIsNotReArmedByDecode(String 
encoded) {
+        String uri = ShellUriHelper.prepareUriForDisplay("http://host/"; + 
encoded + "evil", true);
+        assertFalse(containsControlCharacter(uri), "decoded uri must not 
contain a control character, was: " + uri);
+    }
+
+    @Test
+    public void testEscapeSequenceIsRenderedInline() {
+        // %1b decodes to ESC, which must come back as %1B rather than 
reaching the terminal
+        assertEquals("http://host/%1B]2;title%07";,
+                
ShellUriHelper.prepareUriForDisplay("http://host/%1b]2;title%07";, true));
+    }
+
+    @Test
+    public void testNewlineCannotForgeAnExtraRow() {
+        String uri = 
ShellUriHelper.prepareUriForDisplay("http://evil/%0aseda://looks-legit";, true);
+        assertFalse(uri.contains("\n"), "a decoded newline must not split the 
row, was: " + uri);
+        assertEquals("http://evil/%0Aseda://looks-legit";, uri);
+    }
+
+    @Test
+    public void testLiteralControlCharacterIsEncodedEvenWithoutDecode() {
+        String uri = ShellUriHelper.prepareUriForDisplay("http://host/"; + ESC 
+ "[2J", false);
+        assertFalse(containsControlCharacter(uri), "was: " + uri);
+        assertEquals("http://host/%1B[2J";, uri);
+    }
+
+    @Test
+    public void testMalformedEscapeDoesNotFailTheCommand() {
+        // an incomplete % sequence makes URLDecoder throw, which used to 
abort the whole listing
+        assertEquals("http://host/100%";, 
ShellUriHelper.prepareUriForDisplay("http://host/100%";, true));
+        assertEquals("http://host/%zz";, 
ShellUriHelper.prepareUriForDisplay("http://host/%zz";, true));
+    }
+
+    private static boolean containsControlCharacter(String s) {
+        return s.chars().anyMatch(Character::isISOControl);
+    }
+}

Reply via email to