From 1e555c0b029feee30f44474e7978c3aedda33b17 Mon Sep 17 00:00:00 2001 From: mbiuki Date: Tue, 14 Apr 2026 16:12:05 -0400 Subject: [PATCH 1/7] feat(security): add OWASP Java Encoder and expose via XssWebAPI viewtool (fixes #24120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the OWASP Java Encoder (1.3.1) into dotCMS core as the standard context-aware output encoding library for XSS prevention. Changes: - bom/application/pom.xml, dotCMS/pom.xml: add org.owasp.encoder:encoder:1.3.1 - Xss.java: replace StringEscapeUtils.escapeHtml() with Encode.forHtml(); replace UtilMethods.encodeURL() with Encode.forUriComponent(); add new context-specific helpers: encodeForHTML, encodeForHTMLAttribute, encodeForJavaScript, encodeForCSS - VelocityRequestWrapper.java: replace htmlifyString() with Xss.encodeForHTML() in getParameter() for standards-compliant output encoding - XssWebAPI.java: expose all OWASP encoder contexts to Velocity templates via $xsstool — encodeForHTML, encodeForHTMLAttribute, encodeForJavaScript, encodeForURL, encodeForCSS; legacy strip/escape methods kept and deprecated Co-Authored-By: Claude Sonnet 4.6 --- bom/application/pom.xml | 6 + dotCMS/pom.xml | 5 + .../viewtools/VelocityRequestWrapper.java | 2 +- .../velocity/viewtools/XssWebAPI.java | 195 +++++++--- .../src/main/java/com/liferay/util/Xss.java | 333 +++++++++++------- 5 files changed, 351 insertions(+), 190 deletions(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 56a601e71844..b086a04be298 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1145,6 +1145,12 @@ + + org.owasp.encoder + encoder + 1.3.1 + + io.jsonwebtoken jjwt diff --git a/dotCMS/pom.xml b/dotCMS/pom.xml index 7d9c83ea2faa..2ed32e6d873d 100644 --- a/dotCMS/pom.xml +++ b/dotCMS/pom.xml @@ -991,6 +991,11 @@ + + org.owasp.encoder + encoder + + io.jsonwebtoken jjwt diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java index 3dfbbe51371b..abb26c65fe2d 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java @@ -83,7 +83,7 @@ public HttpSession getSession(final boolean forceCreation) { public String getParameter(final String param) { String ret = super.getParameter(param); if (UtilMethods.isSet(ret) && Xss.URLHasXSS(ret)) { - ret = UtilMethods.htmlifyString(ret); + ret = Xss.encodeForHTML(ret); } return ret; } diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java index cc3821557ac9..fe3c35500533 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java @@ -1,67 +1,150 @@ package com.dotcms.rendering.velocity.viewtools; +import com.liferay.util.Xss; import org.apache.velocity.tools.view.tools.ViewTool; -import com.liferay.util.Xss; +/** + * Velocity view tool ({@code $xsstool}) that exposes context-aware output encoding via the + * OWASP Java Encoder library. + * + *

Use the appropriate method for the output context to prevent XSS: + *

    + *
  • {@link #encodeForHTML(String)} — inside HTML element content
  • + *
  • {@link #encodeForHTMLAttribute(String)} — inside a quoted HTML attribute value
  • + *
  • {@link #encodeForJavaScript(String)} — inside a JavaScript string literal
  • + *
  • {@link #encodeForURL(String)} — inside a URI component (query param, path segment)
  • + *
  • {@link #encodeForCSS(String)} — inside a CSS string or identifier
  • + *
+ * + *

Example usage in a Velocity template: + *

+ *   <p>$xsstool.encodeForHTML($request.getParameter("name"))</p>
+ *   <a href="/search?q=$xsstool.encodeForURL($request.getParameter("q"))">Search</a>
+ *   <script>var msg = "$xsstool.encodeForJavaScript($message)";</script>
+ * 
+ * + *

Registered in {@code toolbox.xml} under the key {@code xsstool}. + * + * @see Xss + */ +public class XssWebAPI implements ViewTool { + + @Override + public void init(Object obj) { + } + + /** + * Encodes the given value for safe inclusion in HTML body content. + * Replaces characters such as {@code <}, {@code >}, {@code &}, {@code "}, and {@code '} + * with their HTML entity equivalents. + * + * @param value the raw value to encode + * @return the HTML-encoded value, or an empty string if {@code value} is null + */ + public String encodeForHTML(final String value) { + return Xss.encodeForHTML(value); + } + + /** + * Encodes the given value for safe inclusion inside a quoted HTML attribute value. + * + * @param value the raw value to encode + * @return the HTML-attribute-encoded value, or an empty string if {@code value} is null + */ + public String encodeForHTMLAttribute(final String value) { + return Xss.encodeForHTMLAttribute(value); + } + + /** + * Encodes the given value for safe embedding inside a JavaScript string literal. + * Use this when rendering user data inside {@code ")); + } + + @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(">")); + } + + @Test + public void encodeForURL_returnsEmptyStringForNull() { + assertEquals("", Xss.encodeForURL(null)); + } + + @Test + public void encodeForURL_preservesUnreservedCharacters() { + final String safe = "hello-world_123~"; + assertEquals("Unreserved URI chars must not be encoded", safe, Xss.encodeForURL(safe)); + } + + // ------------------------------------------------------------------------- + // encodeForCSS + // ------------------------------------------------------------------------- + + @Test + public void encodeForCSS_encodesQuotesAndParens() { + // Inside a CSS string literal, single/double quotes and parens are breakout vectors + 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("'")); + } + + @Test + public void encodeForCSS_returnsEmptyStringForNull() { + assertEquals("", Xss.encodeForCSS(null)); + } + + // ------------------------------------------------------------------------- + // escapeHTMLAttrib (legacy — delegates to encodeForHTML) + // ------------------------------------------------------------------------- + + @Test + public void escapeHTMLAttrib_encodesHtmlEntities() { + assertEquals("<b>bold</b>", Xss.escapeHTMLAttrib("bold")); + } + + @Test + public void escapeHTMLAttrib_returnsEmptyStringForNull() { + assertEquals("", Xss.escapeHTMLAttrib(null)); + } + + // ------------------------------------------------------------------------- + // unEscapeHTMLAttrib + // ------------------------------------------------------------------------- + + @Test + public void unEscapeHTMLAttrib_decodesHtmlEntities() { + assertEquals("bold", Xss.unEscapeHTMLAttrib("<b>bold</b>")); + } + + @Test + public void unEscapeHTMLAttrib_returnsEmptyStringForNull() { + assertEquals("", Xss.unEscapeHTMLAttrib(null)); + } + + // ------------------------------------------------------------------------- + // URLHasXSS / URIHasXSS + // ------------------------------------------------------------------------- + + @Test + public void URLHasXSS_detectsScriptTag() { + assertTrue(Xss.URLHasXSS("")); + } + + @Test + public void URLHasXSS_returnsFalseForCleanInput() { + assertFalse(Xss.URLHasXSS("hello world")); + } + + @Test + public void URLHasXSS_returnsFalseForNull() { + assertFalse(Xss.URLHasXSS(null)); + } + +} From 45797c112c6448476a639840a52bb0dadab17a0a Mon Sep 17 00:00:00 2001 From: mbiuki Date: Tue, 14 Apr 2026 17:55:36 -0400 Subject: [PATCH 3/7] feat: add OwaspEncoderTool viewtool and config flag for XSS encoding - Add $encode Velocity viewtool (OwaspEncoderTool) exposing full OWASP Java Encoder API: forHtml, forHtmlContent, forHtmlAttribute, forHtmlUnquotedAttribute, forCssString, forCssUrl, forUriComponent, forJavaScript, forJavaScriptAttribute, forJavaScriptBlock, forJavaScriptSource, forXml*, forCDATA, plus URL safety helpers (validateUrl, urlHasXSS, cleanUrl). Registered as $encode in toolbox.xml. - Wrap VelocityRequestWrapper XSS encoding in USE_OWASP_ENCODING_FOR_XSS_PARAMS config flag (default true) so it can be reverted to legacy htmlifyString if needed. Closes #24120 Co-Authored-By: Claude Sonnet 4.6 --- .../velocity/viewtools/OwaspEncoderTool.java | 307 ++++++++++++++++++ .../viewtools/VelocityRequestWrapper.java | 4 +- dotCMS/src/main/webapp/WEB-INF/toolbox.xml | 5 + 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderTool.java 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 ")); + } + + @Test + public void forHtml_encodesAmpersand() { + assertTrue(tool.forHtml("a & b").contains("&")); + } + + @Test + public void forHtml_returnsEmptyStringForNull() { + assertEquals("", tool.forHtml(null)); + } + + @Test + public void forHtml_passesThroughPlainText() { + assertEquals("Hello World", tool.forHtml("Hello World")); + } + + // ------------------------------------------------------------------------- + // forHtmlContent + // ------------------------------------------------------------------------- + + @Test + public void forHtmlContent_encodesAngleBrackets() { + final String result = tool.forHtmlContent("bold"); + assertFalse("Raw angle bracket must not appear", result.contains("")); + } + + @Test + public void forHtmlContent_returnsEmptyStringForNull() { + assertEquals("", tool.forHtmlContent(null)); + } + + // ------------------------------------------------------------------------- + // forHtmlAttribute + // ------------------------------------------------------------------------- + + @Test + public void forHtmlAttribute_encodesDoubleQuote() { + final String result = tool.forHtmlAttribute("\" onmouseover=\"alert(1)"); + assertFalse("Unencoded double-quote must not appear", result.contains("\"")); + } + + @Test + public void forHtmlAttribute_encodesSingleQuote() { + final String result = tool.forHtmlAttribute("' onmouseover='alert(1)"); + assertFalse("Unencoded single-quote must not appear", result.contains("'")); + } + + @Test + public void forHtmlAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forHtmlAttribute(null)); + } + + // ------------------------------------------------------------------------- + // forHtmlUnquotedAttribute + // ------------------------------------------------------------------------- + + @Test + public void forHtmlUnquotedAttribute_encodesSpaceAndQuotes() { + final String result = tool.forHtmlUnquotedAttribute("value with spaces"); + assertFalse("Space must be encoded for unquoted attribute", result.contains(" ")); + } + + @Test + public void forHtmlUnquotedAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forHtmlUnquotedAttribute(null)); + } + + // ------------------------------------------------------------------------- + // forCssString + // ------------------------------------------------------------------------- + + @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("'")); + } + + @Test + public void forCssString_returnsEmptyStringForNull() { + assertEquals("", tool.forCssString(null)); + } + + // ------------------------------------------------------------------------- + // forCssUrl + // ------------------------------------------------------------------------- + + @Test + public void forCssUrl_encodesQuotes() { + final String result = tool.forCssUrl("'malicious'"); + assertFalse("Single quote must be encoded in CSS URL context", result.contains("'")); + } + + @Test + public void forCssUrl_returnsEmptyStringForNull() { + assertEquals("", tool.forCssUrl(null)); + } + + // ------------------------------------------------------------------------- + // forUriComponent + // ------------------------------------------------------------------------- + + @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("&")); + } + + @Test + public void forUriComponent_encodesAngleBrackets() { + final String result = tool.forUriComponent(""); + assertFalse("Angle bracket must be percent-encoded", result.contains("<")); + } + + @Test + public void forUriComponent_preservesUnreservedChars() { + final String safe = "hello-world_123~"; + assertEquals("Unreserved URI chars must not be encoded", safe, tool.forUriComponent(safe)); + } + + @Test + public void forUriComponent_returnsEmptyStringForNull() { + assertEquals("", tool.forUriComponent(null)); + } + + // ------------------------------------------------------------------------- + // forJavaScript + // ------------------------------------------------------------------------- + + @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("'")); + } + + @Test + public void forJavaScript_encodesBackslash() { + final String result = tool.forJavaScript("back\\slash"); + assertTrue("Backslash must be doubled in JS output", result.contains("\\\\")); + } + + @Test + public void forJavaScript_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScript(null)); + } + + // ------------------------------------------------------------------------- + // forJavaScriptAttribute / forJavaScriptBlock / forJavaScriptSource + // ------------------------------------------------------------------------- + + @Test + public void forJavaScriptAttribute_encodesSingleQuote() { + assertFalse(tool.forJavaScriptAttribute("'").contains("'")); + } + + @Test + public void forJavaScriptAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScriptAttribute(null)); + } + + @Test + public void forJavaScriptBlock_encodesScriptCloseTag() { + // Inside a , not single quotes. + // OWASP encodes the '<' to prevent the HTML parser closing the script early. + final String result = tool.forJavaScriptBlock(""); + assertFalse(" breakout must be prevented in script-block context", + result.contains("")); + } + + @Test + public void forJavaScriptBlock_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScriptBlock(null)); + } + + @Test + public void forJavaScriptSource_encodesBackslash() { + // In a standalone .js file, backslash is the relevant encoding target. + final String result = tool.forJavaScriptSource("back\\slash"); + assertTrue("Backslash must be doubled in JS source output", result.contains("\\\\")); + } + + @Test + public void forJavaScriptSource_returnsEmptyStringForNull() { + assertEquals("", tool.forJavaScriptSource(null)); + } + + // ------------------------------------------------------------------------- + // forXml family + // ------------------------------------------------------------------------- + + @Test + public void forXml_encodesAngleBrackets() { + final String result = tool.forXml(""); + assertFalse("Angle bracket must be encoded", result.contains("")); + assertTrue(result.contains("<")); + } + + @Test + public void forXml_returnsEmptyStringForNull() { + assertEquals("", tool.forXml(null)); + } + + @Test + public void forXmlContent_returnsEmptyStringForNull() { + assertEquals("", tool.forXmlContent(null)); + } + + @Test + public void forXmlAttribute_returnsEmptyStringForNull() { + assertEquals("", tool.forXmlAttribute(null)); + } + + @Test + public void forXmlComment_returnsEmptyStringForNull() { + assertEquals("", tool.forXmlComment(null)); + } + + @Test + public void forCDATA_returnsEmptyStringForNull() { + assertEquals("", tool.forCDATA(null)); + } + + // ------------------------------------------------------------------------- + // forJava + // ------------------------------------------------------------------------- + + @Test + public void forJava_encodesBackslash() { + final String result = tool.forJava("back\\slash"); + assertTrue("Backslash must be doubled in Java string output", result.contains("\\\\")); + } + + @Test + public void forJava_returnsEmptyStringForNull() { + assertEquals("", tool.forJava(null)); + } + + // ------------------------------------------------------------------------- + // URL safety helpers — validateUrl + // ------------------------------------------------------------------------- + + @Test + public void validateUrl_acceptsValidHttpsUrl() { + assertTrue(tool.validateUrl("https://www.dotcms.com/page?q=1")); + } + + @Test + public void validateUrl_rejectsJavascriptScheme() { + assertFalse(tool.validateUrl("javascript:alert(1)")); + } + + @Test + public void validateUrl_rejectsNull() { + assertFalse(tool.validateUrl(null)); + } + + @Test + public void validateUrl_rejectsMalformed() { + assertFalse(tool.validateUrl("not a url")); + } + + // ------------------------------------------------------------------------- + // URL safety helpers — urlHasXSS + // ------------------------------------------------------------------------- + + @Test + public void urlHasXSS_returnsFalseForCleanUrl() { + assertFalse(tool.urlHasXSS("https://www.dotcms.com/page?name=hello")); + } + + @Test + public void urlHasXSS_returnsTrueWhenParamContainsHtmlTags() { + assertTrue(tool.urlHasXSS("https://www.dotcms.com/page?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E")); + } + + @Test + public void urlHasXSS_returnsFalseForInvalidUrl() { + assertFalse(tool.urlHasXSS("not a url")); + } + + @Test + public void urlHasXSS_returnsFalseForNull() { + assertFalse(tool.urlHasXSS(null)); + } + + // ------------------------------------------------------------------------- + // URL safety helpers — cleanUrl + // ------------------------------------------------------------------------- + + @Test + public void cleanUrl_returnsEncodedUrlForValidInput() { + final String result = tool.cleanUrl("https://www.dotcms.com/page"); + assertEquals("https://www.dotcms.com/page", result); + } + + @Test + public void cleanUrl_returnsNullForInvalidUrl() { + assertNull(tool.cleanUrl("javascript:alert(1)")); + } + + @Test + public void cleanUrl_returnsNullForNull() { + assertNull(tool.cleanUrl(null)); + } +} From a0a8f7be534f454e03973fe6533748fc1ed96382 Mon Sep 17 00:00:00 2001 From: mbiuki Date: Wed, 15 Apr 2026 16:11:55 -0400 Subject: [PATCH 5/7] fix: return proper HTTP 400/404 for invalid id in rules include endpoint Fixes #34888 Previously /api/portlet/rules/include responded with a JasperException (HTTP 200 with an error page body) when the id parameter was missing, invalid, or did not match an existing contentlet. Changes: - include.jsp: validate id before calling the API; throw WebApplicationException(400) for missing/empty or format-invalid id, and WebApplicationException(404) when no contentlet is found. - BaseRestPortlet.getJspResponse(): re-throw WebApplicationException instead of swallowing it in the generic catch block, so the correct HTTP status propagates back to the caller. - include.jsp: encode id and hideRulePushOptions with Xss.encodeForJavaScript() before embedding them in the script block. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/dotcms/rest/BaseRestPortlet.java | 3 ++ .../main/webapp/WEB-INF/jsp/rules/include.jsp | 40 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/BaseRestPortlet.java b/dotCMS/src/main/java/com/dotcms/rest/BaseRestPortlet.java index 5ea9b9ffb74a..d6cd4ff7ea88 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/BaseRestPortlet.java +++ b/dotCMS/src/main/java/com/dotcms/rest/BaseRestPortlet.java @@ -11,6 +11,7 @@ import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.CacheControl; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; @@ -117,6 +118,8 @@ private String getJspResponse ( HttpServletRequest request, HttpServletResponse String responseString = ((ResponseWrapper) responseWrapper).getResponseString(); return responseString; + } catch (WebApplicationException e) { + throw e; } catch (Exception e) { Logger.debug(this.getClass(), "unable to parse: " + path); Logger.error( this.getClass(), e.toString(), e ); diff --git a/dotCMS/src/main/webapp/WEB-INF/jsp/rules/include.jsp b/dotCMS/src/main/webapp/WEB-INF/jsp/rules/include.jsp index e82e0ba416db..26406eab7985 100644 --- a/dotCMS/src/main/webapp/WEB-INF/jsp/rules/include.jsp +++ b/dotCMS/src/main/webapp/WEB-INF/jsp/rules/include.jsp @@ -3,9 +3,41 @@ <%@page import="com.dotcms.repackage.org.apache.struts.Globals"%> <%@ page import="com.dotmarketing.util.PortletURLUtil" %> <%@page import="com.dotmarketing.portlets.contentlet.model.Contentlet"%> +<%@page import="com.dotmarketing.util.UtilMethods"%> +<%@page import="com.liferay.util.Xss"%> +<%@page import="javax.ws.rs.WebApplicationException"%> +<%@page import="javax.ws.rs.core.Response"%> <%@ include file="/html/common/init.jsp" %> -<% Contentlet contentlet = (Contentlet) APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(request.getParameter("id")); %> +<% + final String id = request.getParameter("id"); + if (!UtilMethods.isSet(id)) { + throw new WebApplicationException( + Response.status(Response.Status.BAD_REQUEST) + .entity("Missing or empty required parameter: id") + .build() + ); + } + + Contentlet contentlet; + try { + contentlet = APILocator.getContentletAPI().findContentletByIdentifierAnyLanguage(id); + } catch (final Exception e) { + throw new WebApplicationException( + Response.status(Response.Status.BAD_REQUEST) + .entity("Invalid id parameter: " + Xss.encodeForHTML(id)) + .build() + ); + } + + if (contentlet == null || !UtilMethods.isSet(contentlet.getIdentifier())) { + throw new WebApplicationException( + Response.status(Response.Status.NOT_FOUND) + .entity("No content found for id: " + Xss.encodeForHTML(id)) + .build() + ); + } +%> @@ -26,9 +58,9 @@ localeParam = "locale=" + langIsoCode; } - var siteParam="realmId=<%=request.getParameter("id")%>"; - var hideFireOnParam = "hideFireOn=true"; - var hideRulePushOptions = "hideRulePushOptions=<%=request.getParameter("hideRulePushOptions")%>"; + var siteParam="realmId=<%=Xss.encodeForJavaScript(id)%>"; + var hideFireOnParam = "hideFireOn=true"; + var hideRulePushOptions = "hideRulePushOptions=<%=Xss.encodeForJavaScript(request.getParameter("hideRulePushOptions"))%>"; var isContentletHost = "isContentletHost=<%=contentlet.isHost()%>" //Add param to the rules engine iframe. From c2fd62bc0c99e50de33315fb43cb465a3fdef1e0 Mon Sep 17 00:00:00 2001 From: mbiuki Date: Wed, 15 Apr 2026 16:19:43 -0400 Subject: [PATCH 6/7] test: add unit tests for BaseRestPortlet WebApplicationException propagation 4 tests verifying: - WebApplicationException(400) from a JSP dispatch propagates out of getJspResponse() with its HTTP status intact. - WebApplicationException(404) same. - Ordinary IOException is caught and converted to error-HTML (existing behaviour preserved). - Ordinary RuntimeException same. Co-Authored-By: Claude Sonnet 4.6 --- .../com/dotcms/rest/BaseRestPortletTest.java | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 dotCMS/src/test/java/com/dotcms/rest/BaseRestPortletTest.java diff --git a/dotCMS/src/test/java/com/dotcms/rest/BaseRestPortletTest.java b/dotCMS/src/test/java/com/dotcms/rest/BaseRestPortletTest.java new file mode 100644 index 000000000000..2102cf40793e --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/BaseRestPortletTest.java @@ -0,0 +1,138 @@ +package com.dotcms.rest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.UnitTestBase; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import javax.servlet.RequestDispatcher; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +/** + * Unit tests for {@link BaseRestPortlet}. + * + *

Verifies that {@code WebApplicationException} thrown by a JSP (via the + * {@code RequestDispatcher}) propagates out of {@code getJspResponse()} with its + * HTTP status intact, while ordinary exceptions are caught and converted to an + * error-HTML string.

+ */ +@RunWith(MockitoJUnitRunner.class) +public class BaseRestPortletTest extends UnitTestBase { + + /** Minimal concrete subclass — BaseRestPortlet is abstract. */ + private static class TestPortlet extends BaseRestPortlet { + } + + private TestPortlet portlet; + private Method getJspResponse; + private HttpServletRequest mockRequest; + private HttpServletResponse mockResponse; + private RequestDispatcher mockDispatcher; + + @Before + public void setUp() throws Exception { + portlet = new TestPortlet(); + + // Expose the private getJspResponse method for white-box testing + getJspResponse = BaseRestPortlet.class.getDeclaredMethod( + "getJspResponse", + HttpServletRequest.class, + HttpServletResponse.class, + String.class, + String.class); + getJspResponse.setAccessible(true); + + mockRequest = mock(HttpServletRequest.class); + mockResponse = mock(HttpServletResponse.class); + mockDispatcher = mock(RequestDispatcher.class); + + when(mockRequest.getRequestDispatcher(anyString())).thenReturn(mockDispatcher); + } + + // ------------------------------------------------------------------------- + // WebApplicationException propagation + // ------------------------------------------------------------------------- + + @Test + public void getJspResponse_propagates400WhenJspThrowsWebApplicationException() + throws Exception { + final WebApplicationException cause = new WebApplicationException( + Response.status(Response.Status.BAD_REQUEST).entity("bad id").build()); + doThrow(cause).when(mockDispatcher).include(any(), any()); + + try { + getJspResponse.invoke(portlet, mockRequest, mockResponse, "rules", "include"); + fail("Expected WebApplicationException to propagate"); + } catch (final InvocationTargetException e) { + assertTrue("Cause must be WebApplicationException", + e.getCause() instanceof WebApplicationException); + assertEquals("HTTP status must be 400", + Response.Status.BAD_REQUEST.getStatusCode(), + ((WebApplicationException) e.getCause()).getResponse().getStatus()); + } + } + + @Test + public void getJspResponse_propagates404WhenJspThrowsWebApplicationException() + throws Exception { + final WebApplicationException cause = new WebApplicationException( + Response.status(Response.Status.NOT_FOUND).entity("not found").build()); + doThrow(cause).when(mockDispatcher).include(any(), any()); + + try { + getJspResponse.invoke(portlet, mockRequest, mockResponse, "rules", "include"); + fail("Expected WebApplicationException to propagate"); + } catch (final InvocationTargetException e) { + assertTrue("Cause must be WebApplicationException", + e.getCause() instanceof WebApplicationException); + assertEquals("HTTP status must be 404", + Response.Status.NOT_FOUND.getStatusCode(), + ((WebApplicationException) e.getCause()).getResponse().getStatus()); + } + } + + // ------------------------------------------------------------------------- + // Ordinary exceptions → error HTML (existing behaviour preserved) + // ------------------------------------------------------------------------- + + @Test + public void getJspResponse_returnsErrorHtmlForGenericException() throws Exception { + doThrow(new IOException("disk full")).when(mockDispatcher).include(any(), any()); + + final Object result = getJspResponse.invoke( + portlet, mockRequest, mockResponse, "rules", "include"); + + assertTrue("Result must be a String", result instanceof String); + final String html = (String) result; + assertTrue("Error HTML must mention the JSP path", html.contains("rules")); + assertTrue("Error HTML must include the exception message", html.contains("disk full")); + } + + @Test + public void getJspResponse_returnsErrorHtmlForRuntimeException() throws Exception { + doThrow(new IllegalArgumentException("bad uuid")) + .when(mockDispatcher).include(any(), any()); + + final Object result = getJspResponse.invoke( + portlet, mockRequest, mockResponse, "rules", "include"); + + assertTrue("Result must be a String", result instanceof String); + assertTrue("Error HTML must include the exception message", + ((String) result).contains("bad uuid")); + } +} From d7416c4ae3aa1a5838a9313dde828b79875466c5 Mon Sep 17 00:00:00 2001 From: mbiuki Date: Thu, 16 Apr 2026 17:09:02 -0400 Subject: [PATCH 7/7] refactor: address review comments on PR #35337 - Rename Xss detection methods to camelCase (paramsHaveXSS, uriHasXSS, urlHasXSS) per Java naming conventions; keep PascalCase variants as @Deprecated delegates for backwards compatibility - Update all callers (VelocityRequestWrapper, XssWebAPI, CMSUrlUtil) to use the new camelCase names - Add Given/When/Then Javadoc to every test in OwaspEncoderToolTest - Move XssTest from com.liferay.util to com.dotcms.util (liferay package is outdated; new code should live in dotcms namespace) Tests: 73 passing (XssTest: 22, OwaspEncoderToolTest: 47, BaseRestPortletTest: 4) Co-Authored-By: Claude Sonnet 4.6 --- .../viewtools/VelocityRequestWrapper.java | 2 +- .../velocity/viewtools/XssWebAPI.java | 2 +- .../com/dotmarketing/filters/CMSUrlUtil.java | 4 +- .../src/main/java/com/liferay/util/Xss.java | 64 +++-- .../viewtools/OwaspEncoderToolTest.java | 220 +++++++++++++++++- .../com/{liferay => dotcms}/util/XssTest.java | 136 +++++++++-- 6 files changed, 388 insertions(+), 40 deletions(-) rename dotCMS/src/test/java/com/{liferay => dotcms}/util/XssTest.java (60%) diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java index 9755c41cae62..856227ede8c4 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/VelocityRequestWrapper.java @@ -82,7 +82,7 @@ public HttpSession getSession(final boolean forceCreation) { @Override public String getParameter(final String param) { String ret = super.getParameter(param); - if (UtilMethods.isSet(ret) && Xss.URLHasXSS(ret)) { + if (UtilMethods.isSet(ret) && Xss.urlHasXSS(ret)) { ret = Config.getBooleanProperty("USE_OWASP_ENCODING_FOR_XSS_PARAMS", true) ? Xss.encodeForHTML(ret) : UtilMethods.htmlifyString(ret); diff --git a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java index fe3c35500533..73b51a2fd499 100644 --- a/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java +++ b/dotCMS/src/main/java/com/dotcms/rendering/velocity/viewtools/XssWebAPI.java @@ -144,7 +144,7 @@ public String unEscape(final String value) { * @return {@code true} if XSS patterns are detected */ public boolean hasXss(final String value) { - return Xss.URLHasXSS(value); + return Xss.urlHasXSS(value); } } diff --git a/dotCMS/src/main/java/com/dotmarketing/filters/CMSUrlUtil.java b/dotCMS/src/main/java/com/dotmarketing/filters/CMSUrlUtil.java index fd857e0aa5ef..ac528c3cc339 100644 --- a/dotCMS/src/main/java/com/dotmarketing/filters/CMSUrlUtil.java +++ b/dotCMS/src/main/java/com/dotmarketing/filters/CMSUrlUtil.java @@ -477,7 +477,7 @@ public boolean amISomething(String uri, Host host, Long languageId) { String xssCheck(String uri, String queryString) throws ServletException { String rewrite = null; - if (Xss.URIHasXSS(uri)) { + if (Xss.uriHasXSS(uri)) { Logger.warn(this, "XSS Found in request URI: " + uri); try { rewrite = Xss.encodeForURL(uri); @@ -486,7 +486,7 @@ String xssCheck(String uri, String queryString) throws ServletException { throw new ServletException(e.getMessage(), e); } } else if (queryString != null && null != UtilMethods.decodeURL(queryString)) { - if (Xss.ParamsHaveXSS(queryString)) { + if (Xss.paramsHaveXSS(queryString)) { Logger.warn(this, "XSS Found in Query String: " + queryString); rewrite = uri; } diff --git a/dotCMS/src/main/java/com/liferay/util/Xss.java b/dotCMS/src/main/java/com/liferay/util/Xss.java index a77520515380..84b5693af521 100644 --- a/dotCMS/src/main/java/com/liferay/util/Xss.java +++ b/dotCMS/src/main/java/com/liferay/util/Xss.java @@ -64,33 +64,32 @@ public static String strip ( String text ) { } /** - * Checks into the request query string for possible XSS hacks and return true if any possible XSS fragment is found. + * Checks the request query string for possible XSS hacks. * - * @param request - * @return true if any possible XSS fragment is found + * @param request the incoming HTTP request + * @return {@code true} if any possible XSS fragment is found in the query string */ - public static boolean ParamsHaveXSS ( HttpServletRequest request ) { - return ParamsHaveXSS( request.getQueryString() ); + public static boolean paramsHaveXSS ( final HttpServletRequest request ) { + return paramsHaveXSS( request.getQueryString() ); } /** - * Checks into a given query string for possible XSS hacks and return true if any possible XSS fragment is found. + * Checks the given query string for possible XSS hacks. * - * @param queryString - * @return true if any possible XSS fragment is found + * @param queryString the raw query string to inspect + * @return {@code true} if any possible XSS fragment is found */ - public static boolean ParamsHaveXSS ( String queryString ) { - queryString = UtilMethods.decodeURL( queryString ); - return RegEX.contains( queryString, XSS_REGEXP_PATTERN ); + public static boolean paramsHaveXSS ( final String queryString ) { + return RegEX.contains( UtilMethods.decodeURL( queryString ), XSS_REGEXP_PATTERN ); } /** - * Checks in the given uri for possible XSS hacks and return true if any possible XSS fragment is found. + * Checks the given URI for possible XSS hacks. * - * @param uri - * @return true if any possible XSS fragment is found + * @param uri the URI to inspect + * @return {@code true} if any possible XSS fragment is found */ - public static boolean URIHasXSS ( String uri ) { + public static boolean uriHasXSS ( final String uri ) { if ( uri == null ) { return false; } @@ -98,19 +97,44 @@ public static boolean URIHasXSS ( String uri ) { } /** - * Checks in the given url for possible XSS hacks and return true if any possible XSS fragment is found. + * Checks the given URL for possible XSS hacks. * - * @param url - * @return true if any possible XSS fragment is found - * @deprecated Use {@link #URIHasXSS(String)} and {@link #ParamsHaveXSS(String)} individually. + * @param url the URL to inspect + * @return {@code true} if any possible XSS fragment is found + * @deprecated Use {@link #uriHasXSS(String)} and {@link #paramsHaveXSS(String)} individually. */ - public static boolean URLHasXSS ( String url ) { + @Deprecated + public static boolean urlHasXSS ( final String url ) { if ( url == null ) { return false; } return RegEX.contains( url, XSS_REGEXP_PATTERN ); } + /** @deprecated Use {@link #paramsHaveXSS(HttpServletRequest)} */ + @Deprecated + public static boolean ParamsHaveXSS ( final HttpServletRequest request ) { + return paramsHaveXSS( request ); + } + + /** @deprecated Use {@link #paramsHaveXSS(String)} */ + @Deprecated + public static boolean ParamsHaveXSS ( final String queryString ) { + return paramsHaveXSS( queryString ); + } + + /** @deprecated Use {@link #uriHasXSS(String)} */ + @Deprecated + public static boolean URIHasXSS ( final String uri ) { + return uriHasXSS( uri ); + } + + /** @deprecated Use {@link #urlHasXSS(String)} */ + @Deprecated + public static boolean URLHasXSS ( final String url ) { + return urlHasXSS( url ); + } + /** * Encodes a value for safe use in a URI component (query parameter value, path segment, etc.) * using the OWASP Java Encoder. diff --git a/dotCMS/src/test/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderToolTest.java b/dotCMS/src/test/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderToolTest.java index 7d5c4b727231..2e66692f5a9b 100644 --- a/dotCMS/src/test/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderToolTest.java +++ b/dotCMS/src/test/java/com/dotcms/rendering/velocity/viewtools/OwaspEncoderToolTest.java @@ -29,22 +29,42 @@ public void setUp() { // forHtml // ------------------------------------------------------------------------- + /** + * Given an input containing a script tag, + * When encoded for HTML body content, + * Then angle brackets are replaced with HTML entities so the tag cannot execute. + */ @Test public void forHtml_encodesScriptTag() { assertEquals("<script>alert(1)</script>", tool.forHtml("")); } + /** + * 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")); @@ -54,12 +74,22 @@ public void forHtml_passesThroughPlainText() { // 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)); @@ -69,18 +99,33 @@ public void forHtmlContent_returnsEmptyStringForNull() { // 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)); @@ -90,12 +135,22 @@ public void forHtmlAttribute_returnsEmptyStringForNull() { // 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)); @@ -105,6 +160,11 @@ public void forHtmlUnquotedAttribute_returnsEmptyStringForNull() { // 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: '"); @@ -112,6 +172,11 @@ public void forCssString_encodesSingleQuote() { 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)); @@ -121,12 +186,22 @@ public void forCssString_returnsEmptyStringForNull() { // 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)); @@ -136,6 +211,11 @@ public void forCssUrl_returnsEmptyStringForNull() { // 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"); @@ -143,18 +223,33 @@ public void forUriComponent_encodesSpaceAndSpecialChars() { 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)); @@ -164,6 +259,11 @@ public void forUriComponent_returnsEmptyStringForNull() { // 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='"); @@ -171,12 +271,22 @@ public void forJavaScript_encodesSingleQuote() { 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)); @@ -186,37 +296,66 @@ public void forJavaScript_returnsEmptyStringForNull() { // 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 , not single quotes. - // OWASP encodes the '<' to prevent the HTML parser closing the script early. final String result = tool.forJavaScriptBlock(""); 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() { - // In a standalone .js file, backslash is the relevant encoding target. 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)); @@ -226,6 +365,11 @@ public void forJavaScriptSource_returnsEmptyStringForNull() { // 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(""); @@ -233,26 +377,31 @@ public void forXml_encodesAngleBrackets() { 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)); @@ -262,12 +411,22 @@ public void forCDATA_returnsEmptyStringForNull() { // 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)); @@ -277,21 +436,41 @@ public void forJava_returnsEmptyStringForNull() { // 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")); @@ -301,21 +480,41 @@ public void validateUrl_rejectsMalformed() { // 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

"); @@ -35,11 +47,21 @@ public void encodeForHTML_encodesAmpersandAndQuotes() { assertTrue("Ampersand must be encoded as &", result.contains("&")); } + /** + * Given a null input, + * When encoded for HTML body content, + * Then an empty string is returned instead of throwing. + */ @Test public void encodeForHTML_returnsEmptyStringForNull() { assertEquals("", Xss.encodeForHTML(null)); } + /** + * Given plain text with no special characters, + * When encoded for HTML body content, + * Then the value is returned unchanged. + */ @Test public void encodeForHTML_passesThroughPlainText() { assertEquals("Hello World", Xss.encodeForHTML("Hello World")); @@ -49,6 +71,11 @@ public void encodeForHTML_passesThroughPlainText() { // encodeForHTMLAttribute // ------------------------------------------------------------------------- + /** + * Given an input containing a double-quote event-handler breakout payload, + * When encoded for a quoted HTML attribute value, + * Then the double-quote is encoded. + */ @Test public void encodeForHTMLAttribute_encodesDoubleQuote() { final String result = Xss.encodeForHTMLAttribute("\" onmouseover=\"alert(1)"); @@ -56,6 +83,11 @@ public void encodeForHTMLAttribute_encodesDoubleQuote() { result.contains("\"")); } + /** + * Given a null input, + * When encoded for an HTML attribute, + * Then an empty string is returned. + */ @Test public void encodeForHTMLAttribute_returnsEmptyStringForNull() { assertEquals("", Xss.encodeForHTMLAttribute(null)); @@ -65,6 +97,11 @@ public void encodeForHTMLAttribute_returnsEmptyStringForNull() { // encodeForJavaScript // ------------------------------------------------------------------------- + /** + * Given an input containing a single-quote JS string breakout payload, + * When encoded for a JavaScript string literal, + * Then the single-quote is encoded. + */ @Test public void encodeForJavaScript_encodesScriptBreakout() { final String input = "'; alert(1); var x='"; @@ -73,17 +110,26 @@ public void encodeForJavaScript_encodesScriptBreakout() { result.contains("'")); } + /** + * Given an input containing a backslash, + * When encoded for a JavaScript string literal, + * Then the backslash is doubled and the surrounding word content is preserved. + */ @Test public void encodeForJavaScript_encodesBackslash() { // OWASP encodes \ as \\ in JS string context final String result = Xss.encodeForJavaScript("back\\slash"); - // The single backslash must have been doubled assertTrue("Backslash must be doubled in JS output (\\\\)", result.contains("\\\\")); assertTrue("Result must still contain recognizable word content", result.contains("back") && result.contains("slash")); } + /** + * Given a null input, + * When encoded for JavaScript, + * Then an empty string is returned. + */ @Test public void encodeForJavaScript_returnsEmptyStringForNull() { assertEquals("", Xss.encodeForJavaScript(null)); @@ -93,6 +139,11 @@ public void encodeForJavaScript_returnsEmptyStringForNull() { // encodeForURL // ------------------------------------------------------------------------- + /** + * Given an input containing spaces and ampersands, + * When encoded for a URI component, + * Then both are percent-encoded. + */ @Test public void encodeForURL_encodesSpaceAndSpecialChars() { final String result = Xss.encodeForURL("hello world & more"); @@ -100,6 +151,11 @@ public void encodeForURL_encodesSpaceAndSpecialChars() { assertFalse("Ampersand must be percent-encoded", result.contains("&")); } + /** + * Given an input containing a script tag, + * When encoded for a URI component, + * Then angle brackets are percent-encoded. + */ @Test public void encodeForURL_encodesScriptPayload() { final String result = Xss.encodeForURL(""); @@ -107,11 +163,21 @@ public void encodeForURL_encodesScriptPayload() { 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~"; @@ -122,15 +188,24 @@ public void encodeForURL_preservesUnreservedCharacters() { // 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() { - // Inside a CSS string literal, single/double quotes and parens are breakout vectors 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)); @@ -140,11 +215,21 @@ public void encodeForCSS_returnsEmptyStringForNull() { // 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)); @@ -154,33 +239,58 @@ public void escapeHTMLAttrib_returnsEmptyStringForNull() { // 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 + // 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("")); + 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")); + 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)); + public void urlHasXSS_returnsFalseForNull() { + assertFalse(Xss.urlHasXSS(null)); } }