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

joerghoh pushed a commit to branch master
in repository 
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-engine.git


The following commit(s) were added to refs/heads/master by this push:
     new 7bcfb6c  SLING-13361 escape data for the 
RequestProgressTrackerLogFilter (#92)
7bcfb6c is described below

commit 7bcfb6c005f0986bfc00ae32f9362dfd69e8fd84
Author: Jörg Hoh <[email protected]>
AuthorDate: Wed Sep 23 19:33:17 2026 +0200

    SLING-13361 escape data for the RequestProgressTrackerLogFilter (#92)
---
 .../debug/RequestProgressTrackerLogFilter.java     |  46 +++++++-
 .../debug/RequestProgressTrackerLogFilterTest.java | 118 +++++++++++++++++++++
 2 files changed, 160 insertions(+), 4 deletions(-)

diff --git 
a/src/main/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilter.java
 
b/src/main/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilter.java
index 0fae220..de45734 100644
--- 
a/src/main/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilter.java
+++ 
b/src/main/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilter.java
@@ -131,11 +131,10 @@ public class RequestProgressTrackerLogFilter implements 
Filter {
 
     private void logCompactFormat(RequestProgressTracker rpt) {
         final Iterator<String> messages = rpt.getMessages();
-        final StringBuilder sb = new StringBuilder("\n");
+        final StringBuilder sb = new StringBuilder();
         while (messages.hasNext()) {
-            sb.append(messages.next());
+            sb.append('\n').append(escapeLogMessage(messages.next()));
         }
-        sb.setLength(sb.length() - 1);
         log.debug(sb.toString());
     }
 
@@ -146,10 +145,49 @@ public class RequestProgressTrackerLogFilter implements 
Filter {
         }
         final Iterator<String> it = rpt.getMessages();
         while (it.hasNext()) {
-            log.debug("REQUEST_{} - " + it.next(), requestId);
+            log.debug("REQUEST_{} - {}", requestId, 
escapeLogMessage(it.next()));
         }
     }
 
+    /**
+     * Escapes a request progress tracker message for logging.
+     * <p>
+     * Tracker messages embed request-derived, attacker-controllable data
+     * (container-decoded path info, selectors, suffix, request URI). Escaping
+     * embedded CR and LF prevents such data from forging additional log lines
+     * (log injection, CWE-117). Messages must also always be passed to SLF4J
+     * as a parameter - never concatenated into the format string - so that
+     * attacker-supplied <code>{}</code> sequences are not substituted. The
+     * line terminator the tracker appends to each message is removed.
+     *
+     * @param message the tracker message, may be <code>null</code>
+     * @return the escaped message
+     */
+    static String escapeLogMessage(final String message) {
+        if (message == null) {
+            return null;
+        }
+        // strip the line terminator the tracker appends to each message
+        int end = message.length();
+        while (end > 0 && (message.charAt(end - 1) == '\n' || 
message.charAt(end - 1) == '\r')) {
+            end--;
+        }
+        StringBuilder sb = null;
+        for (int i = 0; i < end; i++) {
+            final char c = message.charAt(i);
+            if (c == '\n' || c == '\r') {
+                if (sb == null) {
+                    sb = new StringBuilder(end + 8);
+                    sb.append(message, 0, i);
+                }
+                sb.append(c == '\n' ? "\\n" : "\\r");
+            } else if (sb != null) {
+                sb.append(c);
+            }
+        }
+        return sb != null ? sb.toString() : message.substring(0, end);
+    }
+
     private String extractExtension(SlingJakartaHttpServletRequest request) {
         final RequestPathInfo requestPathInfo = request.getRequestPathInfo();
         String extension = requestPathInfo.getExtension();
diff --git 
a/src/test/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilterTest.java
 
b/src/test/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilterTest.java
index 37ca80b..f99bf3e 100644
--- 
a/src/test/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilterTest.java
+++ 
b/src/test/java/org/apache/sling/engine/impl/debug/RequestProgressTrackerLogFilterTest.java
@@ -19,13 +19,26 @@
 package org.apache.sling.engine.impl.debug;
 
 import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Iterator;
 
 import org.apache.sling.api.request.RequestProgressTracker;
 import org.apache.sling.api.request.builder.Builders;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.slf4j.Logger;
+import org.slf4j.helpers.MessageFormatter;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /** Partial tests of RequestProgressTrackerLogFilter */
 public class RequestProgressTrackerLogFilterTest {
@@ -89,6 +102,111 @@ public class RequestProgressTrackerLogFilterTest {
         assertTrue("Expecting min ratio of " + minExpectedRatio + ", got " + 
ratio, ratio > minExpectedRatio);
     }
 
+    @Test
+    public void testEscapeLogMessageEscapesCrLf() {
+        // a percent-encoded CR/LF in the request path is decoded by the
+        // container, recorded by the tracker and must not forge log lines
+        assertEquals(
+                "17 LOG Method=GET, PathInfo=/content/x\\r\\n2026-08-11 FAKE 
admin login OK",
+                RequestProgressTrackerLogFilter.escapeLogMessage(
+                        "17 LOG Method=GET, PathInfo=/content/x\r\n2026-08-11 
FAKE admin login OK\n"));
+    }
+
+    @Test
+    public void testEscapeLogMessageKeepsBenignMessage() {
+        assertEquals(
+                "20 TIMER_START{handleSecurity}",
+                RequestProgressTrackerLogFilter.escapeLogMessage("20 
TIMER_START{handleSecurity}\n"));
+        assertEquals("", 
RequestProgressTrackerLogFilter.escapeLogMessage("\n"));
+        assertNull(RequestProgressTrackerLogFilter.escapeLogMessage(null));
+    }
+
+    /**
+     * Replaces the filter's private final SLF4J {@code Logger} with a mock so
+     * the exact arguments reaching the logger can be captured.
+     */
+    private Logger injectMockLogger(final RequestProgressTrackerLogFilter 
filter) throws Exception {
+        final Logger logger = mock(Logger.class);
+        final Field logField = 
RequestProgressTrackerLogFilter.class.getDeclaredField("log");
+        logField.setAccessible(true);
+        logField.set(filter, logger);
+        return logger;
+    }
+
+    private void invokeLogFormat(
+            final RequestProgressTrackerLogFilter filter, final String 
methodName, final RequestProgressTracker rpt)
+            throws Exception {
+        final Method method = filter.getClass().getDeclaredMethod(methodName, 
RequestProgressTracker.class);
+        method.setAccessible(true);
+        method.invoke(filter, rpt);
+    }
+
+    private RequestProgressTracker rptWithMessages(final String... messages) {
+        final RequestProgressTracker rpt = mock(RequestProgressTracker.class);
+        final Iterator<String> it = Arrays.asList(messages).iterator();
+        when(rpt.getMessages()).thenAnswer(invocation -> it);
+        return rpt;
+    }
+
+    @Test
+    public void 
testLogDefaultFormatNeverConcatenatesMessageIntoFormatStringAndEscapesCrLf() 
throws Exception {
+        // a percent-encoded CR/LF in the request path, decoded by the
+        // container and recorded by the tracker, plus a literal "{}" that an
+        // attacker could use to try to smuggle an extra SLF4J placeholder
+        final String attackerMessage = "17 LOG Method=GET, 
PathInfo=/content/x\r\n2026-08-11 FAKE admin login OK {}";
+        final RequestProgressTracker rpt = rptWithMessages(attackerMessage);
+
+        final RequestProgressTrackerLogFilter filter = new 
RequestProgressTrackerLogFilter();
+        final Logger logger = injectMockLogger(filter);
+
+        invokeLogFormat(filter, "logDefaultFormat", rpt);
+
+        // the message must be passed to SLF4J as a parameter, never
+        // concatenated into the format string itself - otherwise attacker
+        // supplied "{}" sequences would be (mis)interpreted as placeholders
+        final ArgumentCaptor<String> formatCaptor = 
ArgumentCaptor.forClass(String.class);
+        final ArgumentCaptor<Object> requestIdCaptor = 
ArgumentCaptor.forClass(Object.class);
+        final ArgumentCaptor<Object> messageCaptor = 
ArgumentCaptor.forClass(Object.class);
+        verify(logger, times(1)).debug(formatCaptor.capture(), 
requestIdCaptor.capture(), messageCaptor.capture());
+
+        assertEquals("REQUEST_{} - {}", formatCaptor.getValue());
+        final String escapedMessage = (String) messageCaptor.getValue();
+        assertFalse(escapedMessage.contains("\r"));
+        assertFalse(escapedMessage.contains("\n"));
+
+        // simulate the actual SLF4J rendering to prove the injected CRLF can
+        // no longer forge a new log record and the attacker's literal "{}"
+        // is not treated as an additional placeholder
+        final String rendered = MessageFormatter.arrayFormat(
+                        formatCaptor.getValue(), new Object[] 
{requestIdCaptor.getValue(), messageCaptor.getValue()})
+                .getMessage();
+        assertFalse(rendered.contains("\r"));
+        assertFalse(rendered.contains("\n"));
+        assertEquals(
+                "REQUEST_1 - 17 LOG Method=GET, 
PathInfo=/content/x\\r\\n2026-08-11 FAKE admin login OK {}", rendered);
+    }
+
+    @Test
+    public void testLogCompactFormatEscapesEveryMessageBeforeJoining() throws 
Exception {
+        final String benign = "20 TIMER_START{handleSecurity}";
+        final String attackerMessage = "17 LOG Method=GET, 
PathInfo=/content/x\r\nFORGED admin login OK";
+        final RequestProgressTracker rpt = rptWithMessages(benign, 
attackerMessage);
+
+        final RequestProgressTrackerLogFilter filter = new 
RequestProgressTrackerLogFilter();
+        final Logger logger = injectMockLogger(filter);
+
+        invokeLogFormat(filter, "logCompactFormat", rpt);
+
+        final ArgumentCaptor<String> messageCaptor = 
ArgumentCaptor.forClass(String.class);
+        verify(logger, times(1)).debug(messageCaptor.capture());
+
+        final String logged = messageCaptor.getValue();
+        assertFalse(logged.contains("\r"));
+        assertFalse("no raw newline may remain other than the message 
separators", logged.contains("\r\n"));
+        assertEquals(
+                "\n" + benign + "\n" + "17 LOG Method=GET, 
PathInfo=/content/x\\r\\nFORGED admin login OK", logged);
+    }
+
     @Test
     public void testConfigMsec() throws Exception {
         final RequestProgressTrackerLogFilter filter = new 
RequestProgressTrackerLogFilter();

Reply via email to