You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@freemarker.apache.org by dd...@apache.org on 2022/12/28 20:48:36 UTC

[freemarker] 02/02: Improved StringUtil.jsStringEnc to support both quotation marks and apostrophe. Added a mode that is both compatible with JSON and JavaScript, without decreasing safety when used in JavaScript.

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

ddekany pushed a commit to branch 2.3-gae
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit e5fa45f4c589b306b5a8ee8c5a42229d378eb607
Author: ddekany <dd...@apache.org>
AuthorDate: Wed Dec 28 21:46:24 2022 +0100

    Improved StringUtil.jsStringEnc to support both quotation marks and apostrophe. Added a mode that is both compatible with JSON and JavaScript, without decreasing safety when used in JavaScript.
---
 .../freemarker/core/AbstractLegacyCFormat.java     |   5 +-
 src/main/java/freemarker/core/JSONCFormat.java     |   4 +-
 .../java/freemarker/core/JavaScriptCFormat.java    |   4 +-
 .../freemarker/template/utility/StringUtil.java    | 117 ++++++++++++++++-----
 src/manual/en_US/book.xml                          |   8 ++
 .../template/utility/StringUtilTest.java           |  77 +++++++++++++-
 6 files changed, 183 insertions(+), 32 deletions(-)

diff --git a/src/main/java/freemarker/core/AbstractLegacyCFormat.java b/src/main/java/freemarker/core/AbstractLegacyCFormat.java
index 99440684..bdf7577d 100644
--- a/src/main/java/freemarker/core/AbstractLegacyCFormat.java
+++ b/src/main/java/freemarker/core/AbstractLegacyCFormat.java
@@ -25,6 +25,8 @@ import freemarker.template.TemplateException;
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateNumberModel;
 import freemarker.template.utility.StringUtil;
+import freemarker.template.utility.StringUtil.JsStringEncCompatibility;
+import freemarker.template.utility.StringUtil.JsStringEncQuotation;
 
 /**
  * Super class of {@link CFormat}-s that merely exist to mimic old {@code ?c} behavior for backward compatibility.
@@ -43,7 +45,8 @@ public abstract class AbstractLegacyCFormat extends CFormat {
 
     @Override
     final String formatString(String s, Environment env) throws TemplateException {
-        return StringUtil.jsStringEnc(s, true, true);
+        return StringUtil.jsStringEnc(
+                s, JsStringEncCompatibility.JAVA_SCRIPT_OR_JSON, JsStringEncQuotation.QUOTATION_MARK);
     }
 
     @Override
diff --git a/src/main/java/freemarker/core/JSONCFormat.java b/src/main/java/freemarker/core/JSONCFormat.java
index 5cf4c52a..ee469941 100644
--- a/src/main/java/freemarker/core/JSONCFormat.java
+++ b/src/main/java/freemarker/core/JSONCFormat.java
@@ -23,6 +23,8 @@ import freemarker.template.Configuration;
 import freemarker.template.TemplateException;
 import freemarker.template.Version;
 import freemarker.template.utility.StringUtil;
+import freemarker.template.utility.StringUtil.JsStringEncCompatibility;
+import freemarker.template.utility.StringUtil.JsStringEncQuotation;
 
 /**
  * JSON {@link CFormat}; when this is used, values output by {@code ?c} are valid JSON values, and therefore also
@@ -46,7 +48,7 @@ public final class JSONCFormat extends AbstractJSONLikeFormat {
 
     @Override
     String formatString(String s, Environment env) throws TemplateException {
-        return StringUtil.jsStringEnc(s, true, true);
+        return StringUtil.jsStringEnc(s, JsStringEncCompatibility.JSON, JsStringEncQuotation.QUOTATION_MARK);
     }
 
     @Override
diff --git a/src/main/java/freemarker/core/JavaScriptCFormat.java b/src/main/java/freemarker/core/JavaScriptCFormat.java
index d9ba7229..1cb44adf 100644
--- a/src/main/java/freemarker/core/JavaScriptCFormat.java
+++ b/src/main/java/freemarker/core/JavaScriptCFormat.java
@@ -21,6 +21,8 @@ package freemarker.core;
 
 import freemarker.template.TemplateException;
 import freemarker.template.utility.StringUtil;
+import freemarker.template.utility.StringUtil.JsStringEncCompatibility;
+import freemarker.template.utility.StringUtil.JsStringEncQuotation;
 
 /**
  * JavaScript {@link CFormat}. This is almost the same as {@link JSONCFormat}, but it uses shorter forms where
@@ -41,7 +43,7 @@ public final class JavaScriptCFormat extends AbstractJSONLikeFormat {
 
     @Override
     String formatString(String s, Environment env) throws TemplateException {
-        return StringUtil.jsStringEnc(s, false, true);
+        return StringUtil.jsStringEnc(s, JsStringEncCompatibility.JAVA_SCRIPT, JsStringEncQuotation.QUOTATION_MARK);
     }
 
     @Override
diff --git a/src/main/java/freemarker/template/utility/StringUtil.java b/src/main/java/freemarker/template/utility/StringUtil.java
index 0d76a48f..51f22a73 100644
--- a/src/main/java/freemarker/template/utility/StringUtil.java
+++ b/src/main/java/freemarker/template/utility/StringUtil.java
@@ -19,6 +19,8 @@
 
 package freemarker.template.utility;
 
+import static freemarker.template.utility.StringUtil.JsStringEncQuotation.*;
+
 import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.io.Writer;
@@ -1320,7 +1322,7 @@ public class StringUtil {
     
     /**
      * Escapes a {@link String} to be safely insertable into a JavaScript string literal; for more see
-     * {@link #jsStringEnc(String, boolean, boolean) jsStringEnc(s, false, false)}.
+     * {@link #jsStringEnc(String, JsStringEncCompatibility, JsStringEncQuotation) jsStringEnc(s, false, QuotationMode.NONE)}.
      */
     public static String javaScriptStringEnc(String s) {
         return jsStringEnc(s, false);
@@ -1340,12 +1342,12 @@ public class StringUtil {
     
     /**
      * Escapes a {@link String} to be safely insertable into a JSON or JavaScript string literal; for more see
-     * {@link #jsStringEnc(String, boolean, boolean) jsStringEnc(s, json, false)}.
+     * {@link #jsStringEnc(String, JsStringEncCompatibility, JsStringEncQuotation) jsStringEnc(s, json, QuotationMode.NONE)}.
      *
      * @since 2.3.20
      */
     public static String jsStringEnc(String s, boolean json) {
-        return jsStringEnc(s, json, false);
+        return jsStringEnc(s, json ? JsStringEncCompatibility.JSON : JsStringEncCompatibility.JAVA_SCRIPT, null);
     }
 
     /**
@@ -1399,22 +1401,24 @@ public class StringUtil {
      * </table>
      *
      * @param s The string to escape
-     * @param json If escaping should restrict itself to rules that are valid in both JSON and JavaScript.
-     * @param quote If quotation marks should be added around the result.
-     *      Currently, it's always ({@code "}, not {@code '}).
+     * @param compatibility If escaping should restrict itself to rules that are valid in JSON, in JavaScript, or in both.
+     * @param quotation In not {@code null}, quotation marks of this type are added around the value.
      *
      * @since 2.3.32
      */
-    public static String jsStringEnc(String s, boolean json, boolean quote) {
+    public static String jsStringEnc(String s, JsStringEncCompatibility compatibility, JsStringEncQuotation quotation) {
         NullArgumentException.check("s", s);
         
         int ln = s.length();
         StringBuilder sb;
-        if (quote) {
-            sb = new StringBuilder(ln + 8);
-            sb.append('"');
-        } else {
+        if (quotation == null) {
             sb = null;
+        } else {
+            if (quotation == APOSTROPHE && compatibility.jsonCompatible) {
+                throw new IllegalArgumentException("JSON compatible mode doesn't allow quotationMode=" + quotation);
+            }
+            sb = new StringBuilder(ln + 8);
+            sb.append(quotation.getSymbol());
         }
         for (int i = 0; i < ln; i++) {
             final char c = s.charAt(i);
@@ -1435,19 +1439,23 @@ public class StringUtil {
                         escapeType = ESC_HEXA;
                     }
                 } else if (c == '"') {
-                    escapeType = ESC_BACKSLASH;
+                    escapeType = quotation == APOSTROPHE ? NO_ESC : ESC_BACKSLASH;
                 } else if (c == '\'') {
-                    escapeType = json || quote ? NO_ESC : ESC_BACKSLASH;
+                    escapeType = !compatibility.javaScriptCompatible || quotation == QUOTATION_MARK ? NO_ESC
+                            : (compatibility.jsonCompatible ? ESC_HEXA : ESC_BACKSLASH);
                 } else if (c == '\\') {
                     escapeType = ESC_BACKSLASH; 
-                } else if (c == '/'  && (i == 0 || s.charAt(i - 1) == '<')) {  // against closing elements
-                    escapeType = quote ? NO_ESC : ESC_BACKSLASH;
-                } else if (c == '>') {  // against "]]> and "-->"
+                } else if (c == '/'
+                        && (i == 0 && quotation == null || i != 0 && s.charAt(i - 1) == '<')) {
+                    // against closing elements with "</"
+                    escapeType = ESC_BACKSLASH;
+                } else if (c == '>') {
+                    // against "]]> and "-->"
                     final boolean dangerous;
-                    if (i == 0) {
-                        dangerous = true;
-                    } else if (quote) {
+                    if (quotation != null && i < 2) {
                         dangerous = false;
+                    } else if (i == 0) {
+                        dangerous = true;
                     } else {
                         final char prevC = s.charAt(i - 1);
                         if (prevC == ']' || prevC == '-') {
@@ -1461,13 +1469,12 @@ public class StringUtil {
                             dangerous = false;
                         }
                     }
-                    escapeType = dangerous ? (json ? ESC_HEXA : ESC_BACKSLASH) : NO_ESC;
-                } else if (c == '<') {  // against "<!"
+                    escapeType = dangerous ? (compatibility.jsonCompatible ? ESC_HEXA : ESC_BACKSLASH) : NO_ESC;
+                } else if (c == '<') {
+                    // against "<!"
                     final boolean dangerous;
                     if (i == ln - 1) {
-                        dangerous = true;
-                    } else if (quote) {
-                        dangerous = false;
+                        dangerous = quotation == null;
                     } else {
                         char nextC = s.charAt(i + 1);
                         dangerous = nextC == '!' || nextC == '?';
@@ -1491,7 +1498,7 @@ public class StringUtil {
                     if (escapeType > 0x20) {
                         sb.append((char) escapeType);
                     } else if (escapeType == ESC_HEXA) {
-                        if (!json && c < 0x100) {
+                        if (!compatibility.jsonCompatible && c < 0x100) {
                             sb.append('x');
                             sb.append(toHexDigitUpperCase(c >> 4));
                             sb.append(toHexDigitUpperCase(c & 0xF));
@@ -1515,8 +1522,8 @@ public class StringUtil {
             if (sb != null) sb.append(c);
         } // for each characters
 
-        if (quote) {
-            sb.append('"');
+        if (quotation != null) {
+            sb.append(quotation.getSymbol());
         }
         
         return sb == null ? s : sb.toString();
@@ -2168,5 +2175,59 @@ public class StringUtil {
         }
         return sb.toString();
     }
-    
+
+    /**
+     * Used as the argument of {@link StringUtil#jsStringEnc(String, JsStringEncCompatibility, JsStringEncQuotation)}.
+     *
+     * @since 2.3.32
+     */
+    public enum JsStringEncCompatibility {
+        /**
+         * Output is expected to be used in JavaScript, not in JSON.
+         */
+        JAVA_SCRIPT(true, false),
+
+        /**
+         * Output is expected to be used in JSON, not in JavaScript. While JSON is compatible with JavaScript, in this
+         * mode we don't care about escaping apostrophe, as it's not special in JSON.
+         */
+        JSON(false, true),
+
+        /**
+         * Output is expected to be used both in JSON and JavaScript.
+         */
+        JAVA_SCRIPT_OR_JSON(true, true);
+
+        JsStringEncCompatibility(boolean javaScriptCompatible, boolean jsonCompatible) {
+            this.javaScriptCompatible = javaScriptCompatible;
+            this.jsonCompatible = jsonCompatible;
+        }
+
+        private final boolean javaScriptCompatible;
+        private final boolean jsonCompatible;
+
+        boolean isJSONCompatible() {
+            return jsonCompatible;
+        }
+    }
+
+    /**
+     * Used as the argument of {@link StringUtil#jsStringEnc(String, JsStringEncCompatibility, JsStringEncQuotation)}.
+     *
+     * @since 2.3.32
+     */
+    public enum JsStringEncQuotation {
+        QUOTATION_MARK('"'),
+        APOSTROPHE('\'');
+
+        private final char symbol;
+
+        JsStringEncQuotation(char symbol) {
+            this.symbol = symbol;
+        }
+
+        public char getSymbol() {
+            return symbol;
+        }
+    }
 }
diff --git a/src/manual/en_US/book.xml b/src/manual/en_US/book.xml
index ed9ea259..b4288ced 100644
--- a/src/manual/en_US/book.xml
+++ b/src/manual/en_US/book.xml
@@ -30211,6 +30211,14 @@ TemplateModel x = env.getVariable("x");  // get variable x</programlisting>
               mode.</para>
             </listitem>
 
+            <listitem>
+              <para>Improved <literal>StringUtil.jsStringEnc</literal> and
+              <literal>javaSctringEsc</literal> to support quoting. Also
+              <literal>jsStringEnc</literal> now have a mode that targets both
+              JavaScript and JSON, and doesn't give up apostrophe
+              escaping.</para>
+            </listitem>
+
             <listitem>
               <para><link
               xlink:href="https://issues.apache.org/jira/browse/FREEMARKER-198">FREEMARKER-198</link>:
diff --git a/src/test/java/freemarker/template/utility/StringUtilTest.java b/src/test/java/freemarker/template/utility/StringUtilTest.java
index 0bb29345..1614dc0d 100644
--- a/src/test/java/freemarker/template/utility/StringUtilTest.java
+++ b/src/test/java/freemarker/template/utility/StringUtilTest.java
@@ -19,6 +19,8 @@
 
 package freemarker.template.utility;
 
+import static freemarker.template.utility.StringUtil.JsStringEncCompatibility.*;
+import static freemarker.template.utility.StringUtil.JsStringEncQuotation.*;
 import static org.hamcrest.Matchers.*;
 import static org.junit.Assert.*;
 
@@ -30,6 +32,7 @@ import org.hamcrest.Matchers;
 import org.junit.Test;
 
 import freemarker.core.ParseException;
+import freemarker.template.utility.StringUtil.JsStringEncCompatibility;
 
 public class StringUtilTest {
 
@@ -449,5 +452,77 @@ public class StringUtilTest {
         StringUtil.RTFEnc(in, sw);
         assertEquals(expected, sw.toString());
     }
-    
+
+    @Test
+    public void jsStringEncQuotationTests() {
+        for (JsStringEncCompatibility anyCompatibility : JsStringEncCompatibility.values()) {
+            JsStringEncCompatibility anyJsonCompatible = anyCompatibility.isJSONCompatible() ? anyCompatibility : JSON;
+
+            assertEquals("", StringUtil.jsStringEnc("", anyCompatibility, null));
+            assertEquals("''", StringUtil.jsStringEnc("", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"\"", StringUtil.jsStringEnc("", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("a", StringUtil.jsStringEnc("a", anyCompatibility, null));
+            assertEquals("a", StringUtil.jsStringEnc("a", anyCompatibility, null));
+            assertEquals("'a'", StringUtil.jsStringEnc("a", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"a\"", StringUtil.jsStringEnc("a", anyCompatibility, QUOTATION_MARK));
+
+            try {
+                StringUtil.jsStringEnc("", anyJsonCompatible, APOSTROPHE);
+                fail();
+            } catch (IllegalArgumentException e) {
+                // expected
+            }
+
+            assertEquals("a\\'b", StringUtil.jsStringEnc("a'b", JAVA_SCRIPT, null));
+            assertEquals("a'b", StringUtil.jsStringEnc("a'b", JSON, null));
+            assertEquals("a\\u0027b", StringUtil.jsStringEnc("a'b", JAVA_SCRIPT_OR_JSON, null));
+            assertEquals("'a\\'b'", StringUtil.jsStringEnc("a'b", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"a'b\"", StringUtil.jsStringEnc("a'b", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("<\\/e>", StringUtil.jsStringEnc("</e>", anyCompatibility, null));
+            assertEquals("'<\\/e>'", StringUtil.jsStringEnc("</e>", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"<\\/e>\"", StringUtil.jsStringEnc("</e>", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("\\/e>", StringUtil.jsStringEnc("/e>", anyCompatibility, null));
+            assertEquals("'/e>'", StringUtil.jsStringEnc("/e>", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"/e>\"", StringUtil.jsStringEnc("/e>", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("\\>", StringUtil.jsStringEnc(">", JAVA_SCRIPT, null));
+            assertEquals("\\u003E", StringUtil.jsStringEnc(">", JSON, null));
+            assertEquals("'>'", StringUtil.jsStringEnc(">", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\">\"", StringUtil.jsStringEnc(">", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("-\\>", StringUtil.jsStringEnc("->", JAVA_SCRIPT, null));
+            assertEquals("-\\u003E", StringUtil.jsStringEnc("->", JSON, null));
+            assertEquals("'->'", StringUtil.jsStringEnc("->", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"->\"", StringUtil.jsStringEnc("->", JAVA_SCRIPT, QUOTATION_MARK));
+
+            assertEquals("--\\>", StringUtil.jsStringEnc("-->", JAVA_SCRIPT, null));
+            assertEquals("--\\u003E", StringUtil.jsStringEnc("-->", anyJsonCompatible, null));
+            assertEquals("'--\\>'", StringUtil.jsStringEnc("-->", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"--\\>\"", StringUtil.jsStringEnc("-->", JAVA_SCRIPT, QUOTATION_MARK));
+            assertEquals("\"--\\u003E\"", StringUtil.jsStringEnc("-->", anyJsonCompatible, QUOTATION_MARK));
+
+            assertEquals("x->", StringUtil.jsStringEnc("x->", anyCompatibility, null));
+            assertEquals("'x->'", StringUtil.jsStringEnc("x->", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"x->\"", StringUtil.jsStringEnc("x->", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("\\x3C", StringUtil.jsStringEnc("<", JAVA_SCRIPT, null));
+            assertEquals("\\u003C", StringUtil.jsStringEnc("<", JSON, null));
+            assertEquals("'<'", StringUtil.jsStringEnc("<", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"<\"", StringUtil.jsStringEnc("<", anyCompatibility, QUOTATION_MARK));
+
+            assertEquals("\\x3C!", StringUtil.jsStringEnc("<!", JAVA_SCRIPT, null));
+            assertEquals("\\u003C!", StringUtil.jsStringEnc("<!", JSON, null));
+            assertEquals("'\\x3C!'", StringUtil.jsStringEnc("<!", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"\\x3C!\"", StringUtil.jsStringEnc("<!", JAVA_SCRIPT, QUOTATION_MARK));
+            assertEquals("\"\\u003C!\"", StringUtil.jsStringEnc("<!", anyJsonCompatible, QUOTATION_MARK));
+
+            assertEquals("<x", StringUtil.jsStringEnc("<x", anyCompatibility, null));
+            assertEquals("'<x'", StringUtil.jsStringEnc("<x", JAVA_SCRIPT, APOSTROPHE));
+            assertEquals("\"<x\"", StringUtil.jsStringEnc("<x", anyCompatibility, QUOTATION_MARK));
+        }
+    }
+
 }