From 28daa288768a70e98f71337dbfe826f80339d1af Mon Sep 17 00:00:00 2001 From: Volodymyr Borysenko Date: Fri, 25 Sep 2026 22:43:05 -0700 Subject: [PATCH] Declare OSGi import ranges from actual API use and freeze BSNs (#137) Dependencies are excluded from bnd's analysis, so every adapter imported org.owasp.encoder and its JSP/Jakarta API packages unversioned. An adapter could then wire to an older core and fail only when a tag ran: the JSON tags call Encode.forJson, added in 1.5. - The parent bundle configuration takes Bundle-SymbolicName and Import-Package from per-module properties. The four published names are now declared explicitly and are unchanged: org.owasp.encoder, org.owasp.encoder.jsp, org.owasp.encoder.jakarta-jsp, org.owasp.encoder.esapi. - encoder-jsp and encoder-jakarta-jsp import org.owasp.encoder [1.5,2). javax.servlet.jsp(.tagext) is [2.0,3) (the tag APIs used are JSP 2.0) and jakarta.servlet.jsp(.tagext) is [3.0,4). Pages 4 is not accepted without compatibility evidence. - encoder-esapi calls only pre-1.4 core methods, so it imports org.owasp.encoder [1.4.1,2), the oldest supported core (SECURITY.md). ESAPI packages stay unversioned because ESAPI has no OSGi metadata. - No embedded classes, new exports or java.* imports; core still imports nothing. The packaged-consumer harness now asserts each artifact's exact import ranges, exports host packages at the versions the pinned API JARs declare, runs ForJsonTag in the JSP/Jakarta probes, and installs each adapter with the released 1.4.0 core on Felix R6 and R8, requiring it to fail to resolve. Guard tests reject an unversioned core import, a lowered ESAPI floor, a widened Pages range and a changed BSN. The README gains an OSGi Bundles table and a News entry. --- README.md | 21 ++++++++++++ compatibility/README.md | 8 +++-- compatibility/consumers.py | 37 +++++++++++++++++++--- compatibility/dependencies/legacy-core.xml | 3 ++ compatibility/src/OsgiConsumer.java | 17 ++++++++++ compatibility/src/TagConsumer.java | 12 +++++++ compatibility/tests/test_guards.py | 25 +++++++++++++++ core/pom.xml | 1 + esapi/pom.xml | 9 ++++++ jakarta/pom.xml | 9 ++++++ jsp/pom.xml | 9 ++++++ pom.xml | 8 +++++ 12 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 compatibility/dependencies/legacy-core.xml diff --git a/README.md b/README.md index 801a876..faf4cb9 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,26 @@ The ESAPI adapter's fixed dependency and tested compatibility policy are documented in [esapi/README.md](esapi/README.md). +OSGi Bundles +------------ + +| JAR | Bundle-SymbolicName | Export-Package | Imports `org.owasp.encoder` | Imports API packages | +|---------------------|---------------------------------|--------------------------|-----------------------------|-------------------------------------------------------------| +| encoder | `org.owasp.encoder` | `org.owasp.encoder` | (none) | (none) | +| encoder-jsp | `org.owasp.encoder.jsp` | `org.owasp.encoder.tag` | `[1.5,2)` | `javax.servlet.jsp`, `javax.servlet.jsp.tagext`: `[2.0,3)` | +| encoder-jakarta-jsp | `org.owasp.encoder.jakarta-jsp` | `org.owasp.encoder.tag` | `[1.5,2)` | `jakarta.servlet.jsp`, `jakarta.servlet.jsp.tagext`: `[3.0,4)` | +| encoder-esapi | `org.owasp.encoder.esapi` | `org.owasp.encoder.esapi`| `[1.4.1,2)` | `org.owasp.esapi.*`: unversioned | + +The symbolic names are fixed; note that the Jakarta bundle's differs from its +`Automatic-Module-Name`. Core exports `org.owasp.encoder` at its release version. +The JSP and Jakarta tags require core 1.5 because they call `Encode.forJson`; the +ESAPI adapter only calls older methods, so its floor is the oldest supported core, +the 1.4.1 security release. Jakarta Pages 4 is not in the accepted range until +compatibility with it has been verified. ESAPI publishes no OSGi metadata: OSGi +users must wrap ESAPI and its dependencies as bundles themselves. The project's +tests supply the ESAPI packages from the framework host, which is not a statement +that upstream ESAPI supports OSGi. + TagLib -------------------- @@ -206,6 +226,7 @@ Development builds use `1.5.0-SNAPSHOT`; this is not a published release. * 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. * deprecation: `Encoders.URI` and both `ForUriTag` classes are now deprecated like `Encode.forUri`, whose Javadoc now says what to use instead; the `forUri` TLD descriptions warn about double encoding, the adapter builds show deprecation call sites, and the README has a [forUri migration section](#migrating-from-foruri) [#130](https://github.com/OWASP/owasp-java-encoder/issues/130). +* fix: the JSP, Jakarta and ESAPI bundles now declare the core versions they need (`[1.5,2)` for the tags, which call `Encode.forJson`; `[1.4.1,2)` for ESAPI) and the JSP API ranges they support, instead of unversioned imports that could wire to an older core and fail when a tag ran. Bundle symbolic names are now declared explicitly and unchanged [#137](https://github.com/OWASP/owasp-java-encoder/issues/137). * fix: `forHtmlUnquotedAttribute` now replaces U+0085 (NEL) with a hyphen like the other C1 control characters, instead of emitting `…`, which HTML5 parsers decode as U+2026 [#136](https://github.com/OWASP/owasp-java-encoder/issues/136). * fix: the XML 1.1 encoders (`forXml11`, `forXml11Content`, `forXml11Attribute`) now encode U+0085 (NEL) as `…` and U+2028 (line separator) as `
`, so they are not normalized to a line feed [#136](https://github.com/OWASP/owasp-java-encoder/issues/136). * maintenance: clarify output-context contracts and expand XML 1.1 tests, fix clean reactor compilation, and remove the obsolete benchmark profile. diff --git a/compatibility/README.md b/compatibility/README.md index 1d482cc..7062f07 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -60,7 +60,11 @@ unrelated split packages among legacy dependency JARs. OSGi tests start Felix 5.6.12 (R6) and 7.0.5 (R8), install the actual core/adapter JARs and a consumer probe bundle, assert ACTIVE state, invoke encoding through the probe's bundle class loader, and shut down the framework. The framework host -supplies the pinned servlet/JSP/EL or ESAPI API packages via system-package exports. +supplies the pinned servlet/JSP/EL or ESAPI API packages via system-package exports, +at the versions the pinned API JARs declare (ESAPI packages are unversioned). The +JSP and Jakarta probes also run `ForJsonTag`, which needs the 1.5 core API. Each +adapter is then installed with the released 1.4.0 core and must fail to resolve, +proving its `org.owasp.encoder` import range excludes cores it cannot run on. Encoder code is absent from the host classpath. This tests the encoder bundles' imports, resolution, and execution; it does not test independently installed vendor API bundles or a full servlet container. Legacy Felix URL handlers are @@ -70,7 +74,7 @@ disabled because this fixture does not use them. Preparation asserts all four artifacts' automatic/explicit module names, descriptor requirements (including transitive API readability), exports, OSGi -identities, imports and export versions, absence of execution-environment +identities, imported packages with their exact version ranges, export versions, absence of execution-environment requirements, multi-release layout, Java 8 class versions, TLD identities and referenced packaged classes, and allowed published runtime dependencies. Base classes must stay within each artifact's own package; test classes and embedded diff --git a/compatibility/consumers.py b/compatibility/consumers.py index 09a9e7d..1bc5752 100644 --- a/compatibility/consumers.py +++ b/compatibility/consumers.py @@ -26,6 +26,20 @@ HOST_PACKAGES = {'core': [], 'jsp': ['javax.servlet.jsp', 'javax.servlet.jsp.tagext', 'javax.servlet.jsp.el', 'javax.el'], 'jakarta': ['jakarta.servlet.jsp', 'jakarta.servlet.jsp.tagext', 'jakarta.servlet.jsp.el', 'jakarta.el'], 'esapi': ['org.owasp.esapi', 'org.owasp.esapi.codecs', 'org.owasp.esapi.errors', 'org.owasp.esapi.reference']} +# Versions the framework exports for host packages, copied from the Export-Package +# headers of the API JARs in compatibility/dependencies (ESAPI has no OSGi metadata). +HOST_VERSIONS = {'javax.servlet.jsp': '2.2.1', 'javax.servlet.jsp.tagext': '2.2.1', 'javax.servlet.jsp.el': '2.2.1', + 'javax.el': '2.2.5', 'jakarta.servlet.jsp': '3.0.0.SNAPSHOT', 'jakarta.servlet.jsp.tagext': '3.0.0.SNAPSHOT', + 'jakarta.servlet.jsp.el': '3.0.0.SNAPSHOT', 'jakarta.el': '4.0.0'} +# Published Import-Package version ranges (#137); None means deliberately unversioned. +# The tags call Encode.forJson (1.5); the ESAPI adapter's floor is the oldest supported core. +IMPORT_RANGES = { + 'core': {}, + 'jsp': {'org.owasp.encoder': '[1.5,2)', 'javax.servlet.jsp': '[2.0,3)', 'javax.servlet.jsp.tagext': '[2.0,3)'}, + 'jakarta': {'org.owasp.encoder': '[1.5,2)', 'jakarta.servlet.jsp': '[3.0,4)', 'jakarta.servlet.jsp.tagext': '[3.0,4)'}, + 'esapi': {'org.owasp.encoder': '[1.4.1,2)', 'org.owasp.esapi': None, 'org.owasp.esapi.codecs': None, + 'org.owasp.esapi.errors': None, 'org.owasp.esapi.reference': None}, +} def run(*args, **kwargs): @@ -65,9 +79,13 @@ def metadata(kind, jar, core): assert [entry.split(';')[0] for entry in exports] == [package], exports assert ';version="' + '.'.join(attrs['Bundle-Version'].split('.')[:3]) + '"' in exports[0], exports imports = clauses(attrs.get('Import-Package', '')) - expected_imports = set(HOST_PACKAGES[kind]) - {'javax.servlet.jsp.el', 'javax.el', 'jakarta.servlet.jsp.el', 'jakarta.el'} - if kind != 'core': expected_imports.add('org.owasp.encoder') - assert set(x.split(';')[0] for x in imports) == expected_imports, imports + actual_ranges = {} + for entry in imports: + name, *parameters = entry.split(';') + versions = [p.split('=', 1)[1].strip('"') for p in parameters if p.startswith('version=')] + assert name not in actual_ranges, ('duplicate import', entry) + actual_ranges[name] = versions[0] if versions else None + assert actual_ranges == IMPORT_RANGES[kind], (kind, imports) assert not any(x.startswith('java.') for x in imports), imports with zipfile.ZipFile(jar) as archive, zipfile.ZipFile(core) as core_archive: names = archive.namelist() @@ -161,7 +179,7 @@ def prepare(args): for entry in source.infolist(): if not entry.filename.endswith('module-info.class'): target.writestr(entry, source.read(entry)) - for kind in ('jsp', 'jakarta', 'esapi', 'osgi-r6', 'osgi-r8'): + for kind in ('jsp', 'jakarta', 'esapi', 'osgi-r6', 'osgi-r8', 'legacy-core'): run(args.maven, '-B', '-ntp', '-f', ROOT / 'compatibility/dependencies' / (kind + '.xml'), '-Dmaven.repo.local=' + str(args.repository.resolve()), 'org.apache.maven.plugins:maven-dependency-plugin:3.9.0:copy-dependencies', @@ -225,12 +243,21 @@ def consume(args): run(java, *config, '-Djdk.util.jar.enableMultiRelease=' + str(mode == 'explicit').lower(), '-Dconsumer.module=' + name, '--module-path', path([out / mode / kind] + artifacts + module_deps), '--class-path', path(classpath), '--module', 'consumer.fixture/consumer.' + main) + host = ','.join(p + (';version="' + HOST_VERSIONS[p] + '"' if p in HOST_VERSIONS else '') + for p in HOST_PACKAGES[kind]) + legacy_core = sorted((out / 'dependencies' / 'legacy-core').glob('encoder-*.jar')) for framework in ('osgi-r6', 'osgi-r8'): framework_jars = sorted((out / 'dependencies' / framework).glob('*.jar')) with tempfile.TemporaryDirectory(prefix='encoder-osgi-') as storage: run(java, *config, '-cp', path([out / 'osgi'] + framework_jars + deps), 'consumer.OsgiConsumer', - storage, ','.join(HOST_PACKAGES[kind]), 'consumer.' + main, + storage, host, 'consumer.' + main, *artifacts, out / 'probes' / (kind + '.jar')) + if kind != 'core': + # A released core older than the adapter's import range must not wire. + assert len(legacy_core) == 1, legacy_core + with tempfile.TemporaryDirectory(prefix='encoder-osgi-') as storage: + run(java, *config, '-cp', path([out / 'osgi'] + framework_jars + deps), 'consumer.OsgiConsumer', + storage, host, '--expect-unresolved', legacy_core[0], jars[kind]) print('PASS Java', args.runtime, kind, flush=True) diff --git a/compatibility/dependencies/legacy-core.xml b/compatibility/dependencies/legacy-core.xml new file mode 100644 index 0000000..623a20b --- /dev/null +++ b/compatibility/dependencies/legacy-core.xml @@ -0,0 +1,3 @@ +4.0.0org.owasp.encoder.testsconsumer-legacy-core1 + org.owasp.encoderencoder1.4.0 + diff --git a/compatibility/src/OsgiConsumer.java b/compatibility/src/OsgiConsumer.java index 8f4119e..888f8a1 100644 --- a/compatibility/src/OsgiConsumer.java +++ b/compatibility/src/OsgiConsumer.java @@ -5,6 +5,7 @@ import java.util.Map; import org.apache.felix.framework.FrameworkFactory; import org.osgi.framework.Bundle; +import org.osgi.framework.BundleException; import org.osgi.framework.Constants; import org.osgi.framework.FrameworkEvent; import org.osgi.framework.launch.Framework; @@ -22,12 +23,28 @@ public static void main(String[] args) throws Exception { config.put(Constants.FRAMEWORK_STORAGE, args[0]); config.put(Constants.FRAMEWORK_SYSTEMPACKAGES_EXTRA, args[1]); config.put("felix.service.urlhandlers", "false"); + // "--expect-unresolved" installs an older core and the adapter, which must + // then fail to resolve against it instead of wiring (#137). + boolean expectUnresolved = "--expect-unresolved".equals(args[2]); Framework framework = new FrameworkFactory().newFramework(config); framework.init(); try { framework.start(); for (int i = 3; i < args.length; ++i) { Bundle bundle = framework.getBundleContext().installBundle(new File(args[i]).toURI().toString()); + if (expectUnresolved && i == args.length - 1) { + try { + bundle.start(); + } catch (BundleException expected) { + String message = String.valueOf(expected.getMessage()); + if (bundle.getState() == Bundle.ACTIVE || !message.contains("org.owasp.encoder")) { + throw new AssertionError("Unexpected failure: " + message, expected); + } + System.out.println("Rejected as expected: " + message); + return; + } + throw new AssertionError(bundle.getSymbolicName() + " wired to an unsupported core"); + } bundle.start(); if (bundle.getState() != Bundle.ACTIVE) throw new AssertionError(bundle); if (i == args.length - 1) { diff --git a/compatibility/src/TagConsumer.java b/compatibility/src/TagConsumer.java index 3658b56..d025110 100644 --- a/compatibility/src/TagConsumer.java +++ b/compatibility/src/TagConsumer.java @@ -44,6 +44,7 @@ import javax.servlet.jsp.el.ExpressionEvaluator; import javax.servlet.jsp.el.VariableResolver; import org.owasp.encoder.tag.ForHtmlTag; +import org.owasp.encoder.tag.ForJsonTag; public final class TagConsumer { public static void main(String[] args) throws Exception { @@ -54,6 +55,17 @@ public static void main(String[] args) throws Exception { tag.setJspContext(new TestJspContext(writer)); tag.doTag(); Checks.encoded(writer.getContentAsString()); + + // ForJsonTag calls Encode.forJson, added in 1.5; this proves that linkage. + TestJspWriter jsonWriter = new TestJspWriter(); + ForJsonTag json = new ForJsonTag(); + json.setValue("'"); + json.setJspContext(new TestJspContext(jsonWriter)); + json.doTag(); + String expected = "\\u003c/script\\u003e'"; + if (!expected.equals(jsonWriter.getContentAsString())) { + throw new AssertionError(jsonWriter.getContentAsString()); + } } /** Minimal JSP context used by tags that only write to {@link #getOut()}. */ diff --git a/compatibility/tests/test_guards.py b/compatibility/tests/test_guards.py index e545932..83acd34 100644 --- a/compatibility/tests/test_guards.py +++ b/compatibility/tests/test_guards.py @@ -93,6 +93,31 @@ def mutate(entries): entries[name] = entries[name].replace(b'provided', b'test') self.rejected('jsp', mutate) + @staticmethod + def header(entries, key, change): + """Rewrite one manifest header; bnd folds long lines, so patching bytes is unreliable.""" + name = 'META-INF/MANIFEST.MF' + text = entries[name].decode().replace('\r\n', '\n').replace('\n ', '') + lines = [key + ': ' + change(line.split(': ', 1)[1]) if line.startswith(key + ': ') else line + for line in text.split('\n')] + entries[name] = '\r\n'.join(lines).encode() + + def test_unversioned_core_import(self): + self.rejected('jsp', lambda entries: self.header( + entries, 'Import-Package', lambda value: value.replace('org.owasp.encoder;version="[1.5,2)"', 'org.owasp.encoder'))) + + def test_lowered_core_floor(self): + self.rejected('esapi', lambda entries: self.header( + entries, 'Import-Package', lambda value: value.replace('[1.4.1,2)', '[1.4,2)'))) + + def test_widened_jakarta_pages_range(self): + self.rejected('jakarta', lambda entries: self.header( + entries, 'Import-Package', lambda value: value.replace('[3.0,4)', '[3.0,5)'))) + + def test_changed_symbolic_name(self): + self.rejected('jakarta', lambda entries: self.header( + entries, 'Bundle-SymbolicName', lambda value: 'org.owasp.encoder.jakarta')) + if __name__ == '__main__': unittest.main() diff --git a/core/pom.xml b/core/pom.xml index 2efdaee..7893375 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -58,6 +58,7 @@ org.owasp.encoder + org.owasp.encoder diff --git a/esapi/pom.xml b/esapi/pom.xml index 2e5287f..daa2f32 100644 --- a/esapi/pom.xml +++ b/esapi/pom.xml @@ -58,6 +58,15 @@ 2.7.0.0 org.owasp.encoder.esapi + org.owasp.encoder.esapi + + + org.owasp.encoder;version="[1.4.1,2)", + * + diff --git a/jakarta/pom.xml b/jakarta/pom.xml index b73968f..dba37d1 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -57,6 +57,15 @@ org.owasp.encoder.jakarta + org.owasp.encoder.jakarta-jsp + + + org.owasp.encoder;version="[1.5,2)", + jakarta.servlet.jsp;version="[3.0,4)", + jakarta.servlet.jsp.tagext;version="[3.0,4)", + * + diff --git a/jsp/pom.xml b/jsp/pom.xml index cfc6ebe..8b28403 100644 --- a/jsp/pom.xml +++ b/jsp/pom.xml @@ -57,6 +57,15 @@ org.owasp.encoder.jsp + org.owasp.encoder.jsp + + + org.owasp.encoder;version="[1.5,2)", + javax.servlet.jsp;version="[2.0,3)", + javax.servlet.jsp.tagext;version="[2.0,3)", + * + diff --git a/pom.xml b/pom.xml index 08f62b1..c2eace3 100755 --- a/pom.xml +++ b/pom.xml @@ -131,6 +131,9 @@ UTF-8 UTF-8 + + + * @@ -356,6 +359,11 @@ ${jigsaw.module.name} !META-INF.versions.*,* + + ${osgi.symbolic.name} + + ${osgi.import.package}