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 00274eedfaf5e20b0160f4267271182646884170 Author: Robert Lazarski <[email protected]> AuthorDate: Fri Sep 4 15:39:41 2026 -1000 Pin the Swagger UI scripts, and stop naming exception classes in faults Two low-severity leaks on the JSON surface. The Swagger UI page loads its scripts from a CDN at a pinned version, which says which release to request but not that what arrived is that release, on a page built to drive authenticated API calls; the tags now carry SHA-384 integrity hashes, taken from two independent CDNs and confirmed identical. They are version-specific, so a deployment pinning another version supplies its own and is warned when it has not. Separately, an unhandled Throwable produced a fault reason of fully.qualified.Class: message regardless of sendStacktraceDetailsWithFaults, on JSON and REST responses as much as SOAP faults, since that catch-all serves every request the servlet handles; it is generic now, and still logged in full. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- .../apache/axis2/openapi/OpenApiConfiguration.java | 24 +++++ .../org/apache/axis2/openapi/SwaggerUIHandler.java | 70 +++++++++++++- .../axis2/openapi/SwaggerUIIntegrityTest.java | 106 +++++++++++++++++++++ .../apache/axis2/transport/http/AxisServlet.java | 28 +++++- src/site/markdown/release-notes/2.0.2.md | 16 ++++ 5 files changed, 238 insertions(+), 6 deletions(-) diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java index a9e4ad1cea..8f0787776c 100644 --- a/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java +++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiConfiguration.java @@ -144,6 +144,14 @@ public class OpenApiConfiguration { /** Swagger UI version to use */ private String swaggerUiVersion = "5.32.13"; + /** + * Subresource Integrity hashes for the Swagger UI scripts, when the deployment + * pins a version other than the one this release ships hashes for. A version + * pin alone says which release to fetch, not that what arrived is that release. + */ + private String swaggerUiBundleIntegrity; + private String swaggerUiStandalonePresetIntegrity; + /** Maven group and artifact for Swagger UI */ private String swaggerUiMavenGroupAndArtifact = "org.webjars:swagger-ui"; @@ -267,6 +275,10 @@ public class OpenApiConfiguration { // Swagger UI swaggerUiVersion = getProperty(props, "openapi.swaggerUi.version", swaggerUiVersion); + swaggerUiBundleIntegrity = getProperty(props, + "openapi.swaggerUi.integrity.bundle", swaggerUiBundleIntegrity); + swaggerUiStandalonePresetIntegrity = getProperty(props, + "openapi.swaggerUi.integrity.standalonePreset", swaggerUiStandalonePresetIntegrity); // Resource packages (comma-separated) String packages = getProperty(props, "openapi.resourcePackages", null); @@ -428,6 +440,16 @@ public class OpenApiConfiguration { public String getSwaggerUiVersion() { return swaggerUiVersion; } public void setSwaggerUiVersion(String swaggerUiVersion) { this.swaggerUiVersion = swaggerUiVersion; } + public String getSwaggerUiBundleIntegrity() { return swaggerUiBundleIntegrity; } + public void setSwaggerUiBundleIntegrity(String swaggerUiBundleIntegrity) { + this.swaggerUiBundleIntegrity = swaggerUiBundleIntegrity; + } + + public String getSwaggerUiStandalonePresetIntegrity() { return swaggerUiStandalonePresetIntegrity; } + public void setSwaggerUiStandalonePresetIntegrity(String swaggerUiStandalonePresetIntegrity) { + this.swaggerUiStandalonePresetIntegrity = swaggerUiStandalonePresetIntegrity; + } + public String getSwaggerUiMavenGroupAndArtifact() { return swaggerUiMavenGroupAndArtifact; } public void setSwaggerUiMavenGroupAndArtifact(String swaggerUiMavenGroupAndArtifact) { this.swaggerUiMavenGroupAndArtifact = swaggerUiMavenGroupAndArtifact; @@ -523,6 +545,8 @@ public class OpenApiConfiguration { copy.scannerClass = this.scannerClass; copy.supportSwaggerUi = this.supportSwaggerUi; copy.swaggerUiVersion = this.swaggerUiVersion; + copy.swaggerUiBundleIntegrity = this.swaggerUiBundleIntegrity; + copy.swaggerUiStandalonePresetIntegrity = this.swaggerUiStandalonePresetIntegrity; copy.swaggerUiMavenGroupAndArtifact = this.swaggerUiMavenGroupAndArtifact; copy.configLocation = this.configLocation; copy.propertiesLocation = this.propertiesLocation; diff --git a/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java b/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java index 428c8cd1bd..80b853e538 100644 --- a/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java +++ b/modules/openapi/src/main/java/org/apache/axis2/openapi/SwaggerUIHandler.java @@ -63,6 +63,25 @@ public class SwaggerUIHandler { // Default Swagger UI version (can be overridden by configuration) private static final String DEFAULT_SWAGGER_UI_VERSION = "5.32.13"; + /** + * Subresource Integrity hashes for {@link #DEFAULT_SWAGGER_UI_VERSION}. + * <p> + * Pinning the version in the URL says which release to ask the CDN for. It does + * not say that what came back is that release: the page is built to drive + * authenticated API calls, so a CDN serving modified script runs it with the + * reader's session. These are SHA-384 digests of the published 5.32.13 files, + * taken from two independent CDNs and confirmed byte-identical. + * <p> + * They are version-specific. A deployment pinning a different version supplies + * its own with {@code openapi.swaggerUi.integrity.bundle} and + * {@code openapi.swaggerUi.integrity.standalonePreset}; without them the page + * still renders, and a warning says integrity is not being enforced. + */ + private static final String DEFAULT_BUNDLE_INTEGRITY = + "sha384-PsJla434CobCNv3y1K4wRavOqkUAvwGEQEfbUmI98CCqqGCJsmuDsgIjM6ZQQODP"; + private static final String DEFAULT_STANDALONE_PRESET_INTEGRITY = + "sha384-IxiIENmaFuZnXUo3BucU89aVUoQ3Z6o2mk+SzTkxlsiYX23mun5v8eb2wbhsfEr8"; + // Default resource paths private static final String SWAGGER_UI_ROOT = "/swagger-ui/"; private static final String API_DOCS_PATH = "/api-docs/"; @@ -330,9 +349,17 @@ public class SwaggerUIHandler { // Add Swagger UI scripts html.append(" <script src=\"https://unpkg.com/swagger-ui-dist@") - .append(swaggerUiVersion).append("/swagger-ui-bundle.js\"></script>\n") + .append(swaggerUiVersion).append("/swagger-ui-bundle.js\"") + .append(integrityAttributes(swaggerUiVersion, + configuration.getSwaggerUiBundleIntegrity(), + DEFAULT_BUNDLE_INTEGRITY, "swagger-ui-bundle.js")) + .append("></script>\n") .append(" <script src=\"https://unpkg.com/swagger-ui-dist@") - .append(swaggerUiVersion).append("/swagger-ui-standalone-preset.js\"></script>\n"); + .append(swaggerUiVersion).append("/swagger-ui-standalone-preset.js\"") + .append(integrityAttributes(swaggerUiVersion, + configuration.getSwaggerUiStandalonePresetIntegrity(), + DEFAULT_STANDALONE_PRESET_INTEGRITY, "swagger-ui-standalone-preset.js")) + .append("></script>\n"); // Add Swagger UI initialization script html.append(generateSwaggerUIScript(openApiUrl, scriptNonce)); @@ -489,12 +516,45 @@ public class SwaggerUIHandler { return Base64.getEncoder().encodeToString(nonce); } + /** + * Builds the integrity and crossorigin attributes for a CDN script tag. + * + * @param version the Swagger UI version being requested + * @param configured an operator-supplied hash, used whatever the version + * @param shipped the hash this release knows, valid only for the default version + * @param fileName named in the warning when no hash applies + * @return the attributes to add, or an empty string when no hash is known + */ + private String integrityAttributes(String version, String configured, + String shipped, String fileName) { + String integrity = null; + if (configured != null && !configured.trim().isEmpty()) { + integrity = configured.trim(); + } else if (DEFAULT_SWAGGER_UI_VERSION.equals(version)) { + integrity = shipped; + } + if (integrity == null) { + // Not fatal: refusing to render would turn a supply-chain hardening + // into an outage for anyone who pinned a different version. + log.warn("Serving " + fileName + " for Swagger UI version " + version + + " without Subresource Integrity: this release ships a hash for " + + DEFAULT_SWAGGER_UI_VERSION + " only. Set" + + " openapi.swaggerUi.integrity.bundle and" + + " openapi.swaggerUi.integrity.standalonePreset to pin this version."); + return ""; + } + // crossorigin is required for the browser to check integrity cross-origin. + return " integrity=\"" + integrity + "\" crossorigin=\"anonymous\""; + } + /** * Apply a Content-Security-Policy to the Swagger UI page. * - * <p>Only the nonced initialisation script and the pinned Swagger UI - * distribution may execute, so script injected anywhere into this page does - * not run even if an encoding defect is reintroduced. Operator-configured + * <p>Only the nonced initialisation script and the Swagger UI distribution may + * execute, so script injected anywhere into this page does not run even if an + * encoding defect is reintroduced. The policy names the CDN origin, which says + * where script may come from but not what it is; that is what the Subresource + * Integrity hashes on the script tags are for. Operator-configured * custom CSS and JavaScript origins are added to the policy so that * customised deployments keep working. */ diff --git a/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIIntegrityTest.java b/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIIntegrityTest.java new file mode 100644 index 0000000000..6f54fdb27e --- /dev/null +++ b/modules/openapi/src/test/java/org/apache/axis2/openapi/SwaggerUIIntegrityTest.java @@ -0,0 +1,106 @@ +/* + * 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.openapi; + +import java.lang.reflect.Method; + +import org.apache.axis2.context.ConfigurationContext; +import org.apache.axis2.context.ConfigurationContextFactory; + +import junit.framework.TestCase; + +/** + * The Swagger UI page is built to drive authenticated API calls, and its scripts come + * from a CDN. Pinning the version in the URL says which release to ask for, not that + * what came back is that release, so the script tags carry Subresource Integrity + * hashes. They are version-specific: a deployment pinning a different version has to + * supply its own, and the page still renders without them rather than going down. + */ +public class SwaggerUIIntegrityTest extends TestCase { + + private String attributesFor(OpenApiConfiguration configuration, String version, + String configured, String shipped) throws Exception { + ConfigurationContext configContext = + ConfigurationContextFactory.createEmptyConfigurationContext(); + SwaggerUIHandler handler = new SwaggerUIHandler(configContext, configuration); + Method method = SwaggerUIHandler.class.getDeclaredMethod("integrityAttributes", + String.class, String.class, String.class, String.class); + method.setAccessible(true); + return (String) method.invoke(handler, version, configured, shipped, + "swagger-ui-bundle.js"); + } + + public void testTheShippedHashIsUsedForTheDefaultVersion() throws Exception { + OpenApiConfiguration configuration = new OpenApiConfiguration(); + String attributes = attributesFor(configuration, + configuration.getSwaggerUiVersion(), null, "sha384-SHIPPED"); + assertTrue("the shipped hash should be applied, was: " + attributes, + attributes.contains("integrity=\"sha384-SHIPPED\"")); + assertTrue("the browser only checks integrity cross-origin with this", + attributes.contains("crossorigin=\"anonymous\"")); + } + + /** A hash for one version says nothing about another, so it must not be reused. */ + public void testTheShippedHashIsNotAppliedToAnotherVersion() throws Exception { + OpenApiConfiguration configuration = new OpenApiConfiguration(); + String attributes = attributesFor(configuration, "4.15.5", null, "sha384-SHIPPED"); + assertEquals("a hash from a different version would just break the page", + "", attributes); + } + + /** Which is why an operator pinning a version can supply their own. */ + public void testAnOperatorSuppliedHashIsUsedForAnyVersion() throws Exception { + OpenApiConfiguration configuration = new OpenApiConfiguration(); + String attributes = + attributesFor(configuration, "4.15.5", "sha384-OPERATOR", "sha384-SHIPPED"); + assertTrue("was: " + attributes, attributes.contains("integrity=\"sha384-OPERATOR\"")); + assertTrue(attributes.contains("crossorigin=\"anonymous\"")); + } + + public void testAnOperatorHashWinsOverTheShippedOne() throws Exception { + OpenApiConfiguration configuration = new OpenApiConfiguration(); + String attributes = attributesFor(configuration, + configuration.getSwaggerUiVersion(), "sha384-OPERATOR", "sha384-SHIPPED"); + assertTrue(attributes.contains("sha384-OPERATOR")); + assertFalse(attributes.contains("sha384-SHIPPED")); + } + + public void testABlankOperatorHashFallsBackRatherThanEmittingNothing() throws Exception { + OpenApiConfiguration configuration = new OpenApiConfiguration(); + String attributes = attributesFor(configuration, + configuration.getSwaggerUiVersion(), " ", "sha384-SHIPPED"); + assertTrue(attributes.contains("sha384-SHIPPED")); + } + + /** The hashes shipped for the default version must actually be present. */ + public void testTheDefaultVersionShipsRealHashes() throws Exception { + OpenApiConfiguration configuration = new OpenApiConfiguration(); + String attributes = attributesFor(configuration, + configuration.getSwaggerUiVersion(), null, + readShippedHash("DEFAULT_BUNDLE_INTEGRITY")); + assertTrue("a sha384 SRI hash is expected, was: " + attributes, + attributes.contains("integrity=\"sha384-")); + } + + private String readShippedHash(String fieldName) throws Exception { + java.lang.reflect.Field field = SwaggerUIHandler.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (String) field.get(null); + } +} diff --git a/modules/transport/http/src/main/java/org/apache/axis2/transport/http/AxisServlet.java b/modules/transport/http/src/main/java/org/apache/axis2/transport/http/AxisServlet.java index 7faf7b5ebf..c34bc13f2c 100644 --- a/modules/transport/http/src/main/java/org/apache/axis2/transport/http/AxisServlet.java +++ b/modules/transport/http/src/main/java/org/apache/axis2/transport/http/AxisServlet.java @@ -241,7 +241,8 @@ public class AxisServlet extends HttpServlet { } } } - handleFault(msgContext, out, new AxisFault(t.toString(), t)); + handleFault(msgContext, out, + new AxisFault(unexpectedErrorReason(msgContext, t), t)); } catch (AxisFault e2) { log.info(e2); throw new ServletException(e2); @@ -483,6 +484,31 @@ public class AxisServlet extends HttpServlet { } } + /** + * The fault reason sent to the caller for an otherwise unhandled Throwable. + * <p> + * {@code Throwable.toString()} is {@code fully.qualified.Class: message}, and the + * reason goes to the client, so the framework's internals were disclosed whatever + * {@code sendStacktraceDetailsWithFaults} said. This catch-all serves every + * request the servlet handles, so that applied to JSON and REST responses as much + * as to SOAP faults. The Throwable is logged with its stack above either way, so + * a generic reason costs the operator nothing. + * + * @param msgContext the message being answered, may be null + * @param t the unhandled error + * @return the class name and message when details are enabled, otherwise generic + */ + private String unexpectedErrorReason(MessageContext msgContext, Throwable t) { + if (msgContext != null) { + Parameter param = msgContext.getParameter( + Constants.Configuration.SEND_STACKTRACE_DETAILS_WITH_FAULTS); + if (param != null && JavaUtils.isTrue(param.getValue())) { + return t.toString(); + } + } + return "Internal server error"; + } + protected void handleFault(MessageContext msgContext, OutputStream out, AxisFault e) throws AxisFault { msgContext.setProperty(MessageContext.TRANSPORT_OUT, out); diff --git a/src/site/markdown/release-notes/2.0.2.md b/src/site/markdown/release-notes/2.0.2.md index 0dab023f84..2a2e0f835e 100644 --- a/src/site/markdown/release-notes/2.0.2.md +++ b/src/site/markdown/release-notes/2.0.2.md @@ -148,6 +148,22 @@ in `SECURITY.md`. from the network; if you persist or replicate contexts, restrict the stream you own. +- **Swagger UI scripts are integrity-pinned.** The page loads swagger-ui from a CDN + at a pinned version, which says which release to ask for but not that what came + back is that release -- and the page is built to drive authenticated API calls. The + script tags now carry Subresource Integrity hashes and `crossorigin="anonymous"`. + The hashes are version-specific, so a deployment pinning a different Swagger UI + version supplies its own with `openapi.swaggerUi.integrity.bundle` and + `openapi.swaggerUi.integrity.standalonePreset`; without them the page still renders + and a warning says integrity is not being enforced. + +- **Unexpected-error faults no longer name the exception class.** An otherwise + unhandled error produced a fault reason of `fully.qualified.Class: message` + whatever `sendStacktraceDetailsWithFaults` was set to, and since that catch-all + serves every request the servlet handles, it applied to JSON and REST responses as + much as to SOAP faults. The reason is now generic unless details are enabled. The + error is logged with its stack as before. + - **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
