diff --git a/bom/application/pom.xml b/bom/application/pom.xml index a4abfea37c47..3391bab2b5a3 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1147,6 +1147,12 @@ + + org.owasp.encoder + encoder + 1.3.1 + + io.jsonwebtoken jjwt diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml index 8fc458d7c217..c2d57ae52afe 100644 --- a/dotCMS/pom.xml +++ b/dotCMS/pom.xml @@ -987,6 +987,11 @@ + + org.owasp.encoder + encoder + + io.jsonwebtoken jjwt diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderTool.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderTool.java new file mode 100644 index 000000000000..9787e423c576 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderTool.java @@ -0,0 +1,307 @@ +package com.dotcms.rendering.velocity.viewtools; + +import io.vavr.Lazy; +import io.vavr.control.Try; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.apache.commons.validator.routines.UrlValidator; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.velocity.tools.view.tools.ViewTool; +import org.owasp.encoder.Encode; + +/** + * Velocity view tool ({@code $encode}) that exposes the full + * OWASP Java Encoder API + * directly to Velocity templates, covering every output context: HTML, HTML attributes, + * JavaScript, CSS, URI components, and XML. + * + *

Registered in {@code toolbox.xml} under the key {@code encode}. + * + *

Example Velocity usage: + *

+ *   <p>$encode.forHtml($request.getParameter("name"))</p>
+ *   <a href="/search?q=$encode.forUriComponent($request.getParameter("q"))">Go</a>
+ *   <script>var msg = "$encode.forJavaScript($message)";</script>
+ *   <div style="color: $encode.forCssString($color)">...</div>
+ * 
+ * + * @see com.liferay.util.Xss + */ +public class OwaspEncoderTool implements ViewTool { + + private static final Lazy URL_VALIDATOR = + Lazy.of(() -> new UrlValidator(new String[]{"http", "https"})); + + @Override + public void init(final Object obj) { + // no initialisation needed + } + + // ------------------------------------------------------------------------- + // URL safety helpers + // ------------------------------------------------------------------------- + + /** + * Returns {@code true} if the given URL is syntactically valid (http/https only). + * + * @param url the URL to validate + * @return {@code true} if valid + */ + public boolean validateUrl(final String url) { + return URL_VALIDATOR.get().isValid(url); + } + + /** + * Returns {@code true} if any query parameter name or value in the URL contains + * characters that would be altered by HTML-attribute encoding — a strong signal of + * an XSS payload. Returns {@code false} for malformed or non-http(s) URLs. + * + * @param urlToTest the fully-qualified URL to inspect + * @return {@code true} if suspicious content is found in any query parameter + */ + public boolean urlHasXSS(final String urlToTest) { + if (!URL_VALIDATOR.get().isValid(urlToTest)) { + return false; + } + final URL url = Try.of(() -> new URL(urlToTest)).getOrNull(); + if (url == null) { + return true; + } + final List params = + URLEncodedUtils.parse(url.getQuery(), StandardCharsets.UTF_8); + return params.stream().parallel().anyMatch(p -> + (p.getName() != null && !p.getName().equals(forHtmlAttribute(p.getName()))) + || (p.getValue() != null && !p.getValue().equals(forHtmlAttribute(p.getValue())))); + } + + /** + * Returns the URL HTML-attribute-encoded if it is valid, or {@code null} if it fails + * validation. Use this when outputting a URL inside an HTML attribute value. + * + * @param url the URL to clean + * @return the encoded URL, or {@code null} + */ + public String cleanUrl(final String url) { + if (URL_VALIDATOR.get().isValid(url)) { + return forHtmlAttribute(url); + } + return null; + } + + // ------------------------------------------------------------------------- + // HTML encoding + // ------------------------------------------------------------------------- + + /** + * Encodes for (X)HTML text content and quoted attribute values. + * Prefer the more specific {@link #forHtmlContent(String)} or + * {@link #forHtmlAttribute(String)} when the context is known. + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forHtml(final String input) { + return input != null ? Encode.forHtml(input) : ""; + } + + /** + * Encodes for HTML text content (inside an element, not inside an attribute). + * Does not encode single or double quotes. + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forHtmlContent(final String input) { + return input != null ? Encode.forHtmlContent(input) : ""; + } + + /** + * Encodes for a quoted HTML attribute value (both single- and double-quoted). + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forHtmlAttribute(final String input) { + return input != null ? Encode.forHtmlAttribute(input) : ""; + } + + /** + * Encodes for an unquoted HTML attribute value. + * Prefer {@link #forHtmlAttribute(String)} for quoted attributes. + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forHtmlUnquotedAttribute(final String input) { + return input != null ? Encode.forHtmlUnquotedAttribute(input) : ""; + } + + // ------------------------------------------------------------------------- + // CSS encoding + // ------------------------------------------------------------------------- + + /** + * Encodes for a CSS string literal (must be surrounded by quotation characters). + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forCssString(final String input) { + return input != null ? Encode.forCssString(input) : ""; + } + + /** + * Encodes for a CSS {@code url()} context (must be surrounded by {@code url(} / {@code )}). + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forCssUrl(final String input) { + return input != null ? Encode.forCssUrl(input) : ""; + } + + // ------------------------------------------------------------------------- + // URI encoding + // ------------------------------------------------------------------------- + + /** + * Percent-encodes a URI component (query parameter name/value, path segment, etc.). + * This is the preferred method for embedding user data in URL query strings. + * + * @param input the raw value to encode + * @return the percent-encoded value, or an empty string if {@code input} is null + */ + public String forUriComponent(final String input) { + return input != null ? Encode.forUriComponent(input) : ""; + } + + /** + * Percent-encodes a full URI according to RFC 3986. + * Note: a {@code javascript:} URI provided by a user would still pass through. + * Prefer {@link #forUriComponent(String)} for individual components. + * + * @param input the raw URI to encode + * @return the encoded value, or an empty string if {@code input} is null + * @deprecated Use {@link #forUriComponent(String)} for URI components. + */ + @Deprecated + public String forUri(final String input) { + return input != null ? Encode.forUri(input) : ""; + } + + // ------------------------------------------------------------------------- + // JavaScript encoding + // ------------------------------------------------------------------------- + + /** + * Encodes for a JavaScript string literal. Safe in script blocks, HTML event attributes, + * and JSON files. The caller must supply surrounding quotation characters. + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forJavaScript(final String input) { + return input != null ? Encode.forJavaScript(input) : ""; + } + + /** + * Encodes for a JavaScript inline event attribute (e.g. {@code onclick="..."}). + * + * @param input the raw value to encode + * @return the encoded value, or an empty string if {@code input} is null + */ + public String forJavaScriptAttribute(final String input) { + return input != null ? Encode.forJavaScriptAttribute(input) : ""; + } + + /** + * Encodes for a JavaScript {@code ")); + } + + /** + * Given an input containing an ampersand, + * When encoded for HTML body content, + * Then the ampersand is replaced with {@code &}. + */ + @Test + public void forHtml_encodesAmpersand() { + assertTrue(tool.forHtml("a & b").contains("&")); + } + + /** + * Given a null input, + * When encoded for HTML body content, + * Then an empty string is returned instead of throwing. + */ + @Test + public void forHtml_returnsEmptyStringForNull() { + assertEquals("", tool.forHtml(null)); + } + + /** + * Given plain text with no special characters, + * When encoded for HTML body content, + * Then the value is returned unchanged. + */ + @Test + public void forHtml_passesThroughPlainText() { + assertEquals("Hello World", tool.forHtml("Hello World")); + } + + // ------------------------------------------------------------------------- + // forHtmlContent + // ------------------------------------------------------------------------- + + /** + * Given an input containing angle brackets, + * When encoded for HTML text content (element body, not attribute), + * Then raw angle brackets do not appear in the output. + */ + @Test + public void forHtmlContent_encodesAngleBrackets() { + final String result = tool.forHtmlContent("bold"); + assertFalse("Raw angle bracket must not appear", result.contains("")); + } + + /** + * Given a null input, + * When encoded for HTML text content, + * Then an empty string is returned. + */ + @Test + public void forHtmlContent_returnsEmptyStringForNull() { + assertEquals("", tool.forHtmlContent(null)); + } + + // ------------------------------------------------------------------------- + // forHtmlAttribute + // ------------------------------------------------------------------------- + + /** + * Given an input containing a double-quote followed by an event handler payload, + * When encoded for a quoted HTML attribute value, + * Then the double-quote is encoded so the attribute cannot be broken out of. + */ + @Test + public void forHtmlAttribute_encodesDoubleQuote() { + final String result = tool.forHtmlAttribute("\" onmouseover=\"alert(1)"); + assertFalse("Unencoded double-quote must not appear", result.contains("\"")); + } + + /** + * Given an input containing a single-quote followed by an event handler payload, + * When encoded for a quoted HTML attribute value, + * Then the single-quote is encoded so the attribute cannot be broken out of. + */ + @Test + public void forHtmlAttribute_encodesSingleQuote() { + final String result = tool.forHtmlAttribute("' onmouseover='alert(1)"); + assertFalse("Unencoded single-quote must not appear", result.contains("'")); + } + + /** + * Given a null input, + * When encoded for an HTML attribute, + * Then an empty string is returned. + */ + @Test + public void forHtmlAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forHtmlAttribute(null)); + } + + // ------------------------------------------------------------------------- + // forHtmlUnquotedAttribute + // ------------------------------------------------------------------------- + + /** + * Given an input containing spaces, + * When encoded for an unquoted HTML attribute value, + * Then spaces are encoded so they cannot delimit a new attribute. + */ + @Test + public void forHtmlUnquotedAttribute_encodesSpaceAndQuotes() { + final String result = tool.forHtmlUnquotedAttribute("value with spaces"); + assertFalse("Space must be encoded for unquoted attribute", result.contains(" ")); + } + + /** + * Given a null input, + * When encoded for an unquoted HTML attribute, + * Then an empty string is returned. + */ + @Test + public void forHtmlUnquotedAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forHtmlUnquotedAttribute(null)); + } + + // ------------------------------------------------------------------------- + // forCssString + // ------------------------------------------------------------------------- + + /** + * Given an input containing a single-quote CSS breakout payload, + * When encoded for a CSS string literal (surrounded by quotes), + * Then the single-quote is encoded so the string cannot be escaped. + */ + @Test + public void forCssString_encodesSingleQuote() { + final String result = tool.forCssString("'; } body { color: red; x: '"); + assertFalse("Single quote must be encoded for CSS string breakout prevention", + result.contains("'")); + } + + /** + * Given a null input, + * When encoded for a CSS string, + * Then an empty string is returned. + */ + @Test + public void forCssString_returnsEmptyStringForNull() { + assertEquals("", tool.forCssString(null)); + } + + // ------------------------------------------------------------------------- + // forCssUrl + // ------------------------------------------------------------------------- + + /** + * Given an input containing single-quotes, + * When encoded for a CSS url() context, + * Then the single-quotes are encoded. + */ + @Test + public void forCssUrl_encodesQuotes() { + final String result = tool.forCssUrl("'malicious'"); + assertFalse("Single quote must be encoded in CSS URL context", result.contains("'")); + } + + /** + * Given a null input, + * When encoded for a CSS url(), + * Then an empty string is returned. + */ + @Test + public void forCssUrl_returnsEmptyStringForNull() { + assertEquals("", tool.forCssUrl(null)); + } + + // ------------------------------------------------------------------------- + // forUriComponent + // ------------------------------------------------------------------------- + + /** + * Given an input containing spaces and ampersands, + * When encoded for a URI component (query param value, path segment), + * Then both characters are percent-encoded so they cannot break the URL structure. + */ + @Test + public void forUriComponent_encodesSpaceAndSpecialChars() { + final String result = tool.forUriComponent("hello world & more"); + assertFalse("Space must be percent-encoded", result.contains(" ")); + assertFalse("Ampersand must be percent-encoded", result.contains("&")); + } + + /** + * Given an input containing angle brackets (a script tag), + * When encoded for a URI component, + * Then angle brackets are percent-encoded. + */ + @Test + public void forUriComponent_encodesAngleBrackets() { + final String result = tool.forUriComponent(""); + assertFalse("Angle bracket must be percent-encoded", result.contains("<")); + } + + /** + * Given an input consisting entirely of unreserved URI characters (letters, digits, {@code -._~}), + * When encoded for a URI component, + * Then the value is returned unchanged (no over-encoding). + */ + @Test + public void forUriComponent_preservesUnreservedChars() { + final String safe = "hello-world_123~"; + assertEquals("Unreserved URI chars must not be encoded", safe, tool.forUriComponent(safe)); + } + + /** + * Given a null input, + * When encoded for a URI component, + * Then an empty string is returned. + */ + @Test + public void forUriComponent_returnsEmptyStringForNull() { + assertEquals("", tool.forUriComponent(null)); + } + + // ------------------------------------------------------------------------- + // forJavaScript + // ------------------------------------------------------------------------- + + /** + * Given an input containing a single-quote JS string breakout payload, + * When encoded for a JavaScript string literal, + * Then the single-quote is encoded so the string cannot be terminated early. + */ + @Test + public void forJavaScript_encodesSingleQuote() { + final String result = tool.forJavaScript("'; alert(1); var x='"); + assertFalse("Single quote must be encoded for JS string breakout prevention", + result.contains("'")); + } + + /** + * Given an input containing a backslash, + * When encoded for a JavaScript string literal, + * Then the backslash is doubled so it cannot act as an escape character. + */ + @Test + public void forJavaScript_encodesBackslash() { + final String result = tool.forJavaScript("back\\slash"); + assertTrue("Backslash must be doubled in JS output", result.contains("\\\\")); + } + + /** + * Given a null input, + * When encoded for JavaScript, + * Then an empty string is returned. + */ + @Test + public void forJavaScript_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScript(null)); + } + + // ------------------------------------------------------------------------- + // forJavaScriptAttribute / forJavaScriptBlock / forJavaScriptSource + // ------------------------------------------------------------------------- + + /** + * Given an input containing a single-quote, + * When encoded for an inline HTML event attribute (e.g. {@code onclick='...'}), + * Then the single-quote is encoded. + */ + @Test + public void forJavaScriptAttribute_encodesSingleQuote() { + assertFalse(tool.forJavaScriptAttribute("'").contains("'")); + } + + /** + * Given a null input, + * When encoded for a JavaScript event attribute, + * Then an empty string is returned. + */ + @Test + public void forJavaScriptAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScriptAttribute(null)); + } + + /** + * Given an input containing a {@code } closing tag, + * When encoded for a JavaScript {@code "); + assertFalse(" breakout must be prevented in script-block context", + result.contains("")); + } + + /** + * Given a null input, + * When encoded for a JavaScript script block, + * Then an empty string is returned. + */ + @Test + public void forJavaScriptBlock_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScriptBlock(null)); + } + + /** + * Given an input containing a backslash, + * When encoded for a standalone JavaScript source file, + * Then the backslash is doubled. + * Note: single-quotes and angle brackets are safe in a pure JS source context (no HTML parser). + */ + @Test + public void forJavaScriptSource_encodesBackslash() { + final String result = tool.forJavaScriptSource("back\\slash"); + assertTrue("Backslash must be doubled in JS source output", result.contains("\\\\")); + } + + /** + * Given a null input, + * When encoded for a JavaScript source file, + * Then an empty string is returned. + */ + @Test + public void forJavaScriptSource_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScriptSource(null)); + } + + // ------------------------------------------------------------------------- + // forXml family + // ------------------------------------------------------------------------- + + /** + * Given an XML element string with angle brackets, + * When encoded for XML/XHTML content, + * Then the angle brackets are replaced with HTML entities. + */ + @Test + public void forXml_encodesAngleBrackets() { + final String result = tool.forXml(""); + assertFalse("Angle bracket must be encoded", result.contains("")); + assertTrue(result.contains("<")); + } + + /** Given a null input, When encoded for XML, Then an empty string is returned. */ + @Test + public void forXml_returnsEmptyStringForNull() { + assertEquals("", tool.forXml(null)); + } + + /** Given a null input, When encoded for XML content, Then an empty string is returned. */ + @Test + public void forXmlContent_returnsEmptyStringForNull() { + assertEquals("", tool.forXmlContent(null)); + } + + /** Given a null input, When encoded for an XML attribute, Then an empty string is returned. */ + @Test + public void forXmlAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forXmlAttribute(null)); + } + + /** Given a null input, When encoded for an XML comment, Then an empty string is returned. */ + @Test + public void forXmlComment_returnsEmptyStringForNull() { + assertEquals("", tool.forXmlComment(null)); + } + + /** Given a null input, When encoded for a CDATA section, Then an empty string is returned. */ + @Test + public void forCDATA_returnsEmptyStringForNull() { + assertEquals("", tool.forCDATA(null)); + } + + // ------------------------------------------------------------------------- + // forJava + // ------------------------------------------------------------------------- + + /** + * Given an input containing a backslash, + * When encoded for a Java string literal, + * Then the backslash is doubled. + */ + @Test + public void forJava_encodesBackslash() { + final String result = tool.forJava("back\\slash"); + assertTrue("Backslash must be doubled in Java string output", result.contains("\\\\")); + } + + /** + * Given a null input, + * When encoded for a Java string literal, + * Then an empty string is returned. + */ + @Test + public void forJava_returnsEmptyStringForNull() { + assertEquals("", tool.forJava(null)); + } + + // ------------------------------------------------------------------------- + // URL safety helpers — validateUrl + // ------------------------------------------------------------------------- + + /** + * Given a well-formed https URL, + * When validated, + * Then {@code true} is returned. + */ + @Test + public void validateUrl_acceptsValidHttpsUrl() { + assertTrue(tool.validateUrl("https://www.dotcms.com/page?q=1")); + } + + /** + * Given a {@code javascript:} URI, + * When validated (only http/https are accepted), + * Then {@code false} is returned. + */ + @Test + public void validateUrl_rejectsJavascriptScheme() { + assertFalse(tool.validateUrl("javascript:alert(1)")); + } + + /** + * Given a null input, + * When validated, + * Then {@code false} is returned. + */ + @Test + public void validateUrl_rejectsNull() { + assertFalse(tool.validateUrl(null)); + } + + /** + * Given a malformed string that is not a URL, + * When validated, + * Then {@code false} is returned. + */ + @Test + public void validateUrl_rejectsMalformed() { + assertFalse(tool.validateUrl("not a url")); + } + + // ------------------------------------------------------------------------- + // URL safety helpers — urlHasXSS + // ------------------------------------------------------------------------- + + /** + * Given a URL whose query parameters contain only safe values, + * When checked for XSS, + * Then {@code false} is returned. + */ + @Test + public void urlHasXSS_returnsFalseForCleanUrl() { + assertFalse(tool.urlHasXSS("https://www.dotcms.com/page?name=hello")); + } + + /** + * Given a URL whose query parameter value contains a percent-encoded {@code ")); + } + + /** + * Given an input containing angle brackets, double-quotes, and an ampersand, + * When encoded for HTML body content, + * Then none of those raw characters appear in the output. + */ + @Test + public void encodeForHTML_encodesAmpersandAndQuotes() { + final String result = Xss.encodeForHTML("

a & b

"); + // OWASP encoder uses " (numeric) for double-quotes and & for ampersands — both valid HTML + assertFalse("Raw angle bracket must not appear", result.contains("alert(1)"); + assertFalse("Angle brackets must be percent-encoded", result.contains("<")); + assertFalse("Angle brackets must be percent-encoded", result.contains(">")); + } + + /** + * Given a null input, + * When encoded for a URI component, + * Then an empty string is returned. + */ + @Test + public void encodeForURL_returnsEmptyStringForNull() { + assertEquals("", Xss.encodeForURL(null)); + } + + /** + * Given unreserved URI characters (letters, digits, {@code -._~}), + * When encoded for a URI component, + * Then the value is returned unchanged (no over-encoding). + */ + @Test + public void encodeForURL_preservesUnreservedCharacters() { + final String safe = "hello-world_123~"; + assertEquals("Unreserved URI chars must not be encoded", safe, Xss.encodeForURL(safe)); + } + + // ------------------------------------------------------------------------- + // encodeForCSS + // ------------------------------------------------------------------------- + + /** + * Given an input containing a single-quote CSS string breakout payload, + * When encoded for a CSS string literal, + * Then the single-quote is encoded. + */ + @Test + public void encodeForCSS_encodesQuotesAndParens() { + final String input = "'; } body { background: red; x: '"; + final String result = Xss.encodeForCSS(input); + assertFalse("Single quote must be encoded to prevent CSS string breakout", + result.contains("'")); + } + + /** + * Given a null input, + * When encoded for CSS, + * Then an empty string is returned. + */ + @Test + public void encodeForCSS_returnsEmptyStringForNull() { + assertEquals("", Xss.encodeForCSS(null)); + } + + // ------------------------------------------------------------------------- + // escapeHTMLAttrib (legacy — delegates to encodeForHTML) + // ------------------------------------------------------------------------- + + /** + * Given an input with HTML tags, + * When encoded via the legacy {@code escapeHTMLAttrib} method, + * Then HTML entities are produced (delegates to encodeForHTML). + */ + @Test + public void escapeHTMLAttrib_encodesHtmlEntities() { + assertEquals("<b>bold</b>", Xss.escapeHTMLAttrib("bold")); + } + + /** + * Given a null input, + * When encoded via the legacy method, + * Then an empty string is returned. + */ + @Test + public void escapeHTMLAttrib_returnsEmptyStringForNull() { + assertEquals("", Xss.escapeHTMLAttrib(null)); + } + + // ------------------------------------------------------------------------- + // unEscapeHTMLAttrib + // ------------------------------------------------------------------------- + + /** + * Given an HTML-encoded string, + * When decoded, + * Then the original plain-text value is restored. + */ + @Test + public void unEscapeHTMLAttrib_decodesHtmlEntities() { + assertEquals("bold", Xss.unEscapeHTMLAttrib("<b>bold</b>")); + } + + /** + * Given a null input, + * When decoded, + * Then an empty string is returned. + */ + @Test + public void unEscapeHTMLAttrib_returnsEmptyStringForNull() { + assertEquals("", Xss.unEscapeHTMLAttrib(null)); + } + + // ------------------------------------------------------------------------- + // urlHasXSS / uriHasXSS (camelCase — canonical names) + // ------------------------------------------------------------------------- + + /** + * Given a string containing a script tag, + * When checked for XSS patterns, + * Then {@code true} is returned. + */ + @Test + public void urlHasXSS_detectsScriptTag() { + assertTrue(Xss.urlHasXSS("")); + } + + /** + * Given a plain string with no XSS patterns, + * When checked for XSS, + * Then {@code false} is returned. + */ + @Test + public void urlHasXSS_returnsFalseForCleanInput() { + assertFalse(Xss.urlHasXSS("hello world")); + } + + /** + * Given a null input, + * When checked for XSS, + * Then {@code false} is returned (no exception). + */ + @Test + public void urlHasXSS_returnsFalseForNull() { + assertFalse(Xss.urlHasXSS(null)); + } + +}