diff --git a/README.md b/README.md
index 5e5dd4e..801a876 100644
--- a/README.md
+++ b/README.md
@@ -162,7 +162,9 @@ always encodes `%`, so an already percent-encoded URI is double-encoded.
- For an entire untrusted URL, parse it with `java.net.URI`, allow-list its scheme
(for example `http` and `https`, rejecting a missing scheme unless relative URLs are
- intended), and then encode the whole value for the enclosing context:
+ intended), enforce application-specific restrictions on its destination and
+ path, and then encode the whole value for the enclosing context. Parsing alone
+ does not establish safety:
```jsp
@@ -171,6 +173,12 @@ always encodes `%`, so an already percent-encoded URI is double-encoded.
`forUri` is retained for compatibility in all 1.x releases. Whether 2.0 removes it is
tracked in [#142](https://github.com/OWASP/owasp-java-encoder/issues/142).
+The ESAPI adapter's `encodeForURL` changes separately in unreleased 1.5: it now
+uses `forUriComponent`, escaping URL delimiters while retaining `%20` for spaces.
+Pass raw component data, not a complete or already encoded URL. See the
+[adapter migration and context contracts](esapi/README.md#url-encoding-migration-in-15-unreleased)
+for differences from earlier adapter releases and ESAPI's reference form encoder.
+
Development
-----------
@@ -193,6 +201,7 @@ News
### Unreleased - 1.5.0
Development builds use `1.5.0-SNAPSHOT`; this is not a published release.
+* fix: the ESAPI adapter's `encodeForURL` now encodes individual URL components with UTF-8, including reserved delimiters and literal `+`, instead of preserving whole-URI delimiters. Spaces remain `%20`, null remains the string `"null"`, and unpaired surrogates remain `-`. See the [migration guide](esapi/README.md#url-encoding-migration-in-15-unreleased) for output changes, form-encoding differences, and the retained quoted HTML/CSS/JavaScript contracts [#100](https://github.com/OWASP/owasp-java-encoder/issues/100).
* feat: all four `forJavaScript*` methods encode dollar sign (`$`) as `\x24`, backtick as `\x60`, and opening brace (`{`) as `\x7b` [#129](https://github.com/OWASP/owasp-java-encoder/issues/129). Escaping `{` prevents input after a trusted `$` from completing `${...}`. Encoded output now supports literal text in ordinary (untagged) template literals as well as single- and double-quoted strings. This changes the encoded output while preserving its decoded JavaScript string value. Tagged templates (including `String.raw`), `${...}` expression bodies, JSON, and script URLs are unsupported; each method's HTML context restrictions still apply.
* fix: all four `forJavaScript*` methods escape unpaired UTF-16 surrogates as `\uXXXX`, preserving their JavaScript string values through UTF-8 serialization [#135](https://github.com/OWASP/owasp-java-encoder/issues/135), and escape DEL/C1 controls (U+007F to U+009F) as `\xNN` [#163](https://github.com/OWASP/owasp-java-encoder/issues/163). Valid surrogate pairs and other non-ASCII text remain unescaped except U+2028/U+2029. These are output-fidelity changes; NEL was already ordinary JavaScript string data.
* feat: add `Encode.forJson` String/Writer methods, the `json` encoder context, and `forJson` tags and EL functions in both JSP and Jakarta tag libraries [#145](https://github.com/OWASP/owasp-java-encoder/issues/145). The caller supplies double quotes. Output uses RFC 8259 string escapes and also escapes HTML script delimiters. Java `null` becomes the text `null` (the JSON string `"null"` when quoted); unpaired surrogates use Unicode escapes and may not interoperate with every JSON consumer. Prefer a serializer for complete JSON documents. The ESAPI adapter retains its existing JSON delegation and null behavior.
diff --git a/compatibility/src/EsapiConsumer.java b/compatibility/src/EsapiConsumer.java
index d656659..df01981 100644
--- a/compatibility/src/EsapiConsumer.java
+++ b/compatibility/src/EsapiConsumer.java
@@ -39,5 +39,12 @@ public static void main(String[] args) throws Exception {
Checks.origin(org.owasp.encoder.esapi.ESAPIEncoder.class);
org.owasp.esapi.Encoder encoder = org.owasp.encoder.esapi.ESAPIEncoder.getInstance();
Checks.encoded(encoder.encodeForHTML("A&B<"));
+ if (!"a%20b%2Bc%26admin%3Dtrue%2F%23".equals(
+ encoder.encodeForURL("a b+c&admin=true/#"))) {
+ throw new AssertionError("URL component delimiters were not encoded");
+ }
+ if (!"null".equals(encoder.encodeForURL(null))) {
+ throw new AssertionError("URL null contract changed");
+ }
}
}
diff --git a/core/src/main/java/org/owasp/encoder/Encode.java b/core/src/main/java/org/owasp/encoder/Encode.java
index 6d22484..083cbff 100644
--- a/core/src/main/java/org/owasp/encoder/Encode.java
+++ b/core/src/main/java/org/owasp/encoder/Encode.java
@@ -667,9 +667,10 @@ public static void forCssUrl(Writer out, String input)
*
The adapter's {@code encodeForCSS} encodes only quoted CSS strings, and * {@code encodeForJavaScript} encodes single- or double-quoted JavaScript * strings and literal text in ordinary (untagged) template literals, not JSON, - * tagged templates (including {@code String.raw}), or script URLs. It escapes + * tagged templates (including {@code String.raw}), template expression bodies, + * or script URLs. It escapes * DEL/C1 controls and unpaired UTF-16 surrogates while preserving valid pairs. - * Its {@code encodeForURL} delegates to deprecated {@link Encode#forUri(String)}: - * it preserves URI delimiters such as {@code & = / ? #} and therefore is - * not a URL-component or form encoder. For an inserted component, use - * {@link Encode#forUriComponent(String)}. Validate complete URLs and their - * schemes, then encode for the enclosing output context.
+ * Neither method encodes arbitrary unquoted CSS or JavaScript code. + * + *{@code encodeForHTMLAttribute} encodes quoted HTML text attributes using + * {@link Encode#forHtmlAttribute(String)}. It does not make event-handler code + * or an untrusted URL safe. For a URL-valued attribute, validate the complete + * URL against application rules, including allowed schemes, then encode it for + * the enclosing HTML attribute.
+ * + *Starting with 1.5, {@code encodeForURL} encodes one raw URL component using + * {@link Encode#forUriComponent(String)}. It percent-encodes UTF-8 bytes, + * including delimiters such as {@code & = / ? # +}, and uses {@code %20} + * for spaces. This intentionally differs from ESAPI's reference form encoding, + * which uses {@code +} for spaces and a configurable character encoding. The + * adapter retains its historical {@code "null"} result for {@code null} input + * and replaces unpaired UTF-16 surrogates with {@code -}. Already percent-encoded + * input is encoded again. It neither validates a complete URL nor reads ESAPI + * configuration. Assemble the URL from trusted structure and encoded raw + * components, validate it for its intended use, then encode for the enclosing + * output context. For form encoding specifically, use + * {@link java.net.URLEncoder} with an explicit character encoding.
* *The following methods delegate to ESAPI. Most are outside the scope of * contextual output encoding; JSON encoding retains the reference behavior @@ -210,7 +226,11 @@ public String decodeForHTML(String s) { return reference().decodeForHTML(s); } - /** {@inheritDoc} */ + /** + * Encodes a quoted HTML text attribute, not an unquoted attribute, + * event-handler program, or unvalidated URL. See + * {@link Encode#forHtmlAttribute(String)} for the enclosing-context rules. + */ @Override public String encodeForHTMLAttribute(String s) { return Encode.forHtmlAttribute(s); @@ -219,7 +239,8 @@ public String encodeForHTMLAttribute(String s) { /** * Encodes a single- or double-quoted JavaScript string or literal text in an * ordinary (untagged) template literal using {@link Encode#forJavaScript(String)}. - * Not for JSON, tagged templates (including {@code String.raw}), or script URLs. + * Not for JSON, tagged templates (including {@code String.raw}), template + * expression bodies, unquoted code, or script URLs. * DEL/C1 controls and unpaired UTF-16 surrogates are escaped; valid pairs * remain unescaped. */ @@ -283,13 +304,15 @@ public String encodeForXMLAttribute(String s) { } /** - * Encodes a complete URI using deprecated {@link Encode#forUri(String)}. - * Preserves delimiters such as {@code & = / ? #}; not a component - * or form encoder. The caller must validate the URI and its scheme. + * Encodes a raw URL component as UTF-8 with spaces as {@code %20}, using + * {@link Encode#forUriComponent(String)}. Retains the adapter's + * {@code "null"} result for null and {@code -} for unpaired surrogates. + * The checked exception remains in the ESAPI interface contract; + * this implementation does not depend on a configurable charset. */ @Override public String encodeForURL(String s) throws EncodingException { - return Encode.forUri(s); + return Encode.forUriComponent(s); } /** {@inheritDoc} */ diff --git a/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIContextTest.java b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIContextTest.java new file mode 100644 index 0000000..5b5e1eb --- /dev/null +++ b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIContextTest.java @@ -0,0 +1,183 @@ +// Copyright (c) 2026 OWASP +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// * Redistributions of source code must retain the above +// copyright notice, this list of conditions and the following +// disclaimer. +// +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials +// provided with the distribution. +// +// * Neither the name of the OWASP nor the names of its +// contributors may be used to endorse or promote products +// derived from this software without specific prior written +// permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +// COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +// INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +// HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +// OF THE POSSIBILITY OF SUCH DAMAGE. + +package org.owasp.encoder.esapi; + +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import junit.framework.TestCase; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.owasp.esapi.ESAPI; +import org.owasp.esapi.Encoder; +import org.owasp.esapi.errors.EncodingException; +import org.owasp.esapi.reference.DefaultEncoder; + +/** Context and value contracts, also exercised by the stable ESAPI matrix. */ +public class ESAPIContextTest extends TestCase { + private final Encoder encoder = ESAPIEncoder.getInstance(); + + public void testUrlComponentEncodingAndReferenceFormDifferences() throws Exception { + assertEquals("UTF-8", ESAPI.securityConfiguration().getCharacterEncoding()); + Encoder reference = DefaultEncoder.getInstance(); + // Input, adapter component encoding, ESAPI reference form encoding. + String[][] cases = { + {"", "", ""}, + {"abcABC123-._", "abcABC123-._", "abcABC123-._"}, + {"a b+c", "a%20b%2Bc", "a+b%2Bc"}, + {"~*", "~%2A", "%7E*"}, + {"&=/?#", "%26%3D%2F%3F%23", "%26%3D%2F%3F%23"}, + {":[]@!$'(),;", "%3A%5B%5D%40%21%24%27%28%29%2C%3B", + "%3A%5B%5D%40%21%24%27%28%29%2C%3B"}, + {"%20%zz%", "%2520%25zz%25", "%2520%25zz%25"}, + {"\"<>\\`{}", "%22%3C%3E%5C%60%7B%7D", "%22%3C%3E%5C%60%7B%7D"}, + {"\u00e9\u03a9\ud83d\ude00", "%C3%A9%CE%A9%F0%9F%98%80", + "%C3%A9%CE%A9%F0%9F%98%80"}, + {"\u0000\r\n\t\u007f", "%00%0D%0A%09%7F", "%00%0D%0A%09%7F"} + }; + for (String[] example : cases) { + assertEquals(example[0], example[1], encoder.encodeForURL(example[0])); + assertEquals(example[0], example[2], reference.encodeForURL(example[0])); + assertEquals(example[0], URLDecoder.decode(example[1], "UTF-8")); + assertEquals(example[0], URLDecoder.decode(example[2], "UTF-8")); + } + } + + public void testUrlRetainsAdapterNullAndMalformedUtf16Policy() throws Exception { + Encoder reference = DefaultEncoder.getInstance(); + assertEquals("null", encoder.encodeForURL(null)); + assertNull(reference.encodeForURL(null)); + String[][] cases = { + {"\ud800", "-", "%3F"}, + {"\udfff", "-", "%3F"}, + {"a\ud800z", "a-z", "a%3Fz"}, + {"\udc00\ud800", "--", "%3F%3F"}, + {"\ud800\ud800\udc00", "-%F0%90%80%80", "%3F%F0%90%80%80"} + }; + for (String[] example : cases) { + assertEquals(example[1], encoder.encodeForURL(example[0])); + assertEquals(example[2], reference.encodeForURL(example[0])); + } + } + + public void testUrlEncodingCannotAddQueryParametersOrFragments() throws Exception { + String input = "a b+c&admin=true/../?other=value#fragment\u03a9\ud83d\ude00"; + String encoded = encoder.encodeForURL(input); + URI url = new URI("https://example.test/search?q=" + encoded + "&page=1"); + assertEquals("https", url.getScheme()); + assertEquals("example.test", url.getHost()); + assertEquals("/search", url.getRawPath()); + assertNull(url.getRawFragment()); + String[] query = url.getRawQuery().split("&", -1); + assertEquals(2, query.length); + assertEquals("page=1", query[1]); + String[] parameter = query[0].split("=", -1); + assertEquals(2, parameter.length); + assertEquals("q", parameter[0]); + assertEquals(input, URLDecoder.decode(parameter[1], "UTF-8")); + + URI path = new URI("https://example.test/items/" + encoded + "/detail"); + assertNull(path.getRawQuery()); + assertNull(path.getRawFragment()); + assertEquals(4, path.getRawPath().split("/", -1).length); + assertEquals(input, URLDecoder.decode(path.getRawPath().split("/", -1)[2], "UTF-8")); + } + + public void testUrlKeepsCheckedExceptionInterface() throws Exception { + assertEquals(Arrays.asList(EncodingException.class), Arrays.asList( + Encoder.class.getMethod("encodeForURL", String.class).getExceptionTypes())); + assertEquals(Arrays.asList(EncodingException.class), Arrays.asList( + encoder.getClass().getMethod("encodeForURL", String.class).getExceptionTypes())); + } + + public void testQuotedHtmlAttributesPreserveDataAndCannotAddMarkup() { + String[] inputs = {"", "'\" autofocus onfocus=alert(1) x='\"", "