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

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


The following commit(s) were added to refs/heads/main by this push:
     new 5156d2194a Fix SSI issues
5156d2194a is described below

commit 5156d2194aacde4b6c90bed45a00d7c04bbaf9bc
Author: remm <[email protected]>
AuthorDate: Sun Jun 28 15:56:33 2026 +0200

    Fix SSI issues
    
    Quoting with SSI exec.
    Escaping.
    Recursive substitution.
    Avoid tokenizer to handle = inside quotes for parameters.
    Co authored with OpenCode, based on the code review.
---
 java/org/apache/catalina/ssi/SSIExec.java          | 46 +++++++++---
 .../apache/catalina/ssi/SSIExternalResolver.java   |  2 +-
 java/org/apache/catalina/ssi/SSIFilter.java        |  7 +-
 java/org/apache/catalina/ssi/SSIMediator.java      | 67 ++++++++++++++---
 java/org/apache/catalina/ssi/SSIProcessor.java     | 86 +++++++++++++---------
 java/org/apache/catalina/ssi/SSIServlet.java       |  4 +-
 .../catalina/ssi/TestExpressionParseTree.java      | 75 ++++++++++++++++++-
 webapps/docs/changelog.xml                         |  3 +
 8 files changed, 225 insertions(+), 65 deletions(-)

diff --git a/java/org/apache/catalina/ssi/SSIExec.java 
b/java/org/apache/catalina/ssi/SSIExec.java
index e0e3a38410..feec1c7fe7 100644
--- a/java/org/apache/catalina/ssi/SSIExec.java
+++ b/java/org/apache/catalina/ssi/SSIExec.java
@@ -20,7 +20,7 @@ import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.PrintWriter;
-import java.util.StringTokenizer;
+import java.util.ArrayList;
 
 import org.apache.catalina.util.IOTools;
 import org.apache.tomcat.util.res.StringManager;
@@ -67,21 +67,45 @@ public class SSIExec implements SSICommand {
             boolean foundProgram = false;
             try {
                 Runtime rt = Runtime.getRuntime();
-                StringTokenizer st = new StringTokenizer(substitutedValue);
-                String[] cmdArray = new String[st.countTokens()];
-                for (int i = 0; i < cmdArray.length; i++) {
-                    cmdArray[i] = st.nextToken();
+                ArrayList<String> tokens = new ArrayList<>();
+                StringBuilder current = new StringBuilder();
+                boolean inDoubleQuote = false;
+                boolean inSingleQuote = false;
+                for (int j = 0; j < substitutedValue.length(); j++) {
+                    char c = substitutedValue.charAt(j);
+                    if (c == '"' && !inSingleQuote) {
+                        inDoubleQuote = !inDoubleQuote;
+                    } else if (c == '\'' && !inDoubleQuote) {
+                        inSingleQuote = !inSingleQuote;
+                    } else if (Character.isWhitespace(c) && !inDoubleQuote && 
!inSingleQuote) {
+                        if (current.length() > 0) {
+                            tokens.add(current.toString());
+                            current.setLength(0);
+                        }
+                    } else {
+                        current.append(c);
+                    }
                 }
+                if (current.length() > 0) {
+                    tokens.add(current.toString());
+                }
+                String[] cmdArray = tokens.toArray(new String[0]);
                 Process proc = rt.exec(cmdArray);
                 foundProgram = true;
                 char[] buf = new char[BUFFER_SIZE];
-                try (BufferedReader stdOutReader = new BufferedReader(new 
InputStreamReader(proc.getInputStream()));
-                        BufferedReader stdErrReader =
-                                new BufferedReader(new 
InputStreamReader(proc.getErrorStream()))) {
-                    IOTools.flow(stdErrReader, writer, buf);
-                    IOTools.flow(stdOutReader, writer, buf);
+                try {
+                    try (BufferedReader stdOutReader = new BufferedReader(new 
InputStreamReader(proc.getInputStream()));
+                            BufferedReader stdErrReader = new BufferedReader(
+                                    new 
InputStreamReader(proc.getErrorStream()))) {
+                        // We don't spawn a thread here, since this is costly. 
stderr would be usually written
+                        // right away and the amount written would be small
+                        IOTools.flow(stdErrReader, writer, buf);
+                        IOTools.flow(stdOutReader, writer, buf);
+                    }
+                    proc.waitFor();
+                } finally {
+                    proc.destroy();
                 }
-                proc.waitFor();
                 lastModified = System.currentTimeMillis();
             } catch (InterruptedException e) {
                 ssiMediator.log(sm.getString("ssiExec.executeFailed", 
substitutedValue), e);
diff --git a/java/org/apache/catalina/ssi/SSIExternalResolver.java 
b/java/org/apache/catalina/ssi/SSIExternalResolver.java
index 7eeece8c67..25c4e189e3 100644
--- a/java/org/apache/catalina/ssi/SSIExternalResolver.java
+++ b/java/org/apache/catalina/ssi/SSIExternalResolver.java
@@ -56,7 +56,7 @@ public interface SSIExternalResolver {
      * Returns the current date. This is useful for putting the SSI stuff in a 
regression test. Since you can make the
      * current date a constant, it makes testing easier since the output won't 
change.
      *
-     * @return the data
+     * @return the date
      */
     Date getCurrentDate();
 
diff --git a/java/org/apache/catalina/ssi/SSIFilter.java 
b/java/org/apache/catalina/ssi/SSIFilter.java
index 275bb66866..6101c60adb 100644
--- a/java/org/apache/catalina/ssi/SSIFilter.java
+++ b/java/org/apache/catalina/ssi/SSIFilter.java
@@ -26,6 +26,7 @@ import java.io.OutputStreamWriter;
 import java.io.PrintWriter;
 import java.io.Reader;
 import java.io.Serial;
+import java.nio.charset.StandardCharsets;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -173,7 +174,11 @@ public class SSIFilter extends GenericFilter {
             // Ignore, will try to use a writer
         }
         if (out == null) {
-            res.getWriter().write(new String(bytes));
+            String encoding = res.getCharacterEncoding();
+            if (encoding == null) {
+                encoding = StandardCharsets.ISO_8859_1.name();
+            }
+            res.getWriter().write(new String(bytes, encoding));
         } else {
             out.write(bytes);
         }
diff --git a/java/org/apache/catalina/ssi/SSIMediator.java 
b/java/org/apache/catalina/ssi/SSIMediator.java
index d1401fda2e..7d0310fe88 100644
--- a/java/org/apache/catalina/ssi/SSIMediator.java
+++ b/java/org/apache/catalina/ssi/SSIMediator.java
@@ -327,7 +327,7 @@ public class SSIMediator {
      * @return the value after variable substitution
      */
     public String substituteVariables(String val) {
-        // If it has no references or HTML entities then no work need to be 
done
+        // If it contains no variable references ($) or HTML entities (&), no 
processing is needed.
         if (val.indexOf('$') < 0 && val.indexOf('&') < 0) {
             return val;
         }
@@ -343,15 +343,30 @@ public class SSIMediator {
         while (charStart > -1) {
             int charEnd = sb.indexOf(";", charStart);
             if (charEnd > -1) {
-                char c = (char) Integer.parseInt(sb.substring(charStart + 2, 
charEnd));
-                sb.delete(charStart, charEnd + 1);
-                sb.insert(charStart, c);
-                charStart = sb.indexOf("&#");
+                boolean processed = false;
+                try {
+                    int codePoint = Integer.parseInt(sb.substring(charStart + 
2, charEnd));
+                    if (codePoint >= 0 && codePoint <= 0xFFFF) {
+                        char c = (char) codePoint;
+                        sb.delete(charStart, charEnd + 1);
+                        sb.insert(charStart, c);
+                        processed = true;
+                    }
+                } catch (NumberFormatException e) {
+                    // Not a valid numeric entity – leave as-is
+                }
+                if (processed) {
+                    charStart = sb.indexOf("&#", charStart + 1);
+                } else {
+                    charStart = sb.indexOf("&#", charStart + 2);
+                }
             } else {
                 break;
             }
         }
 
+        int maxSubstitutions = 100;
+        int substitutionCount = 0;
         for (int i = 0; i < sb.length();) {
             // Find the next $
             for (; i < sb.length(); i++) {
@@ -364,13 +379,36 @@ public class SSIMediator {
                 break;
             }
             // Check to see if the $ is escaped
-            if (i > 1 && sb.charAt(i - 2) == '\\') {
-                sb.deleteCharAt(i - 2);
-                i--;
-                continue;
+            int dollarIdx = i - 1;
+            // Count consecutive backslashes before $
+            int backslashCount = 0;
+            int pos = dollarIdx - 1;
+            while (pos >= 0 && sb.charAt(pos) == '\\') {
+                backslashCount++;
+                pos--;
+            }
+            if (backslashCount > 0) {
+                int backslashStart = dollarIdx - backslashCount;
+                if (backslashCount % 2 == 1) {
+                    // Odd: $ is escaped. Remove backslashes, keep $ as 
literal.
+                    sb.delete(backslashStart, dollarIdx);
+                    for (int b = 0; b < backslashCount / 2; b++) {
+                        sb.insert(backslashStart, '\\');
+                    }
+                    i = backslashStart + backslashCount / 2 + 1;
+                    continue;
+                } else {
+                    // Even: reduce backslashes, $ is NOT escaped.
+                    sb.delete(backslashStart, dollarIdx);
+                    for (int b = 0; b < backslashCount / 2; b++) {
+                        sb.insert(backslashStart, '\\');
+                    }
+                    dollarIdx = backslashStart + backslashCount / 2;
+                    i = dollarIdx + 1;
+                }
             }
             int nameStart = i;
-            int start = i - 1;
+            int start = dollarIdx;
             int end;
             int nameEnd;
             char endChar = ' ';
@@ -398,8 +436,13 @@ public class SSIMediator {
             }
             // Replace the var name with its value
             sb.replace(start, end, value);
-            // Start searching for the next $ after the value that was just 
substituted.
-            i = start + value.length();
+            // Re-scan from the start of the substituted value. A max iteration
+            // count prevents infinite loops from circular variable references
+            // (e.g. X="$Y" and Y="$X")
+            if (++substitutionCount > maxSubstitutions) {
+                break;
+            }
+            i = start;
         }
         return sb.toString();
     }
diff --git a/java/org/apache/catalina/ssi/SSIProcessor.java 
b/java/org/apache/catalina/ssi/SSIProcessor.java
index 06cf0d2e6c..276194d5db 100644
--- a/java/org/apache/catalina/ssi/SSIProcessor.java
+++ b/java/org/apache/catalina/ssi/SSIProcessor.java
@@ -23,7 +23,6 @@ import java.io.Reader;
 import java.io.StringWriter;
 import java.util.HashMap;
 import java.util.Locale;
-import java.util.StringTokenizer;
 
 import org.apache.catalina.util.IOTools;
 
@@ -197,47 +196,62 @@ public class SSIProcessor {
      * @return an array with the parameter names
      */
     protected String[] parseParamNames(StringBuilder cmd, int start) {
+        // Count parameters first
+        int count = 0;
         int bIdx = start;
-        int i = 0;
-        int quotes;
-        boolean inside = false;
-        StringBuilder retBuf = new StringBuilder();
+        boolean insideQuotes = false;
+        boolean escaped = false;
         while (bIdx < cmd.length()) {
-            if (!inside) {
-                while (bIdx < cmd.length() && isSpace(cmd.charAt(bIdx))) {
-                    bIdx++;
+            char c = cmd.charAt(bIdx);
+            if (escaped) {
+                escaped = false;
+            } else if (c == '\\') {
+                escaped = true;
+            } else if (c == '"' || c == '\'' || c == '`') {
+                insideQuotes = !insideQuotes;
+            } else if (c == '=' && !insideQuotes) {
+                count++;
+            }
+            bIdx++;
+        }
+        String[] retString = new String[count];
+        // Extract parameter names (characters between spaces and the first 
unquoted '=')
+        bIdx = start;
+        int idx = 0;
+        StringBuilder nameBuf = new StringBuilder();
+        boolean collectingName = false;
+        insideQuotes = false;
+        escaped = false;
+        while (bIdx < cmd.length() && idx < count) {
+            char c = cmd.charAt(bIdx);
+            if (escaped) {
+                escaped = false;
+                if (collectingName && !insideQuotes) {
+                    nameBuf.append(c);
                 }
-                if (bIdx >= cmd.length()) {
-                    break;
+            } else if (c == '\\') {
+                escaped = true;
+                if (collectingName && !insideQuotes) {
+                    nameBuf.append(c);
                 }
-                inside = true;
-            } else {
-                while (bIdx < cmd.length() && cmd.charAt(bIdx) != '=') {
-                    retBuf.append(cmd.charAt(bIdx));
-                    bIdx++;
-                }
-                retBuf.append('=');
-                inside = false;
-                quotes = 0;
-                boolean escaped = false;
-                for (; bIdx < cmd.length() && quotes != 2; bIdx++) {
-                    char c = cmd.charAt(bIdx);
-                    // Need to skip escaped characters
-                    if (c == '\\' && !escaped) {
-                        escaped = true;
-                        continue;
-                    }
-                    if (c == '"' && !escaped) {
-                        quotes++;
-                    }
-                    escaped = false;
+            } else if (!insideQuotes && (c == '"' || c == '\'' || c == '`')) {
+                insideQuotes = true;
+            } else if (insideQuotes && (c == '"' || c == '\'' || c == '`')) {
+                insideQuotes = false;
+            } else if (!insideQuotes && isSpace(c)) {
+                if (collectingName) {
+                    nameBuf.setLength(0);
+                    collectingName = false;
                 }
+            } else if (!insideQuotes && c == '=') {
+                retString[idx++] = nameBuf.toString().trim();
+                nameBuf.setLength(0);
+                collectingName = false;
+            } else if (!insideQuotes) {
+                collectingName = true;
+                nameBuf.append(c);
             }
-        }
-        StringTokenizer str = new StringTokenizer(retBuf.toString(), "=");
-        String[] retString = new String[str.countTokens()];
-        while (str.hasMoreTokens()) {
-            retString[i++] = str.nextToken().trim();
+            bIdx++;
         }
         return retString;
     }
diff --git a/java/org/apache/catalina/ssi/SSIServlet.java 
b/java/org/apache/catalina/ssi/SSIServlet.java
index e1c3a7ccca..73f95e8e54 100644
--- a/java/org/apache/catalina/ssi/SSIServlet.java
+++ b/java/org/apache/catalina/ssi/SSIServlet.java
@@ -101,7 +101,7 @@ public class SSIServlet extends HttpServlet {
 
 
     /**
-     * Process and forward the GET request to our 
<code>requestHandler()</code>.
+     * Delegate the GET request to our <code>requestHandler()</code>.
      *
      * @param req a value of type 'HttpServletRequest'
      * @param res a value of type 'HttpServletResponse'
@@ -119,7 +119,7 @@ public class SSIServlet extends HttpServlet {
 
 
     /**
-     * Process and forward the POST request to our 
<code>requestHandler()</code>.
+     * Delegate the POST request to our <code>requestHandler()</code>.
      *
      * @param req a value of type 'HttpServletRequest'
      * @param res a value of type 'HttpServletResponse'
diff --git a/test/org/apache/catalina/ssi/TestExpressionParseTree.java 
b/test/org/apache/catalina/ssi/TestExpressionParseTree.java
index 50c3856cd8..18ccebb9a2 100644
--- a/test/org/apache/catalina/ssi/TestExpressionParseTree.java
+++ b/test/org/apache/catalina/ssi/TestExpressionParseTree.java
@@ -136,9 +136,80 @@ public class TestExpressionParseTree {
     }
 
 
+    @Test
+    public void testSubstituteVariablesPlainVar() throws Exception {
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("VAR", "value");
+        Assert.assertEquals("value", mediator.substituteVariables("$VAR"));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesBracedVar() throws Exception {
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("VAR", "value");
+        Assert.assertEquals("value", mediator.substituteVariables("${VAR}"));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesNoVar() throws Exception {
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        Assert.assertEquals("", mediator.substituteVariables("$UNKNOWN"));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesSingleBackslashEscapesDollar() throws 
Exception {
+        // Input: \$VAR (1 backslash) -> $ is escaped, backslash consumed -> 
literal $VAR
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("VAR", "value");
+        String input = "\\" + "$VAR";
+        Assert.assertEquals("$VAR", mediator.substituteVariables(input));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesTwoBackslashesVarSubstituted() throws 
Exception {
+        // Input: \\$VAR (2 backslashes) -> even, $ not escaped, reduce to 1 
-> \ + value
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("VAR", "value");
+        String input = "\\\\" + "$VAR";
+        Assert.assertEquals("\\" + "value", 
mediator.substituteVariables(input));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesThreeBackslashesEscapesDollar() throws 
Exception {
+        // Input: 3 backslashes + $VAR -> odd, $ escaped, keep 1 backslash -> 
\$VAR literal
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("VAR", "value");
+        String input = "\\\\" + "\\" + "$VAR";
+        Assert.assertEquals("\\" + "$VAR", 
mediator.substituteVariables(input));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesFourBackslashesVarSubstituted() throws 
Exception {
+        // Input: 4 backslashes + $VAR -> even, $ not escaped, reduce to 2 -> 
\\ + value
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("VAR", "value");
+        String input = "\\\\" + "\\\\" + "$VAR";
+        Assert.assertEquals("\\\\" + "value", 
mediator.substituteVariables(input));
+    }
+
+
+    @Test
+    public void testSubstituteVariablesEscapedDollarFollowedByVar() throws 
Exception {
+        // Input: \$Y$Y -> escaped $Y (literal) followed by variable $Y -> $Y 
+ value
+        SSIMediator mediator = new SSIMediator(new 
TesterSSIExternalResolver(), LAST_MODIFIED);
+        mediator.setVariableValue("Y", "result");
+        String input = "\\" + "$Y$Y";
+        Assert.assertEquals("$Yresult", mediator.substituteVariables(input));
+    }
+
     /**
-     * Minimal implementation that provides the bare essentials require for the
-     * unit tests.
+     * Minimal implementation that provides the bare essentials require for 
the unit tests.
      */
     private static class TesterSSIExternalResolver
             implements SSIExternalResolver {
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 75bfc3f0f3..31cd2492af 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -253,6 +253,9 @@
         <code>JAASRealm</code> should do a logout if login does not fail 
outright but
         does not produce a Principal. (remm)
       </fix>
+      <fix>
+        Various edge cases for SSI substitutions, quoting and escaping. (remm)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Coyote">


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to