}.
* (Note, the single-quote character ({@code '}) is not
* encoded.)
*
@@ -694,9 +695,11 @@ public static void forCssUrl(Writer out, String input)
* safe. Encode each untrusted value inserted into a URL with
* {@link #forUriComponent(String)} instead. To use an entire untrusted
* URL, parse it with {@link java.net.URI}, allow-list its scheme (for
- * example {@code http} and {@code https}), and then encode it for the
+ * example {@code http} and {@code https}), enforce any other application
+ * restrictions, and then encode it for the
* enclosing output context, for example with
- * {@link #forHtmlAttribute(String)}. This method always encodes
+ * {@link #forHtmlAttribute(String)}. Parsing alone does not establish safety.
+ * This method always encodes
* {@code %}, so never apply it to a URI that is already percent-encoded.
* It is retained for compatibility in all 1.x releases; removal in 2.0 is
* under consideration.
@@ -724,7 +727,7 @@ public static void forCssUrl(Writer out, String input)
/**
* Performs percent-encoding for a component of a URI, such as a query
- * parameter name or value, path or query-string. In particular this
+ * parameter name or value, path segment, or fragment. In particular this
* method ensures that special characters in the component do not get
* interpreted as part of another component.
*
@@ -745,9 +748,13 @@ public static void forCssUrl(Writer out, String input)
* Encoding Notes
*
*
- *
The output contains only unreserved characters and
- * {@code %xx} escapes, so it is safe to be used in most containing
- * contexts, including: HTML/XML, CSS, and JavaScript contexts.
+ *
The output contains only unreserved characters and {@code %xx}
+ * escapes. Assemble the URL from trusted structure and encoded raw
+ * components, validate it for its intended use, then encode the complete
+ * value for the enclosing context (for example,
+ * {@link #forHtmlAttribute(String)} for a quoted HTML attribute).
+ * Component encoding does not validate a URL or make arbitrary CSS or
+ * JavaScript code safe.
*
*
URL encoding is an encoding for bytes, not unicode. The
* input string is thus first encoded as a sequence of UTF-8
diff --git a/core/src/main/java/org/owasp/encoder/Encoders.java b/core/src/main/java/org/owasp/encoder/Encoders.java
index 40959ce..9e4fcef 100644
--- a/core/src/main/java/org/owasp/encoder/Encoders.java
+++ b/core/src/main/java/org/owasp/encoder/Encoders.java
@@ -150,8 +150,9 @@ public final class Encoders {
*
* @deprecated Encoding a complete URI does not make an untrusted URI
* safe. Use {@link #URI_COMPONENT} for each untrusted value inserted
- * into a URL, or validate an entire URL with {@link java.net.URI} and an
- * allow-listed scheme before encoding it for the enclosing context. See
+ * into a URL. For a complete URL, parse with {@link java.net.URI} and enforce
+ * application rules, including allowed schemes, before encoding it for the
+ * enclosing context. Parsing alone does not establish safety. See
* {@link Encode#forUri(String)}. Retained for compatibility in all 1.x
* releases.
*/
diff --git a/core/src/test/java/org/owasp/encoder/EncodersTest.java b/core/src/test/java/org/owasp/encoder/EncodersTest.java
index 7775e88..c8c3bc2 100644
--- a/core/src/test/java/org/owasp/encoder/EncodersTest.java
+++ b/core/src/test/java/org/owasp/encoder/EncodersTest.java
@@ -138,6 +138,21 @@ public void testJsonContext() throws Exception {
assertNotSame(Encoders.JAVASCRIPT_SOURCE_ENCODER, encoder);
}
+ @SuppressWarnings("deprecation") // Retained 1.x output is part of the contract.
+ public void testLegacyUriOutputIsUnchangedAcrossEntryPoints() throws Exception {
+ String input = "https://example.test/a b?q=a+b&next=%20#fragment";
+ String expected = "https://example.test/a%20b?q=a+b&next=%2520#fragment";
+ assertEquals(expected, Encode.forUri(input));
+ StringWriter direct = new StringWriter();
+ Encode.forUri(direct, input);
+ assertEquals(expected, direct.toString());
+ StringWriter registered = new StringWriter();
+ EncodedWriter writer = new EncodedWriter(registered, "uri");
+ writer.write(input);
+ writer.close();
+ assertEquals(expected, registered.toString());
+ }
+
public void testXML11Names() {
assertXML11Encoder("xml-1.1", XMLEncoder.Mode.ALL,
"&<>"'");
diff --git a/esapi/README.md b/esapi/README.md
index ddf408b..cc0331f 100644
--- a/esapi/README.md
+++ b/esapi/README.md
@@ -81,6 +81,63 @@ Applications can select another tested version with normal Maven dependency
management. The signed 1.4.1 POM and current development POM default
deterministically to 2.7.0.0; the Central 1.4.0 POM still uses the range above.
+## URL encoding migration in 1.5 (unreleased)
+
+Starting with `1.5.0-SNAPSHOT`, `ESAPIEncoder.getInstance().encodeForURL(value)`
+uses `Encode.forUriComponent` to encode **one raw URL component** with UTF-8.
+Earlier adapter releases, including signed 1.4.1, use deprecated `Encode.forUri`
+and leave delimiters such as `& = / ? # +` unchanged. For example:
+
+| Raw input | Adapter through 1.4.1 | Adapter 1.5 | ESAPI reference with UTF-8 |
+| --- | --- | --- | --- |
+| `a b+c&admin=true` | `a%20b+c&admin=true` | `a%20b%2Bc%26admin%3Dtrue` | `a+b%2Bc%26admin%3Dtrue` |
+| `~*` | `~*` | `~%2A` | `%7E*` |
+| `%20` | `%2520` | `%2520` | `%2520` |
+| Java `null` | String `"null"` | String `"null"` | Java `null` |
+| Unpaired UTF-16 surrogate | `-` | `-` | `%3F` (replacement `?`) |
+
+The adapter deliberately uses RFC 3986 component encoding with `%20` for spaces,
+preserving its existing space, null, and malformed-Unicode policies. ESAPI's
+[reference implementation][esapi-url-reference] uses form encoding with `+` for
+spaces and the configured `Encryptor.CharacterEncoding`. Both escape URL
+delimiters, but their output is not interchangeable for byte comparisons or
+request signatures. Use `java.net.URLEncoder.encode(value, "UTF-8")` if a
+protocol requires form encoding. The adapter still declares `EncodingException`
+and does not read ESAPI configuration for this operation.
+
+Encode each raw parameter name/value or path segment once, then assemble the
+URL using trusted delimiters. Validate the final URL against application rules,
+including allowed schemes and any destination/path restrictions. Component
+encoding does not prevent every application-specific path issue (for example,
+`.` and `..` are unreserved). URI parsing alone is not a safety check. For a
+quoted HTML URL attribute, encode the assembled, validated URL with
+`Encode.forHtmlAttribute`. See the [shared migration guidance](../README.md#migrating-from-foruri).
+
+Callers that passed complete URLs must change that call pattern before adopting
+1.5: component encoding escapes the scheme colon, slashes, and other structural
+delimiters. Already percent-encoded input is encoded again. Existing core,
+registry, JSP, and Jakarta `forUri` entry points retain their behavior throughout
+1.x; only this adapter delegate changes. This is unreleased 1.5 behavior, not a
+change to the retained 1.4.1 artifacts.
+
+## Supported output contexts
+
+The adapter intentionally preserves these contexts rather than matching every
+escape emitted by ESAPI's reference implementation:
+
+| Method | Supported context and caller responsibility |
+| --- | --- |
+| `encodeForHTMLAttribute` | A quoted HTML text attribute. Supply single or double quotes. HTML escaping alone does not make event-handler code or an unvalidated URL safe. |
+| `encodeForCSS` | A quoted CSS string using `Encode.forCssString`; not arbitrary unquoted CSS values or expressions. |
+| `encodeForJavaScript` | A single/double-quoted string or literal text in an ordinary untagged template, using `Encode.forJavaScript`. Not JSON, tagged templates (including `String.raw`), `${...}` expression bodies, arbitrary unquoted code, or script URLs. |
+| `encodeForURL` (1.5) | One raw URL component; assemble and validate the URL, then encode for its enclosing context. |
+
+More escaping does not make arbitrary unquoted JavaScript or CSS safe. The CSS
+size fix, JavaScript template-boundary and Unicode handling, JSON delegation,
+lazy reference lookup, and ESAPI's default disablement of unsafe SQL encoding
+remain intact. Parser and contract tests run against the stable ESAPI matrix
+listed above; that matrix remains separate from upstream security support.
+
## Runtime and security notes
`ESAPIEncoder.getInstance()` and its OWASP Java Encoder-backed methods do not
@@ -127,6 +184,7 @@ dependency declaration.
[esapi-security]: https://github.com/ESAPI/esapi-java-legacy/security
[esapi-latest]: https://github.com/ESAPI/esapi-java-legacy/releases/latest
[esapi-release]: https://github.com/ESAPI/esapi-java-legacy/releases/tag/esapi-2.7.0.0
+[esapi-url-reference]: https://github.com/ESAPI/esapi-java-legacy/blob/esapi-2.7.0.0/src/main/java/org/owasp/esapi/reference/DefaultEncoder.java#L506-L516
[encoder-release]: https://github.com/OWASP/owasp-java-encoder/releases/tag/v1.4.1
[encoder-verification]: ../releases/1.4.1.md#verification
[encoder-advisories]: ../releases/1.4.1.md#security-fixes
diff --git a/esapi/pom.xml b/esapi/pom.xml
index e12d053..2e5287f 100644
--- a/esapi/pom.xml
+++ b/esapi/pom.xml
@@ -71,6 +71,13 @@
esapi${esapi.version}
+
+
+ org.jsoup
+ jsoup
+ 1.23.2
+ test
+
diff --git a/esapi/src/main/java/org/owasp/encoder/esapi/ESAPIEncoder.java b/esapi/src/main/java/org/owasp/encoder/esapi/ESAPIEncoder.java
index 2924cbb..61294f1 100644
--- a/esapi/src/main/java/org/owasp/encoder/esapi/ESAPIEncoder.java
+++ b/esapi/src/main/java/org/owasp/encoder/esapi/ESAPIEncoder.java
@@ -59,13 +59,29 @@
*
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.
{@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='\"", "