From 1e01c9dd1df3bfebc3b892d469abab6fc856b2e5 Mon Sep 17 00:00:00 2001 From: Jim Manico Date: Fri, 25 Sep 2026 20:24:46 -0700 Subject: [PATCH 1/2] Resolve ESAPI reference encoder only for delegated calls --- esapi/README.md | 8 ++ .../org/owasp/encoder/esapi/ESAPIEncoder.java | 51 +++++---- .../owasp/encoder/esapi/ESAPIEncoderTest.java | 70 ++++++++++++ .../esapi/ESAPIInitializationProbe.java | 101 ++++++++++++++++++ 4 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java diff --git a/esapi/README.md b/esapi/README.md index 5e886ab..5012855 100644 --- a/esapi/README.md +++ b/esapi/README.md @@ -30,6 +30,14 @@ management. The adapter's published POM still defaults deterministically to ## Runtime and security notes +`ESAPIEncoder.getInstance()` and its OWASP Java Encoder-backed methods do not +load ESAPI configuration. Delegated operations resolve ESAPI's reference encoder +on each call, so a missing `ESAPI.properties` causes an ESAPI configuration +exception for that operation and can be retried after configuration becomes +available in the same JVM. ESAPI retains responsibility for caching its reference +encoder. Obtaining the adapter through `ESAPI.encoder()` still requires ESAPI +configuration to select the implementation. + The ESAPI dependency remains a compile dependency because its `Encoder` type is part of the adapter's public API. Its transitive dependencies are therefore also available to applications. The ESAPI module enforces dependency convergence for 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 3b2aecd..3923a74 100644 --- a/esapi/src/main/java/org/owasp/encoder/esapi/ESAPIEncoder.java +++ b/esapi/src/main/java/org/owasp/encoder/esapi/ESAPIEncoder.java @@ -125,6 +125,9 @@ private ESAPIEncoder() {} * that the OWASP Java Encoders implement (see class documentation). * Though not a requirement of the ESAPI Encoder API, the returned value * is also serializable. + * Obtaining the instance and using OWASP Java Encoder-backed methods does + * not load ESAPI configuration. Delegated methods resolve ESAPI's reference + * encoder when called and require its configuration to be available. * * @return An encoder implementation that uses the OWASP Java Encoders * for most of the common encoding methods. @@ -136,8 +139,9 @@ public static Encoder getInstance() { /** * This is the private singleton that implements the ESAPI Encoder shim. * It is implemented as a single-value enum to get all the "free" singleton - * properties associated with enums--such as serialization, and on-demand - * initialization. + * properties associated with enums, including serialization and thread-safe + * initialization. Initializing this enum does not initialize ESAPI's + * reference encoder. * *

The implementation is intentionally private to avoid any API baggage. * The instance should be obtained using @@ -150,33 +154,36 @@ private enum Impl implements Encoder { INSTANCE; /** - * The reference encoder from ESAPI. Any ESAPI method without an - * OWASP Java Encoder equivalent is delegated to this instance. + * Resolves ESAPI's reference encoder for each delegated call. ESAPI + * caches the successful singleton itself. Keeping this call out of + * class initialization allows a failed configuration load to be retried. */ - private final Encoder _referenceEncoder = DefaultEncoder.getInstance(); + private static Encoder reference() { + return DefaultEncoder.getInstance(); + } /** {@inheritDoc} */ @Override public String canonicalize(String s) { - return _referenceEncoder.canonicalize(s); + return reference().canonicalize(s); } /** {@inheritDoc} */ @Override public String canonicalize(String s, boolean strict) { - return _referenceEncoder.canonicalize(s, strict); + return reference().canonicalize(s, strict); } /** {@inheritDoc} */ @Override public String canonicalize(String s, boolean restrictMultiple, boolean restrictMixed) { - return _referenceEncoder.canonicalize(s, restrictMultiple, restrictMixed); + return reference().canonicalize(s, restrictMultiple, restrictMixed); } /** {@inheritDoc} */ @Override public String getCanonicalizedURI(URI dirtyUri) { - return _referenceEncoder.getCanonicalizedURI(dirtyUri); + return reference().getCanonicalizedURI(dirtyUri); } /** @@ -197,7 +204,7 @@ public String encodeForHTML(String s) { /** {@inheritDoc} */ @Override public String decodeForHTML(String s) { - return _referenceEncoder.decodeForHTML(s); + return reference().decodeForHTML(s); } /** {@inheritDoc} */ @@ -218,43 +225,43 @@ public String encodeForJavaScript(String s) { /** {@inheritDoc} */ @Override public String encodeForVBScript(String s) { - return _referenceEncoder.encodeForVBScript(s); + return reference().encodeForVBScript(s); } /** {@inheritDoc} */ @Override public String encodeForSQL(Codec codec, String s) { - return _referenceEncoder.encodeForSQL(codec, s); + return reference().encodeForSQL(codec, s); } /** {@inheritDoc} */ @Override public String encodeForOS(Codec codec, String s) { - return _referenceEncoder.encodeForOS(codec, s); + return reference().encodeForOS(codec, s); } /** {@inheritDoc} */ @Override public String encodeForLDAP(String s) { - return _referenceEncoder.encodeForLDAP(s); + return reference().encodeForLDAP(s); } /** {@inheritDoc} */ @Override public String encodeForLDAP(String s, boolean b) { - return _referenceEncoder.encodeForLDAP(s, b); + return reference().encodeForLDAP(s, b); } /** {@inheritDoc} */ @Override public String encodeForDN(String s) { - return _referenceEncoder.encodeForDN(s); + return reference().encodeForDN(s); } /** {@inheritDoc} */ @Override public String encodeForXPath(String s) { - return _referenceEncoder.encodeForXPath(s); + return reference().encodeForXPath(s); } /** {@inheritDoc} */ @@ -282,19 +289,19 @@ public String encodeForURL(String s) throws EncodingException { /** {@inheritDoc} */ @Override public String decodeFromURL(String s) throws EncodingException { - return _referenceEncoder.decodeFromURL(s); + return reference().decodeFromURL(s); } /** {@inheritDoc} */ @Override public String encodeForBase64(byte[] bytes, boolean wrap) { - return _referenceEncoder.encodeForBase64(bytes, wrap); + return reference().encodeForBase64(bytes, wrap); } /** {@inheritDoc} */ @Override public byte[] decodeFromBase64(String s) throws IOException { - return _referenceEncoder.decodeFromBase64(s); + return reference().decodeFromBase64(s); } /** @@ -302,7 +309,7 @@ public byte[] decodeFromBase64(String s) throws IOException { */ @Override public String encodeForJSON(String s) { - return _referenceEncoder.encodeForJSON(s); + return reference().encodeForJSON(s); } /** @@ -310,7 +317,7 @@ public String encodeForJSON(String s) { */ @Override public String decodeFromJSON(String s) { - return _referenceEncoder.decodeFromJSON(s); + return reference().decodeFromJSON(s); } } diff --git a/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIEncoderTest.java b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIEncoderTest.java index b5b1b51..ac06a84 100644 --- a/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIEncoderTest.java +++ b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIEncoderTest.java @@ -2,14 +2,20 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; +import java.util.concurrent.TimeUnit; import junit.framework.Test; import junit.framework.TestCase; import junit.framework.TestSuite; +import org.junit.rules.TemporaryFolder; import org.owasp.encoder.Encode; import org.owasp.esapi.ESAPI; import org.owasp.esapi.Encoder; @@ -31,6 +37,70 @@ public void testConfiguredAsEsapiEncoder() { assertSame(ESAPIEncoder.getInstance(), ESAPI.encoder()); } + public void testInitializationRecoversWhenConfigurationArrives() throws Exception { + TemporaryFolder temporary = new TemporaryFolder(); + temporary.create(); + try { + File work = temporary.newFolder("work"); + File home = temporary.newFolder("home"); + File configuration = temporary.newFolder("configuration"); + File classes = temporary.newFolder("classes"); + String probe = ESAPIInitializationProbe.class.getName(); + String resource = probe.replace('.', '/') + ".class"; + Path target = classes.toPath().resolve(resource); + Files.createDirectories(target.getParent()); + try (InputStream input = getClass().getResourceAsStream("/" + resource)) { + assertNotNull(input); + Files.copy(input, target); + } + File fixture = new File(temporary.getRoot(), "fixture.properties"); + try (InputStream input = getClass().getResourceAsStream("/.esapi/ESAPI.properties")) { + assertNotNull(input); + Files.copy(input, fixture.toPath()); + } + + // Include production classes and dependency JARs, but never the test + // output directory: it contains .esapi/ESAPI.properties. + StringBuilder classpath = new StringBuilder(classes.getAbsolutePath()); + for (Class type : new Class[] {ESAPIEncoder.class, Encode.class}) { + classpath.append(File.pathSeparator).append(new File(type.getProtectionDomain() + .getCodeSource().getLocation().toURI()).getAbsolutePath()); + } + String testClasspath = System.getProperty("surefire.test.class.path", + System.getProperty("java.class.path")); + for (String entry : testClasspath.split(File.pathSeparator)) { + File file = new File(entry); + if (file.isFile() && file.getName().endsWith(".jar")) { + classpath.append(File.pathSeparator).append(file.getAbsolutePath()); + } + } + File java = new File(System.getProperty("java.home"), + File.separatorChar == '\\' ? "bin/java.exe" : "bin/java"); + File output = temporary.newFile("probe.log"); + ProcessBuilder builder = new ProcessBuilder(java.getAbsolutePath(), + "-Duser.home=" + home.getAbsolutePath(), + "-Dorg.owasp.esapi.resources=" + configuration.getAbsolutePath(), + "-cp", classpath.toString(), probe, fixture.getAbsolutePath()); + // Do not inherit JVM options that could supply configuration or a + // previously customized ESAPI implementation to the isolated process. + builder.environment().remove("JAVA_TOOL_OPTIONS"); + builder.environment().remove("JDK_JAVA_OPTIONS"); + builder.environment().remove("_JAVA_OPTIONS"); + Process process = builder.directory(work).redirectErrorStream(true) + .redirectOutput(output).start(); + try { + assertTrue("Initialization probe timed out", process.waitFor(30, TimeUnit.SECONDS)); + String text = new String(Files.readAllBytes(output.toPath()), StandardCharsets.UTF_8); + assertEquals(text, 0, process.exitValue()); + assertTrue(text, text.contains("ESAPI initialization recovery passed")); + } finally { + process.destroyForcibly(); + } + } finally { + temporary.delete(); + } + } + public void testJavaEncoderBackedMethods() throws Exception { Encoder encoder = ESAPIEncoder.getInstance(); String input = "<>&\"' /\u03a9"; diff --git a/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java new file mode 100644 index 0000000..d5f5ee1 --- /dev/null +++ b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java @@ -0,0 +1,101 @@ +package org.owasp.encoder.esapi; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.owasp.encoder.Encode; +import org.owasp.esapi.ESAPI; +import org.owasp.esapi.Encoder; +import org.owasp.esapi.errors.ConfigurationException; +import org.owasp.esapi.reference.DefaultEncoder; +import static org.junit.Assert.*; + +/** Runs only in a fresh JVM with no test configuration on its classpath. */ +public final class ESAPIInitializationProbe { + private ESAPIInitializationProbe() {} + + public static void main(String[] args) throws Exception { + ClassLoader loader = ESAPIInitializationProbe.class.getClassLoader(); + for (String resource : new String[] {"ESAPI.properties", ".esapi/ESAPI.properties", + "esapi/ESAPI.properties"}) { + assertNull("Configuration leaked onto the probe classpath: " + resource, + loader.getResource(resource)); + } + Path configuration = Paths.get(System.getProperty("org.owasp.esapi.resources")); + assertFalse(Files.exists(configuration.resolve("ESAPI.properties"))); + + Encoder encoder = ESAPIEncoder.getInstance(); + assertBackedMethods(encoder); + assertSingletonSerialization(encoder); + for (int attempt = 0; attempt < 2; attempt++) { + try { + encoder.decodeForHTML("<"); + fail("Delegation must require ESAPI configuration"); + } catch (ConfigurationException expected) { + // Errors such as ExceptionInInitializerError and + // NoClassDefFoundError are intentionally not caught. + } + try { + encoder.canonicalize("<"); + fail("Canonicalization must require ESAPI configuration"); + } catch (ConfigurationException expected) { + // The reference singleton must remain retryable after failure. + } + try { + encoder.encodeForJSON(null); + fail("JSON must still delegate to ESAPI, including null input"); + } catch (ConfigurationException expected) { + // Preserve delegation instead of adding a separate JSON policy. + } + assertSame(encoder, ESAPIEncoder.getInstance()); + assertBackedMethods(encoder); + assertSingletonSerialization(encoder); + } + + // Make configuration available in the SAME JVM. Do not reset any ESAPI + // singleton or use a new class loader: the adapter itself must recover. + Files.copy(Paths.get(args[0]), configuration.resolve("ESAPI.properties")); + assertEquals("<", encoder.decodeForHTML("<")); + assertEquals("<", encoder.canonicalize("<")); + assertEquals("<", encoder.canonicalize("<", true)); + assertEquals("<", encoder.canonicalize("<", true, true)); + assertNull(encoder.encodeForJSON(null)); + assertNull(encoder.decodeFromJSON(null)); + assertEquals(DefaultEncoder.getInstance().encodeForJSON("a\"b"), + encoder.encodeForJSON("a\"b")); + assertEquals("a\"b", encoder.decodeFromJSON("a\\\"b")); + assertSame(DefaultEncoder.getInstance(), DefaultEncoder.getInstance()); + assertSame(encoder, ESAPI.encoder()); + assertBackedMethods(encoder); + assertSingletonSerialization(encoder); + System.out.println("ESAPI initialization recovery passed"); + } + + private static void assertBackedMethods(Encoder encoder) throws Exception { + String input = "<>&\"' /\u03a9"; + assertEquals("<", encoder.encodeForHTML("<")); + assertEquals(Encode.forCssString(input), encoder.encodeForCSS(input)); + assertEquals(Encode.forHtml(input), encoder.encodeForHTML(input)); + assertEquals(Encode.forHtmlAttribute(input), encoder.encodeForHTMLAttribute(input)); + assertEquals(Encode.forJavaScript(input), encoder.encodeForJavaScript(input)); + assertEquals(Encode.forXml(input), encoder.encodeForXML(input)); + assertEquals(Encode.forXmlAttribute(input), encoder.encodeForXMLAttribute(input)); + assertEquals(Encode.forUri(input), encoder.encodeForURL(input)); + } + + private static void assertSingletonSerialization(Encoder encoder) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { + output.writeObject(encoder); + } + try (ObjectInputStream input = new ObjectInputStream( + new ByteArrayInputStream(bytes.toByteArray()))) { + assertSame(encoder, input.readObject()); + } + assertSame(encoder, ESAPIEncoder.getInstance()); + } +} From c8dd8eba73cbc5ad482ac3974073213785d2d3a7 Mon Sep 17 00:00:00 2001 From: Jim Manico Date: Fri, 25 Sep 2026 20:31:45 -0700 Subject: [PATCH 2/2] Check every ESAPI classpath configuration path in recovery probe --- .../owasp/encoder/esapi/ESAPIInitializationProbe.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java index d5f5ee1..27689d9 100644 --- a/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java +++ b/esapi/src/test/java/org/owasp/encoder/esapi/ESAPIInitializationProbe.java @@ -20,8 +20,9 @@ private ESAPIInitializationProbe() {} public static void main(String[] args) throws Exception { ClassLoader loader = ESAPIInitializationProbe.class.getClassLoader(); - for (String resource : new String[] {"ESAPI.properties", ".esapi/ESAPI.properties", - "esapi/ESAPI.properties"}) { + for (String resource : new String[] {"ESAPI.properties", "resourceDirectory/ESAPI.properties", + ".esapi/ESAPI.properties", "esapi/ESAPI.properties", + "resources/ESAPI.properties", "src/main/resources/ESAPI.properties"}) { assertNull("Configuration leaked onto the probe classpath: " + resource, loader.getResource(resource)); } @@ -65,10 +66,11 @@ public static void main(String[] args) throws Exception { assertEquals("<", encoder.canonicalize("<", true, true)); assertNull(encoder.encodeForJSON(null)); assertNull(encoder.decodeFromJSON(null)); - assertEquals(DefaultEncoder.getInstance().encodeForJSON("a\"b"), + Encoder reference = DefaultEncoder.getInstance(); + assertEquals(reference.encodeForJSON("a\"b"), encoder.encodeForJSON("a\"b")); assertEquals("a\"b", encoder.decodeFromJSON("a\\\"b")); - assertSame(DefaultEncoder.getInstance(), DefaultEncoder.getInstance()); + assertSame(reference, DefaultEncoder.getInstance()); assertSame(encoder, ESAPI.encoder()); assertBackedMethods(encoder); assertSingletonSerialization(encoder);