This is an automated email from the ASF dual-hosted git repository. robertlazarski pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/axis-axis2-java-core.git
commit 84811c7c87192cd4aa96b3443f195cba2384617f Author: Robert Lazarski <[email protected]> AuthorDate: Fri Sep 4 10:26:57 2026 -1000 Say what context deserialization actually enforces, and enforce that SECURITY.md promised three times that SafeObjectInputStream restricts deserialization to known Axis2 context classes. It never did, and it cannot: the feature carries application objects on purpose -- self-managed data, and Parameter values of any serializable type -- so a list of Axis2's own classes would refuse what it exists to carry. The streams Axis2 creates now refuse dynamic proxies, which it never writes and which front the classic gadget chains, and accept a JEP 290 pattern for an integrator who does know their types. The object-form path reads from the caller's own stream, which cannot take a filter by then; the docs now say so rather than implying otherwise. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- SECURITY.md | 37 ++++- .../externalize/ContextDeserializationFilter.java | 153 ++++++++++++++++++ .../context/externalize/SafeObjectInputStream.java | 8 +- .../ContextDeserializationFilterTest.java | 178 +++++++++++++++++++++ src/site/markdown/release-notes/2.0.2.md | 13 ++ 5 files changed, 383 insertions(+), 6 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 5a2825b04c..76475cfb4f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -138,7 +138,7 @@ Axis2 exposes the following URL patterns from the servlet mapping: | **Form-urlencoded builder** | Unbounded body read into an in-memory map | `formUrlEncodedMaxRequestSize` bounds the read; the stream fails rather than truncating | | **Service dispatchers** | Routing to unintended service; header spoofing; a service chosen from message content after the Security phase has already run | Dispatchers validate service existence and unknown services return fault; selecting the service from the SOAP body namespace is off unless `allowContentBasedServiceDispatch` is set, so binding happens before the Security phase runs | | **Hot-deployment** (DeploymentEngine) | Malicious AAR/MAR deploys arbitrary code | Trust boundary is filesystem access; no signature verification (admin operation) | -| **Context externalization** (SafeObjectInputStream) | Java deserialization gadget chains | Whitelist-based `SafeObjectInputStream`; restricted to known Axis2 context classes | +| **Context externalization** (SafeObjectInputStream) | Java deserialization gadget chains | No class allowlist is possible: the feature exists to carry application objects (self-managed data, `Parameter` values). Streams Axis2 creates refuse dynamic proxies and honour `org.apache.axis2.context.externalize.serialFilter`; no Axis2 path feeds them from the network, and an integrator who persists or replicates contexts must restrict the stream they own | | **Metadata endpoints** (`?wsdl`, `?xsd`, `/services/`, `.xsd`/`.wsdl` by name, OpenAPI/MCP) | Service enumeration, schema disclosure | `exposeServiceMetadata` enforced uniformly across the servlet and standalone HTTP paths and the OpenAPI/MCP generators | | **WS-Addressing response endpoints** (`wsa:ReplyTo`, `wsa:FaultTo`) | SSRF: an inbound header names the destination of a server-initiated send | Non-anonymous response endpoints refused by default (`allowNonAnonymousResponseEndpoints`); when enabled, scheme restricted to HTTPS, destination screened at both the header-parsing and transport-selection layers, and redirects not followed | | **OpenAPI / Swagger UI surface** | Reflected XSS from request-controlled values; Host reflected into published URLs | Host validated, values encoded for their output context, CSP with a per-response script nonce; the published `servers[].url` is relative unless `openapi.serverBaseUrl` pins it | @@ -178,8 +178,11 @@ release 2.0.1. **Lesson:** Any `ObjectInputStream.readObject()` on network input is a critical-severity finding. The remaining use of Java serialization in -Axis2 is `SafeObjectInputStream` for context externalization, which uses -a class whitelist. +Axis2 is `SafeObjectInputStream` for context externalization. It has no +class allowlist and cannot have one: the data it carries includes +application objects by design. Nothing in Axis2 feeds it from the +network -- see item 6 below for what it does enforce, and for what an +integrator who persists contexts has to do themselves. ### 2. XML Parsing (XXE/SSRF) @@ -226,8 +229,32 @@ migration from `commons-fileupload` 1.x to `commons-fileupload2` in block HTTP/HTTPS/FTP/JAR/file scheme resolution to prevent SSRF via xmlschema-core's `DefaultURIResolver`. -4. **Deserialization whitelist:** `SafeObjectInputStream` restricts Java - object deserialization to known Axis2 context classes. +4. **Deserialization of externalized contexts (2.0.2):** There is no class + allowlist, and one cannot be shipped: context externalization exists to + carry application objects -- `SelfManagedDataHolder` holds whatever a + service put there, and a `Parameter` value may be any serializable + object -- so a list of Axis2's own context classes would refuse the data + the feature is for. Earlier revisions of this document claimed such an + allowlist; it never existed. What holds without knowing the + application's types: + + - The streams Axis2 creates refuse **dynamic proxies**. Axis2 never + writes one, and a proxy over an attacker-chosen invocation handler is + the entry point of the classic gadget chains. Override with + `org.apache.axis2.context.externalize.allowProxies=true`. + - `org.apache.axis2.context.externalize.serialFilter` takes a JEP 290 + pattern applied to those streams only, for an integrator who does know + their own types. An unparseable pattern, or one defining no filter, is + an error rather than a silent no-op. The JVM-wide `jdk.serialFilter` + applies as well. + - Not covered: where the writer chose object form, `SafeObjectInputStream` + calls `readObject()` on the `ObjectInput` its caller supplied. That + stream belongs to the caller and has already read objects by then, so no + filter can be installed on it. Restricting it is the caller's to do. + + No Axis2 code path feeds these streams from the network. The risk arrives + when an integrator persists or replicates contexts, and deserializing + attacker-influenced bytes stays dangerous whatever is configured here. 5. **Clustering removed:** The entire clustering module (Tribes-based inter-node communication with unvalidated deserialization) has been diff --git a/modules/kernel/src/org/apache/axis2/context/externalize/ContextDeserializationFilter.java b/modules/kernel/src/org/apache/axis2/context/externalize/ContextDeserializationFilter.java new file mode 100644 index 0000000000..f24b8a1fcb --- /dev/null +++ b/modules/kernel/src/org/apache/axis2/context/externalize/ContextDeserializationFilter.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.axis2.context.externalize; + +import java.io.ObjectInputFilter; +import java.io.ObjectInputStream; +import java.lang.reflect.Proxy; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * The deserialization filter applied to the object streams context externalization + * creates. + * <p> + * <b>What this can and cannot be.</b> Axis2 cannot ship a list of permitted classes + * for this data. Context externalization deliberately carries application objects: + * {@link org.apache.axis2.util.SelfManagedDataHolder} exists to hold whatever a + * service put there, and a {@link org.apache.axis2.description.Parameter} value can + * be any serializable object. A list restricted to Axis2's own context classes would + * refuse the data the feature is for. So the useful properties are the ones that hold + * without knowing the application's types: + * <ul> + * <li>dynamic proxies are refused. Axis2's own externalization never writes one, and + * a proxy backed by an attacker-chosen invocation handler is the entry point of + * the classic gadget chains. Set + * {@code org.apache.axis2.context.externalize.allowProxies=true} if an + * application genuinely serializes a proxy into its self-managed data.</li> + * <li>an integrator who knows their own types can name them: + * {@code org.apache.axis2.context.externalize.serialFilter} takes a JEP 290 + * pattern and is applied to these streams only, leaving the rest of the JVM + * alone. The JVM-wide {@code jdk.serialFilter} continues to apply as well.</li> + * </ul> + * <p> + * <b>What is not covered.</b> {@code SafeObjectInputStream} has a second path: + * where the writer chose object form, it calls {@code readObject()} on the + * {@code ObjectInput} its caller supplied. That stream belongs to the caller -- + * a container persisting a session, or an application's own code -- and by the time + * {@code readExternal} runs it has already read objects, so a filter cannot be + * installed on it ({@code setObjectInputFilter} rejects that). Restricting that path + * is the caller's to do, with {@code jdk.serialFilter} or a filter of their own. + * <p> + * Deserializing bytes an attacker can influence remains dangerous whatever is set + * here. Nothing in Axis2 feeds these streams from the network; the risk arrives when + * an integrator persists or replicates contexts. + */ +final class ContextDeserializationFilter implements ObjectInputFilter { + + /** A JEP 290 pattern applied to context deserialization only. */ + static final String SERIAL_FILTER_PROPERTY = + "org.apache.axis2.context.externalize.serialFilter"; + + /** Set true to permit dynamic proxies in externalized context data. */ + static final String ALLOW_PROXIES_PROPERTY = + "org.apache.axis2.context.externalize.allowProxies"; + + private static final Log log = LogFactory.getLog(ContextDeserializationFilter.class); + + private final ObjectInputFilter configured; + private final boolean allowProxies; + + private ContextDeserializationFilter(ObjectInputFilter configured, boolean allowProxies) { + this.configured = configured; + this.allowProxies = allowProxies; + } + + /** + * Installs the filter on a stream this package created. + * + * @param stream a freshly created stream, before anything has been read from it + */ + static void apply(ObjectInputStream stream) { + stream.setObjectInputFilter(create()); + } + + static ContextDeserializationFilter create() { + ObjectInputFilter configured = null; + String pattern = getProperty(SERIAL_FILTER_PROPERTY); + if (pattern != null && !pattern.trim().isEmpty()) { + try { + // NumberFormatException for a bad limit is an IllegalArgumentException. + configured = ObjectInputFilter.Config.createFilter(pattern.trim()); + } catch (IllegalArgumentException e) { + // Fail loudly on a pattern that will not compile: whoever set one + // meant to restrict something, and ignoring it silently would leave + // them believing a restriction is in force. + throw new IllegalArgumentException("The value of " + + SERIAL_FILTER_PROPERTY + " is not a valid filter pattern", e); + } + if (configured == null) { + // A degenerate pattern such as ";;" compiles to no filter at all, + // which reads as success but restricts nothing. Same reasoning. + throw new IllegalArgumentException("The value of " + + SERIAL_FILTER_PROPERTY + " defines no filter: " + pattern); + } + } + return new ContextDeserializationFilter(configured, + Boolean.parseBoolean(getProperty(ALLOW_PROXIES_PROPERTY))); + } + + public Status checkInput(FilterInfo filterInfo) { + Class<?> serialClass = filterInfo.serialClass(); + if (serialClass == null) { + // A stream-size, depth or reference count check rather than a class. + return delegate(filterInfo); + } + + Class<?> componentType = serialClass; + while (componentType.isArray()) { + componentType = componentType.getComponentType(); + } + if (!allowProxies && Proxy.isProxyClass(componentType)) { + if (log.isDebugEnabled()) { + log.debug("Refusing a dynamic proxy in externalized context data: " + + componentType.getName()); + } + return Status.REJECTED; + } + + return delegate(filterInfo); + } + + private Status delegate(FilterInfo filterInfo) { + if (configured != null) { + return configured.checkInput(filterInfo); + } + return Status.UNDECIDED; + } + + private static String getProperty(String name) { + try { + return System.getProperty(name); + } catch (SecurityException e) { + return null; + } + } +} diff --git a/modules/kernel/src/org/apache/axis2/context/externalize/SafeObjectInputStream.java b/modules/kernel/src/org/apache/axis2/context/externalize/SafeObjectInputStream.java index d2ad38745d..52c163f678 100644 --- a/modules/kernel/src/org/apache/axis2/context/externalize/SafeObjectInputStream.java +++ b/modules/kernel/src/org/apache/axis2/context/externalize/SafeObjectInputStream.java @@ -392,6 +392,12 @@ public class SafeObjectInputStream implements ObjectInput, ObjectStreamConstants private ObjectInputStream createObjectInputStream(InputStream is) throws IOException { // The created ObjectInputStream must use the same class/object resolution // code that is used by the original ObjectInput - return new ObjectInputStreamWithCL(is); + ObjectInputStreamWithCL stream = new ObjectInputStreamWithCL(is); + // Every byte-form read routes through here, so this is the one place a + // filter reaches each stream this class owns. See ContextDeserializationFilter + // for what it can enforce, and for why the object-form path -- readObject() + // on the ObjectInput the caller supplied -- is the caller's to restrict. + ContextDeserializationFilter.apply(stream); + return stream; } } diff --git a/modules/kernel/test/org/apache/axis2/context/externalize/ContextDeserializationFilterTest.java b/modules/kernel/test/org/apache/axis2/context/externalize/ContextDeserializationFilterTest.java new file mode 100644 index 0000000000..215d083545 --- /dev/null +++ b/modules/kernel/test/org/apache/axis2/context/externalize/ContextDeserializationFilterTest.java @@ -0,0 +1,178 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.axis2.context.externalize; + +import java.io.ObjectInputFilter; +import java.io.Serializable; +import java.lang.reflect.Proxy; +import java.util.HashMap; + +import junit.framework.TestCase; + +/** + * Context externalization carries application objects on purpose -- self-managed + * data and Parameter values can be anything serializable -- so Axis2 cannot ship a + * list of permitted classes for it. These tests pin what the filter does enforce + * regardless of the application's types, and that an integrator who does know their + * types can restrict them. + */ +public class ContextDeserializationFilterTest extends TestCase { + + private String savedFilter; + private String savedProxies; + + @Override + protected void setUp() throws Exception { + savedFilter = System.getProperty(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY); + savedProxies = System.getProperty(ContextDeserializationFilter.ALLOW_PROXIES_PROPERTY); + System.clearProperty(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY); + System.clearProperty(ContextDeserializationFilter.ALLOW_PROXIES_PROPERTY); + } + + @Override + protected void tearDown() throws Exception { + restore(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY, savedFilter); + restore(ContextDeserializationFilter.ALLOW_PROXIES_PROPERTY, savedProxies); + } + + private void restore(String name, String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + + /** The gadget-chain entry point Axis2 itself never writes. */ + public void testDynamicProxiesAreRefused() { + assertEquals(ObjectInputFilter.Status.REJECTED, + check(ContextDeserializationFilter.create(), proxyClass())); + } + + public void testProxyArraysAreRefusedToo() { + Class<?> proxyArray = java.lang.reflect.Array.newInstance(proxyClass(), 0).getClass(); + assertEquals(ObjectInputFilter.Status.REJECTED, + check(ContextDeserializationFilter.create(), proxyArray)); + } + + /** + * Application data has to keep working: this feature exists to carry it, so an + * ordinary serializable class is not the filter's business. + */ + public void testApplicationClassesAreNotRefused() { + ContextDeserializationFilter filter = ContextDeserializationFilter.create(); + assertEquals(ObjectInputFilter.Status.UNDECIDED, check(filter, HashMap.class)); + assertEquals(ObjectInputFilter.Status.UNDECIDED, check(filter, String.class)); + assertEquals(ObjectInputFilter.Status.UNDECIDED, + check(filter, ApplicationPayload.class)); + } + + /** An escape hatch, for an application that really does serialize a proxy. */ + public void testProxiesCanBeAllowedBack() { + System.setProperty(ContextDeserializationFilter.ALLOW_PROXIES_PROPERTY, "true"); + assertEquals(ObjectInputFilter.Status.UNDECIDED, + check(ContextDeserializationFilter.create(), proxyClass())); + } + + /** An integrator who knows their own types can name them. */ + public void testAConfiguredPatternIsEnforced() { + System.setProperty(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY, + "java.util.HashMap;!*"); + ContextDeserializationFilter filter = ContextDeserializationFilter.create(); + assertEquals(ObjectInputFilter.Status.ALLOWED, check(filter, HashMap.class)); + assertEquals(ObjectInputFilter.Status.REJECTED, + check(filter, ApplicationPayload.class)); + } + + /** + * A pattern that will not compile must not be ignored: whoever set it believes + * something is restricted. + */ + public void testAnUnparseablePatternIsRefusedLoudly() { + // An unknown limit, and a bad limit value: the second throws + // NumberFormatException, which is an IllegalArgumentException. + for (String bad : new String[] {"bogus=1", "maxdepth=abc", "maxdepth=-5"}) { + System.setProperty(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY, bad); + try { + ContextDeserializationFilter.create(); + fail("an invalid filter pattern must not be silently ignored: " + bad); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage() + .contains(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY)); + } + } + } + + /** + * A pattern that compiles to nothing reads as success while restricting nothing, + * which is the same trap as one that will not compile. + */ + public void testAPatternDefiningNoFilterIsRefusedLoudly() { + System.setProperty(ContextDeserializationFilter.SERIAL_FILTER_PROPERTY, ";;"); + try { + ContextDeserializationFilter.create(); + fail("a pattern defining no filter must not pass for a restriction"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("defines no filter")); + } + } + + private ObjectInputFilter.Status check(ObjectInputFilter filter, Class<?> serialClass) { + return filter.checkInput(new StubFilterInfo(serialClass)); + } + + private Class<?> proxyClass() { + return Proxy.getProxyClass(getClass().getClassLoader(), new Class[] {Runnable.class}); + } + + /** A stand-in for an application object in self-managed data. */ + private static class ApplicationPayload implements Serializable { + private static final long serialVersionUID = 1L; + } + + /** Reports one class; the counters are what a stream would supply. */ + private static class StubFilterInfo implements ObjectInputFilter.FilterInfo { + private final Class<?> serialClass; + + StubFilterInfo(Class<?> serialClass) { + this.serialClass = serialClass; + } + + public Class<?> serialClass() { + return serialClass; + } + + public long arrayLength() { + return -1; + } + + public long depth() { + return 1; + } + + public long references() { + return 1; + } + + public long streamBytes() { + return 1; + } + } + +} diff --git a/src/site/markdown/release-notes/2.0.2.md b/src/site/markdown/release-notes/2.0.2.md index 5fce9a269b..b1149f214a 100644 --- a/src/site/markdown/release-notes/2.0.2.md +++ b/src/site/markdown/release-notes/2.0.2.md @@ -110,6 +110,19 @@ in `SECURITY.md`. resolves schemas over the network passes `-asl` to the CLI or sets `<allowAbsoluteSchemaLocations>true</allowAbsoluteSchemaLocations>` on the plugin. +- **Context deserialization: a documented allowlist that never existed is now + described accurately, and what can be enforced is.** `SECURITY.md` claimed three + times that `SafeObjectInputStream` restricts deserialization to known Axis2 + context classes. It does not, and cannot: the feature exists to carry application + objects -- `SelfManagedDataHolder` holds whatever a service put there, and a + `Parameter` value may be any serializable object -- so such a list would refuse + the data it is for. The streams Axis2 creates now refuse **dynamic proxies** + (`org.apache.axis2.context.externalize.allowProxies=true` to override), and + `org.apache.axis2.context.externalize.serialFilter` accepts a JEP 290 pattern for + an integrator who does know their own types. No Axis2 path feeds these streams + from the network; if you persist or replicate contexts, restrict the stream you + own. + - **OpenAPI and Swagger UI output.** Request-controlled values are validated and encoded for the context they are written into, the served page carries a Content-Security-Policy with a per-response script nonce, and the published
