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

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


The following commit(s) were added to refs/heads/main by this push:
     new 84f265e8d1b3 CAMEL-24973: camel-util - Align URISupport.sanitizeUri 
with the endpoint URI parser (#26803)
84f265e8d1b3 is described below

commit 84f265e8d1b3548fd03f0fd0474cf6c410e5e9e6
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 25 11:19:50 2026 +0200

    CAMEL-24973: camel-util - Align URISupport.sanitizeUri with the endpoint 
URI parser (#26803)
    
    - End RAW(...) and RAW{...} values the same way URIScanner does: at the 
closing bracket followed by & or the end
    - Use one userinfo masking rule for URISupport.sanitizeUri, 
URISupport.sanitizePath and SensitiveUtils.maskUserInfoCredentials, applied to 
every uri in the text
    - Match sensitive keys ignoring dashes, as options can be configured in 
dash case
    - Apply the keywords from addSanitizeKeywords to every parameter and keep 
the ones added earlier
    - Mask sensitive values in the "Unknown parameters" error messages
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../component/jetty12/JettyHttpComponent12.java    |   4 +-
 .../impl/engine/DefaultCamelContextExtension.java  |   2 +-
 .../apache/camel/impl/DefaultComponentTest.java    |  13 ++
 .../org/apache/camel/support/DefaultComponent.java |   2 +-
 .../camel/support/ScheduledPollConsumer.java       |   3 +-
 .../java/org/apache/camel/util/SensitiveUtils.java | 137 ++++++++++++++++---
 .../java/org/apache/camel/util/URISupport.java     | 152 ++++++++++++++++-----
 .../org/apache/camel/util/SensitiveUtilsTest.java  |  24 ++++
 .../java/org/apache/camel/util/URISupportTest.java | 113 +++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  18 +++
 10 files changed, 413 insertions(+), 55 deletions(-)

diff --git 
a/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/JettyHttpComponent12.java
 
b/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/JettyHttpComponent12.java
index 9f9f069e338b..1090c561ab25 100644
--- 
a/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/JettyHttpComponent12.java
+++ 
b/components/camel-jetty/src/main/java/org/apache/camel/component/jetty12/JettyHttpComponent12.java
@@ -29,6 +29,7 @@ import org.apache.camel.component.jetty.JettyHttpEndpoint;
 import org.apache.camel.spi.Metadata;
 import org.apache.camel.spi.annotations.Component;
 import org.apache.camel.support.PropertyBindingSupport;
+import org.apache.camel.util.URISupport;
 import org.eclipse.jetty.server.ConnectionFactory;
 import org.eclipse.jetty.server.ForwardedRequestCustomizer;
 import org.eclipse.jetty.server.HttpConfiguration;
@@ -119,7 +120,8 @@ public class JettyHttpComponent12 extends 
JettyHttpComponent {
                         throw new IllegalArgumentException(
                                 "There are " + properties.size() + " 
parameters that couldn't be set on the SocketConnector."
                                                            + " Check the uri 
if the parameters are spelt correctly and that they are properties of the 
SelectChannelConnector."
-                                                           + " Unknown 
parameters=[" + properties + "]");
+                                                           + " Unknown 
parameters=["
+                                                           + 
URISupport.sanitizeParameters(properties) + "]");
                     }
                 }
 
diff --git 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
index d2cce18c9a82..0be9ea6e0fb7 100644
--- 
a/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
+++ 
b/core/camel-base-engine/src/main/java/org/apache/camel/impl/engine/DefaultCamelContextExtension.java
@@ -656,7 +656,7 @@ class DefaultCamelContextExtension implements 
ExtendedCamelContext {
     @Override
     public void setAdditionalSensitiveKeywords(String 
additionalSensitiveKeywords) {
         this.additionalSensitiveKeywords = additionalSensitiveKeywords;
-        // re-configure sensitive keywords asap so they take effect immediately
+        // add the keywords to the ones used when sanitizing uris, they apply 
to the whole JVM and cannot be removed
         URISupport.addSanitizeKeywords(additionalSensitiveKeywords);
     }
 
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultComponentTest.java 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultComponentTest.java
index 33c097196123..c86fc93826f5 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/impl/DefaultComponentTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/impl/DefaultComponentTest.java
@@ -26,15 +26,18 @@ import org.apache.camel.CamelContext;
 import org.apache.camel.ContextTestSupport;
 import org.apache.camel.Endpoint;
 import org.apache.camel.NoSuchBeanException;
+import org.apache.camel.ResolveEndpointFailedException;
 import org.apache.camel.TypeConversionException;
 import org.apache.camel.spi.Registry;
 import org.apache.camel.support.DefaultComponent;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * Unit test for helper methods on the DefaultComponent.
@@ -270,6 +273,16 @@ public class DefaultComponentTest extends 
ContextTestSupport {
         assertEquals("camelContext must be specified", e.getMessage());
     }
 
+    @Test
+    public void testUnknownParametersAreSanitized() {
+        ResolveEndpointFailedException e = 
assertThrows(ResolveEndpointFailedException.class,
+                () -> 
context.getEndpoint("timer:foo?privateKeyPassphrase=secret&foo=bar"),
+                "Should have thrown a ResolveEndpointFailedException");
+
+        assertTrue(e.getMessage().endsWith("Unknown parameters=[{foo=bar, 
privateKeyPassphrase=xxxxxx}]"), e.getMessage());
+        assertFalse(e.getMessage().contains("secret"), e.getMessage());
+    }
+
     @Override
     protected Registry createCamelRegistry() throws Exception {
         Date bean1 = new Date(10);
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/DefaultComponent.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/DefaultComponent.java
index ad07db3178fa..cec14989670e 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/DefaultComponent.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/DefaultComponent.java
@@ -302,7 +302,7 @@ public abstract class DefaultComponent extends 
ServiceSupport implements Compone
                     uri, "There are " + param.size()
                          + " parameters that couldn't be set on the endpoint."
                          + " Check the uri if the parameters are spelt 
correctly and that they are properties of the endpoint."
-                         + " Unknown parameters=[" + param + "]");
+                         + " Unknown parameters=[" + 
URISupport.sanitizeParameters(param) + "]");
         }
     }
 
diff --git 
a/core/camel-support/src/main/java/org/apache/camel/support/ScheduledPollConsumer.java
 
b/core/camel-support/src/main/java/org/apache/camel/support/ScheduledPollConsumer.java
index bcbfa60dfab8..8c765e9313b0 100644
--- 
a/core/camel-support/src/main/java/org/apache/camel/support/ScheduledPollConsumer.java
+++ 
b/core/camel-support/src/main/java/org/apache/camel/support/ScheduledPollConsumer.java
@@ -39,6 +39,7 @@ import org.apache.camel.spi.ScheduledPollConsumerScheduler;
 import org.apache.camel.support.service.ServiceHelper;
 import org.apache.camel.util.ObjectHelper;
 import org.apache.camel.util.PropertiesHelper;
+import org.apache.camel.util.URISupport;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -668,7 +669,7 @@ public abstract class ScheduledPollConsumer extends 
DefaultConsumer
                         getEndpoint(), "There are " + copy.size()
                                        + " scheduler parameters that couldn't 
be set on the endpoint."
                                        + " Check the uri if the parameters are 
spelt correctly and that they are properties of the endpoint."
-                                       + " Unknown parameters=[" + copy + "]");
+                                       + " Unknown parameters=[" + 
URISupport.sanitizeParameters(copy) + "]");
             }
         }
         afterConfigureScheduler(scheduler, newScheduler);
diff --git 
a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java 
b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
index 7998a1691f29..f5a254b3ff6a 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java
@@ -26,21 +26,6 @@ import java.util.regex.Pattern;
 
 public final class SensitiveUtils {
 
-    /**
-     * Matches URI userinfo credentials ({@code scheme://user:password@host} 
or {@code scheme://:password@host}) and
-     * captures the password as group 2. The scheme may contain {@code +} / 
{@code .} / {@code -} (e.g.
-     * {@code mongodb+srv}). Does not match user-only userinfo without a 
password ({@code scheme://user@host}).
-     * <p>
-     * Stricter than {@link URISupport}'s {@code USERINFO_PASSWORD} (used in 
{@link URISupport#sanitizeUri}): requires a
-     * scheme and a colon before the password. Used for free-text log masking. 
The user/password prefix uses a
-     * non-greedy match so passwords may contain {@code :} (aligned with 
{@link URISupport#sanitizeUri}).
-     * <p>
-     * Passwords must not contain a raw {@code @} (use percent-encoding such 
as {@code %40}); capture stops at the first
-     * {@code @} that ends userinfo.
-     */
-    private static final Pattern URI_USERINFO_PASSWORD_IN_TEXT
-            = 
Pattern.compile("([a-zA-Z][a-zA-Z0-9+.-]*://[^/@\\s\"']*?:)([^@\\s\"']+)(@)");
-
     /**
      * Matches PEM private-key blocks ({@code -----BEGIN ... PRIVATE KEY-----} 
… {@code -----END ... PRIVATE KEY-----})
      * and captures the key body as group 2. Public keys and certificates are 
not matched.
@@ -299,6 +284,11 @@ public final class SensitiveUtils {
      * Masks passwords embedded in URI userinfo ({@code 
scheme://user:password@host}) within free text. Complements
      * name-based secret detection: the password has no {@code password=} key, 
so key/value maskers never see it.
      * <p>
+     * The userinfo ends at the last {@code @} before the path or query, so a 
password may contain {@code :} and
+     * {@code @}. A password with an unencoded {@code /} or {@code ?} is 
masked up to the {@code @}, unless it contains
+     * something that reads as a query parameter ({@code ?key=} or {@code 
&key=}). In free text a URI also ends at a
+     * whitespace or a quote. {@link URISupport#sanitizeUri(String)} masks 
userinfo passwords with the same rule.
+     * <p>
      * Query-parameter forms such as {@code ?password=secret} are not handled 
here; use a key/value masker or
      * {@link URISupport#sanitizeUri(String)} for those.
      *
@@ -311,11 +301,122 @@ public final class SensitiveUtils {
         if (source == null || source.isEmpty() || mask == null) {
             return source;
         }
-        if (!source.contains("://")) {
+        return maskUserInfo(source, mask, true);
+    }
+
+    /**
+     * Masks the userinfo password of every {@code scheme://user:password@} in 
the source.
+     *
+     * @param source the source
+     * @param mask   the replacement string for the password
+     * @param text   whether the source is free text, where a whitespace or a 
quote ends a URI
+     */
+    static String maskUserInfo(String source, String mask, boolean text) {
+        int idx = source.indexOf("://");
+        if (idx == -1) {
+            return source;
+        }
+        StringBuilder sb = null;
+        int copied = 0;
+        while (idx != -1) {
+            int next = idx + 3;
+            // there must be a scheme before ://
+            if (idx > 0 && isSchemeChar(source.charAt(idx - 1))) {
+                int[] password = passwordRange(source, idx + 3, text);
+                if (password != null) {
+                    if (sb == null) {
+                        sb = new StringBuilder(source.length());
+                    }
+                    sb.append(source, copied, password[0]).append(mask);
+                    copied = password[1];
+                    next = password[1];
+                }
+            }
+            idx = source.indexOf("://", next);
+        }
+        if (sb == null) {
             return source;
         }
-        return URI_USERINFO_PASSWORD_IN_TEXT.matcher(source)
-                .replaceAll("$1" + Matcher.quoteReplacement(mask) + "$3");
+        return sb.append(source, copied, source.length()).toString();
+    }
+
+    /**
+     * Masks the password of a URI path (without the scheme) that starts with 
{@code user:password@}.
+     *
+     * @param path the path
+     * @param mask the replacement string for the password
+     */
+    static String maskPathUserInfo(String path, String mask) {
+        int[] password = passwordRange(path, 0, false);
+        if (password == null) {
+            return path;
+        }
+        return path.substring(0, password[0]) + mask + 
path.substring(password[1]);
+    }
+
+    // the start and end of the password in the userinfo of the authority that 
begins at start, or null if there is none
+    private static int[] passwordRange(String source, int start, boolean text) 
{
+        int len = source.length();
+        // the user ends at the first colon
+        int colon = -1;
+        for (int i = start; i < len && colon == -1; i++) {
+            char ch = source.charAt(i);
+            if (ch == ':') {
+                colon = i;
+            } else if (ch == '/' || ch == '?' || text && isTextDelimiter(ch)) {
+                return null;
+            }
+        }
+        if (colon == -1) {
+            return null;
+        }
+        // the userinfo ends at the last @ before the path or query
+        int at = -1;
+        for (int i = colon + 1; i < len; i++) {
+            char ch = source.charAt(i);
+            if (ch == '@') {
+                at = i;
+            } else if (ch == '/' || ch == '?' || text && isTextDelimiter(ch)) {
+                break;
+            }
+        }
+        if (at == -1) {
+            // a password with an unencoded / or ? ends at the last @ before a 
query parameter, the next uri,
+            // or (in free text) a whitespace or a quote
+            for (int i = colon + 1; i < len; i++) {
+                char ch = source.charAt(i);
+                if (ch == '@') {
+                    at = i;
+                } else if ((ch == '?' || ch == '&') && 
isQueryParameter(source, i + 1)
+                        || ch == ':' && source.startsWith("//", i + 1)
+                        || text && isTextDelimiter(ch)) {
+                    break;
+                }
+            }
+        }
+        return at == -1 ? null : new int[] { colon + 1, at };
+    }
+
+    // whether the text at the given index is a query parameter key followed 
by =
+    private static boolean isQueryParameter(String source, int from) {
+        for (int i = from; i < source.length(); i++) {
+            char ch = source.charAt(i);
+            if (ch == '=') {
+                return i > from;
+            }
+            if (ch == '&' || ch == '?' || ch == '@' || ch == '/' || ch == ':' 
|| Character.isWhitespace(ch)) {
+                return false;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isSchemeChar(char ch) {
+        return Character.isLetterOrDigit(ch) || ch == '+' || ch == '.' || ch 
== '-';
+    }
+
+    private static boolean isTextDelimiter(char ch) {
+        return Character.isWhitespace(ch) || ch == '"' || ch == '\'';
     }
 
     /**
diff --git 
a/core/camel-util/src/main/java/org/apache/camel/util/URISupport.java 
b/core/camel-util/src/main/java/org/apache/camel/util/URISupport.java
index 25c658967178..870528597371 100644
--- a/core/camel-util/src/main/java/org/apache/camel/util/URISupport.java
+++ b/core/camel-util/src/main/java/org/apache/camel/util/URISupport.java
@@ -27,6 +27,7 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -34,6 +35,7 @@ import java.util.Set;
 import java.util.StringJoiner;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Function;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import static org.apache.camel.util.CamelURIParser.URI_ALREADY_NORMALIZED;
@@ -51,31 +53,22 @@ public final class URISupport {
     public static final char[] RAW_TOKEN_START = { '(', '{' };
     public static final char[] RAW_TOKEN_END = { ')', '}' };
 
-    @SuppressWarnings("RegExpUnnecessaryNonCapturingGroup")
-    private static final String PRE_SECRETS_FORMAT = 
"([?&][^=]*(?:%s)[^=]*)=(RAW(([{][^}]*[}])|([(][^)]*[)]))|[^&]*)";
+    // Match the key of a query parameter as first capture group (the value 
starts after the = sign)
+    private static final Pattern QUERY_PARAMETER_KEY = 
Pattern.compile("[?&]([^?&=]*)=");
 
-    // Match any key-value pair in the URI query string whose key contains
-    // "passphrase" or "password" or secret key (case-insensitive).
-    // First capture group is the key, second is the value.
-    private static final Pattern ALL_SECRETS
-            = 
Pattern.compile(PRE_SECRETS_FORMAT.formatted(SensitiveUtils.getSensitivePattern()),
-                    Pattern.CASE_INSENSITIVE);
+    // Match any of the sensitive keywords (such as passphrase, password or 
secret key) in a query parameter key
+    private static final Pattern SENSITIVE_KEYWORDS
+            = Pattern.compile(SensitiveUtils.getSensitivePattern(), 
Pattern.CASE_INSENSITIVE);
 
-    // Match the user password in the URI as second capture group
-    // (applies to URI with authority component and userinfo token in the form
-    // "user:password").
-    private static final Pattern USERINFO_PASSWORD = 
Pattern.compile("(.*://.*?:)(.*)(@)");
-
-    // Match the user password in the URI path as second capture group
-    // (applies to URI path with authority component and userinfo token in the
-    // form "user:password").
-    private static final Pattern PATH_USERINFO_PASSWORD = 
Pattern.compile("(.*?:)(.*)(@)");
+    // use xxxxxx as replacement as that works well with JMX also
+    private static final String MASK = "xxxxxx";
 
     private static final Charset CHARSET = StandardCharsets.UTF_8;
 
     private static final String EMPTY_QUERY_STRING = "";
 
-    private static Pattern EXTRA_SECRETS;
+    // custom keywords (lower case and without dashes) added by 
addSanitizeKeywords
+    private static volatile Set<String> extraKeywords = Set.of();
 
     private URISupport() {
         // Helper class
@@ -88,16 +81,26 @@ public final class URISupport {
      * @param keywords keywords separated by comma
      */
     public static synchronized void addSanitizeKeywords(String keywords) {
-        StringJoiner pattern = new StringJoiner("|");
+        if (keywords == null) {
+            return;
+        }
+        Set<String> answer = new LinkedHashSet<>(extraKeywords);
         for (String key : keywords.split(",")) {
-            // skip existing keys
-            key = key.toLowerCase(Locale.ROOT).trim();
-            if (!SensitiveUtils.containsSensitive(key)) {
-                pattern.add("\\Q" + key.toLowerCase(Locale.ROOT) + "\\E");
+            // keys are matched without dashes, the same way as the parameter 
names
+            key = key.toLowerCase(Locale.ROOT).trim().replace("-", "");
+            // skip empty and existing keys
+            if (!key.isEmpty() && !SensitiveUtils.containsSensitive(key)) {
+                answer.add(key);
             }
         }
-        EXTRA_SECRETS = Pattern.compile(PRE_SECRETS_FORMAT.formatted(pattern),
-                Pattern.CASE_INSENSITIVE);
+        extraKeywords = Set.copyOf(answer);
+    }
+
+    /**
+     * Removes the keywords added by {@link #addSanitizeKeywords(String)}. 
Only for testing.
+     */
+    static synchronized void resetSanitizeKeywords() {
+        extraKeywords = Set.of();
     }
 
     /**
@@ -106,21 +109,104 @@ public final class URISupport {
      * @param  uri The uri to sanitize.
      * @return     Returns null if the uri is null, otherwise the URI with the 
passphrase, password or secretKey
      *             sanitized.
-     * @see        #ALL_SECRETS and #USERINFO_PASSWORD for the matched pattern
+     * @see        SensitiveUtils#maskUserInfoCredentials(String, String) for 
how the userinfo password is found
      */
     public static String sanitizeUri(String uri) {
-        // use xxxxx as replacement as that works well with JMX also
         String sanitized = uri;
         if (uri != null) {
-            sanitized = ALL_SECRETS.matcher(sanitized).replaceAll("$1=xxxxxx");
-            if (EXTRA_SECRETS != null) {
-                sanitized = 
EXTRA_SECRETS.matcher(sanitized).replaceFirst("$1=xxxxxx");
-            }
-            sanitized = 
USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
+            sanitized = sanitizeQueryParameters(sanitized);
+            sanitized = SensitiveUtils.maskUserInfo(sanitized, MASK, false);
         }
         return sanitized;
     }
 
+    /**
+     * Returns a copy of the parameters where the values of sensitive 
parameters (such as passwords) are masked, using
+     * the same rules as {@link #sanitizeUri(String)}.
+     *
+     * @param  parameters the parameters
+     * @return            null if the parameters are null, otherwise a copy of 
the parameters with the sensitive values
+     *                    masked
+     */
+    public static Map<String, Object> sanitizeParameters(Map<String, Object> 
parameters) {
+        if (parameters == null) {
+            return null;
+        }
+        Map<String, Object> answer = new LinkedHashMap<>(parameters.size());
+        for (Map.Entry<String, Object> entry : parameters.entrySet()) {
+            Object value = entry.getValue();
+            answer.put(entry.getKey(), value != null && 
isSensitiveKey(entry.getKey()) ? MASK : value);
+        }
+        return answer;
+    }
+
+    private static String sanitizeQueryParameters(String uri) {
+        StringBuilder sb = null;
+        int copied = 0;
+        int pos = 0;
+        Matcher matcher = QUERY_PARAMETER_KEY.matcher(uri);
+        while (matcher.find(pos)) {
+            // the value starts after the = sign
+            pos = matcher.end();
+            if (isSensitiveKey(matcher.group(1))) {
+                int end = valueEnd(uri, pos);
+                if (sb == null) {
+                    sb = new StringBuilder(uri.length());
+                }
+                sb.append(uri, copied, pos).append(MASK);
+                copied = end;
+                pos = end;
+            }
+            // otherwise continue searching from the start of the value, as it 
can contain another uri
+        }
+        if (sb == null) {
+            return uri;
+        }
+        return sb.append(uri, copied, uri.length()).toString();
+    }
+
+    private static boolean isSensitiveKey(String key) {
+        // ignore dashes the same way as SensitiveUtils.containsSensitive, as 
options can be configured in dash case
+        String text = key.toLowerCase(Locale.ROOT).replace("-", "");
+        if (SENSITIVE_KEYWORDS.matcher(text).find()) {
+            return true;
+        }
+        for (String keyword : extraKeywords) {
+            if (text.contains(keyword)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static int valueEnd(String uri, int start) {
+        int open = start + RAW_TOKEN_PREFIX.length();
+        if (uri.startsWith(RAW_TOKEN_PREFIX, start) && open < uri.length()) {
+            for (int i = 0; i < RAW_TOKEN_START.length; i++) {
+                if (uri.charAt(open) == RAW_TOKEN_START[i]) {
+                    return rawValueEnd(uri, open + 1, RAW_TOKEN_END[i]);
+                }
+            }
+        }
+        int end = uri.indexOf('&', start);
+        return end != -1 ? end : uri.length();
+    }
+
+    private static int rawValueEnd(String uri, int from, char tokenEnd) {
+        // a RAW value ends at the closing bracket that is followed by & or 
the end, the same way as URIScanner parses it
+        int last = -1;
+        for (int i = from; i < uri.length(); i++) {
+            if (uri.charAt(i) == tokenEnd) {
+                if (i + 1 == uri.length() || uri.charAt(i + 1) == '&') {
+                    return i + 1;
+                }
+                last = i;
+            }
+        }
+        // no such bracket, which happens when the uri is part of a longer 
text, so mask up to the last closing bracket
+        return last != -1 ? last + 1 : uri.length();
+    }
+
     public static String textBlockToSingleLine(String uri) {
         // Java 17 text blocks have new lines with optional white space
         if (uri != null) {
@@ -159,7 +245,7 @@ public final class URISupport {
     public static String sanitizePath(String path) {
         String sanitized = path;
         if (path != null) {
-            sanitized = 
PATH_USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
+            sanitized = SensitiveUtils.maskPathUserInfo(sanitized, MASK);
         }
         return sanitized;
     }
diff --git 
a/core/camel-util/src/test/java/org/apache/camel/util/SensitiveUtilsTest.java 
b/core/camel-util/src/test/java/org/apache/camel/util/SensitiveUtilsTest.java
index eb5513cdc2ce..78a604198124 100644
--- 
a/core/camel-util/src/test/java/org/apache/camel/util/SensitiveUtilsTest.java
+++ 
b/core/camel-util/src/test/java/org/apache/camel/util/SensitiveUtilsTest.java
@@ -105,6 +105,30 @@ class SensitiveUtilsTest {
                 .isEqualTo("{\"url\":\"mongodb://user:xxxxx@host/db\"}");
     }
 
+    @Test
+    void maskUserInfoCredentialsUsesTheSameRuleAsSanitizeUri() {
+        // the userinfo ends at the last @ before the path
+        
assertThat(SensitiveUtils.maskUserInfoCredentials("ftp://joe:p@ss@host/in";, 
"xxxxx"))
+                .isEqualTo("ftp://joe:xxxxx@host/in";);
+        
assertThat(SensitiveUtils.maskUserInfoCredentials("ftp://joe:pa/ss@host/dir";, 
"xxxxx"))
+                .isEqualTo("ftp://joe:xxxxx@host/dir";);
+        // the user may contain an @, such as an email address
+        
assertThat(SensitiveUtils.maskUserInfoCredentials("ftp://[email protected]:pw@host/in";,
 "xxxxx"))
+                .isEqualTo("ftp://[email protected]:xxxxx@host/in";);
+        // an @ in the query is not the end of a password
+        
assertThat(SensitiveUtils.maskUserInfoCredentials("smtp://host:[email protected]",
 "xxxxx"))
+                .isEqualTo("smtp://host:[email protected]");
+        assertThat(SensitiveUtils.maskUserInfoCredentials(
+                
"ftp://joe:[email protected]/in?callbackUrl=http://cb.example.com:8080/x&[email protected]";,
 "xxxxx"))
+                .isEqualTo(
+                        
"ftp://joe:[email protected]/in?callbackUrl=http://cb.example.com:8080/x&[email protected]";);
+        // in free text a whitespace ends the uri
+        assertThat(SensitiveUtils.maskUserInfoCredentials("Connecting to 
ftp://joe:pw@host as [email protected]", "xxxxx"))
+                .isEqualTo("Connecting to ftp://joe:xxxxx@host as 
[email protected]");
+        assertThat(SensitiveUtils.maskUserInfoCredentials("see 
http://host:8080/x for [email protected]", "xxxxx"))
+                .isEqualTo("see http://host:8080/x for [email protected]");
+    }
+
     @Test
     void maskUserInfoCredentialsHandlesNullEmptyAndSpecialMask() {
         assertThat(SensitiveUtils.maskUserInfoCredentials(null, 
"xxxxx")).isNull();
diff --git 
a/core/camel-util/src/test/java/org/apache/camel/util/URISupportTest.java 
b/core/camel-util/src/test/java/org/apache/camel/util/URISupportTest.java
index 489bf6741f18..e4e2b517ce27 100644
--- a/core/camel-util/src/test/java/org/apache/camel/util/URISupportTest.java
+++ b/core/camel-util/src/test/java/org/apache/camel/util/URISupportTest.java
@@ -25,9 +25,11 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -39,6 +41,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class URISupportTest {
 
+    @AfterEach
+    public void resetSanitizeKeywords() {
+        // the keywords added by some tests are global
+        URISupport.resetSanitizeKeywords();
+    }
+
     @Test
     public void testNormalizeEndpointUri() throws Exception {
         String out1 = 
URISupport.normalizeUri("smtp://localhost?username=davsclaus&password=secret");
@@ -766,4 +774,109 @@ public class URISupportTest {
         assertEquals(expected, URISupport.sanitizeUri(uri1));
     }
 
+    @Test
+    public void testSanitizeCustomKeysAppliedToAllParameters() {
+        URISupport.addSanitizeKeywords("pincode");
+        assertThat(URISupport.sanitizeUri("my:host?pincode=1111&pincode=2222"))
+                .isEqualTo("my:host?pincode=xxxxxx&pincode=xxxxxx");
+
+        // keywords added by an earlier call are kept
+        URISupport.addSanitizeKeywords("otp-code");
+        
assertThat(URISupport.sanitizeUri("my:host?otpCode=123456&pincode=1111&otp-code=654321"))
+                
.isEqualTo("my:host?otpCode=xxxxxx&pincode=xxxxxx&otp-code=xxxxxx");
+
+        // only built-in or empty keywords, which must not mask any other 
parameter
+        URISupport.addSanitizeKeywords("password, ");
+        URISupport.addSanitizeKeywords(null);
+        
assertThat(URISupport.sanitizeUri("my:host?foo=bar&timeout=5000")).isEqualTo("my:host?foo=bar&timeout=5000");
+    }
+
+    @Test
+    public void testSanitizeUriRawValueWithClosingBracket() {
+        // a RAW value ends at the closing bracket followed by & or the end, 
the same way as when parsing the query
+        
assertThat(URISupport.sanitizeUri("ftp://host/dir?password=RAW(se)cret)&binary=true"))
+                .isEqualTo("ftp://host/dir?password=xxxxxx&binary=true";);
+        
assertThat(URISupport.sanitizeUri("ftp://host/dir?password=RAW{se}cret}&binary=true";))
+                .isEqualTo("ftp://host/dir?password=xxxxxx&binary=true";);
+        
assertThat(URISupport.sanitizeUri("ftp://host/dir?binary=true&password=RAW()s3cr3t)"))
+                .isEqualTo("ftp://host/dir?binary=true&password=xxxxxx";);
+        
assertThat(URISupport.sanitizeUri("foo:bar?password=RAW(++?)w&rd)&serviceName=somechat"))
+                .isEqualTo("foo:bar?password=xxxxxx&serviceName=somechat");
+    }
+
+    @Test
+    public void testSanitizeUriRawValueInText() {
+        // the uri is part of a longer text, such as a route label, so the RAW 
value is masked up to the last bracket
+        
assertThat(URISupport.sanitizeUri("from[ftp://host/dir?password=RAW(se)cret)]"))
+                .isEqualTo("from[ftp://host/dir?password=xxxxxx]";);
+        
assertThat(URISupport.sanitizeUri("from[ftp://host/dir?password=RAW(secr...]"))
+                .isEqualTo("from[ftp://host/dir?password=xxxxxx";);
+    }
+
+    @Test
+    public void testSanitizeUriWithUserInfoAndOtherUri() {
+        assertThat(URISupport.sanitizeUri(
+                
"ftp://joe:[email protected]/in?callbackUrl=http://cb.example.com:8080/x&[email protected]";))
+                .isEqualTo(
+                        
"ftp://joe:[email protected]/in?callbackUrl=http://cb.example.com:8080/x&[email protected]";);
+        // the userinfo ends at the last @ before the path
+        
assertThat(URISupport.sanitizeUri("ftp://joe:p@[email protected]/in";))
+                .isEqualTo("ftp://joe:[email protected]/in";);
+        // every uri in the text is sanitized
+        assertThat(URISupport.sanitizeUri("From[ftp://joe:secret1@host1/in] -> 
To[sftp://bob:secret2@host2/out]";))
+                .isEqualTo("From[ftp://joe:xxxxxx@host1/in] -> 
To[sftp://bob:xxxxxx@host2/out]";);
+        // no userinfo
+        
assertThat(URISupport.sanitizeUri("smtp://host:[email protected]")).isEqualTo("smtp://host:[email protected]");
+        
assertThat(URISupport.sanitizeUri("ftp://joe@host:21/[email protected]";))
+                .isEqualTo("ftp://joe@host:21/[email protected]";);
+    }
+
+    @Test
+    public void testSanitizeUriDashCaseKeys() {
+        assertThat(URISupport.sanitizeUri(
+                
"my:host?access-key=A1&private-key=P2&connection-string=C3&sasl-jaas-config=J4&pass-phrase=P5&foo-bar=keep"))
+                .isEqualTo(
+                        
"my:host?access-key=xxxxxx&private-key=xxxxxx&connection-string=xxxxxx&sasl-jaas-config=xxxxxx&pass-phrase=xxxxxx&foo-bar=keep");
+    }
+
+    @Test
+    public void testSanitizeUriNestedUri() {
+        
assertThat(URISupport.sanitizeUri("http://host/cb?url=http://other/path?token=abc&foo=bar";))
+                
.isEqualTo("http://host/cb?url=http://other/path?token=xxxxxx&foo=bar";);
+    }
+
+    @Test
+    public void testSanitizeParameters() {
+        Map<String, Object> parameters = new LinkedHashMap<>();
+        parameters.put("password", "secret");
+        parameters.put("private-key", "pk");
+        parameters.put("token", null);
+        parameters.put("foo", "bar");
+
+        assertThat(URISupport.sanitizeParameters(parameters))
+                .containsExactly(entry("password", "xxxxxx"), 
entry("private-key", "xxxxxx"), entry("token", null),
+                        entry("foo", "bar"));
+        // the given parameters are not changed
+        assertThat(parameters).containsEntry("password", "secret");
+        assertThat(URISupport.sanitizeParameters(null)).isNull();
+    }
+
+    @Test
+    public void testSanitizeUriWithUserInfoPasswordWithSlashOrQuestionMark() {
+        
assertThat(URISupport.sanitizeUri("ftp://joe:pa/ss@host/dir";)).isEqualTo("ftp://joe:xxxxxx@host/dir";);
+        
assertThat(URISupport.sanitizeUri("ftp://joe:pa?ss@host/dir?binary=true";))
+                .isEqualTo("ftp://joe:xxxxxx@host/dir?binary=true";);
+        
assertThat(URISupport.sanitizeUri("ftp://joe:p/a@b@host/dir";)).isEqualTo("ftp://joe:xxxxxx@host/dir";);
+        // a password that contains a query parameter (?key=value) cannot be 
told apart from host:port?key=value@...
+        // (see smtp://host:[email protected]), so it is not masked
+        
assertThat(URISupport.sanitizeUri("ftp://joe:p?a=b@host/dir";)).isEqualTo("ftp://joe:p?a=b@host/dir";);
+    }
+
+    @Test
+    public void testSanitizePathWithUserInfoAndOtherAt() {
+        
assertThat(URISupport.sanitizePath("joe:secret@host/[email protected]"))
+                .isEqualTo("joe:xxxxxx@host/[email protected]");
+        
assertThat(URISupport.sanitizePath("joe:pa/ss@host/dir")).isEqualTo("joe:xxxxxx@host/dir");
+    }
+
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 27a65d70d6c5..fdfc1d31cc00 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -613,6 +613,24 @@ structurally significant in Camel's own 
`key=value&key=value` query syntax.
 Code that asserts a literal, fully-normalized endpoint URI string containing 
one of those characters in a
 query value may need to update the expected string to the (now consistently) 
unencoded form.
 
+=== camel-core - masking of sensitive values in endpoint URIs
+
+`URISupport.sanitizeUri()`, which masks secrets in endpoint URIs shown in 
logs, events, JMX names and error
+messages, now masks some values it used to miss, so the masked form of a URI 
can differ from earlier releases:
+
+* option names are matched ignoring dashes, so `access-key` is masked like 
`accessKey`
+* a `RAW(...)` or `RAW{...}` value is masked up to the bracket that ends it, 
also when the value contains that bracket
+* the userinfo password of every URI in a text is masked, not only the last one
+* the `Unknown parameters=[...]` message of an endpoint that cannot be created 
masks the values of sensitive parameters
+
+The keywords added with `camel.main.additionalSensitiveKeywords` (or 
`URISupport.addSanitizeKeywords()`) now apply to
+every matching parameter, not only the first one, and they are added to the 
keywords configured earlier instead of
+replacing them. The keywords apply to the whole JVM and cannot be removed.
+
+`SensitiveUtils.maskUserInfoCredentials()`, used when masking log messages, 
uses the same userinfo rule: the userinfo
+ends at the last `@` before the path, so a password may contain `@`, and an 
`@` in the query of a URI is no longer
+taken as the end of a password.
+
 === camel-core - property placeholders in pollEnrich
 
 Camel 4.22 stopped resolving property placeholders (`{{...}}`) on the 
_per-message evaluated_

Reply via email to