This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/resource-bundle-fix in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
commit 924cf745705c848411d293b709f32577c034e074 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Wed Sep 23 07:53:47 2026 +0100 WSS4J messages now render correctly even if Santuario is never initialised at all. --- .../wss4j/common/ext/WSSecurityException.java | 68 +++++++++++ .../common/crypto/MerlinAKIKeyIdentifierTest.java | 14 ++- .../common/ext/WSSecurityExceptionMessageTest.java | 132 +++++++++++++++++++++ 3 files changed, 212 insertions(+), 2 deletions(-) diff --git a/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java b/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java index a837dbc48..d2f6d3f94 100644 --- a/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java +++ b/ws-security-common/src/main/java/org/apache/wss4j/common/ext/WSSecurityException.java @@ -23,6 +23,10 @@ import org.apache.xml.security.exceptions.XMLSecurityException; import javax.xml.namespace.QName; +import java.text.MessageFormat; +import java.util.MissingResourceException; +import java.util.ResourceBundle; + /** * Exception class for WS-Security. */ @@ -126,8 +130,59 @@ public class WSSecurityException extends XMLSecurityException { } } + /** + * The WSS4J message bundle, resolved directly rather than through Santuario's I18n. + * <p/> + * I18n holds a single static bundle for the whole JVM and both of its init methods return + * early once it has been set, so whichever library initialises it first owns it for the + * lifetime of the JVM. WSProviderConfig installs {@code WSS4JResourceBundle}, which knows + * both WSS4J's message ids and Santuario's, but it only gets the chance if nothing has + * initialised Santuario before it. When it loses that race - an application that uses XML + * Signature or OpenSAML before it uses WSS4J, for instance - none of the ids in + * wss4j_errors.properties resolve any more, and every WSS4J error degrades to + * 'No message with ID "..." found in resource bundle "..."'. + * <p/> + * Resolving our own ids ourselves makes the text independent of who won that race. Ids we + * do not own are left to I18n, so Santuario's messages keep working as before. + */ + private static final ResourceBundle WSS4J_MESSAGES = loadMessages(); + + private static ResourceBundle loadMessages() { + try { + return ResourceBundle.getBundle("messages.wss4j_errors"); + } catch (Exception ex) { //NOPMD + // Should not happen, the bundle ships with this jar. Deliberately broad: this runs in + // a static initializer of the exception class every WSS4J failure path goes through, + // so anything thrown here would surface as an ExceptionInInitializerError in place of + // the real error. Falling back to I18n is always safe. + return null; + } + } + + /** + * Format the message for a WSS4J message id, or null if the id is not one of ours, in which + * case the message that XMLSecurityException already derived via I18n is used instead. + */ + private static String formatMessage(String msgId, Object... arguments) { + if (WSS4J_MESSAGES == null || msgId == null) { + return null; + } + try { + return MessageFormat.format(WSS4J_MESSAGES.getString(msgId), arguments); + } catch (MissingResourceException ex) { //NOPMD + // Not a WSS4J message id - it is one of Santuario's, so leave it to I18n + return null; + } + } + private ErrorCode errorCode; + /** + * The message resolved from WSS4J's own bundle, or null when the message id is not one of + * WSS4J's. Never read directly, see {@link #getMessage()}. + */ + private final String wss4jMessage; + public WSSecurityException(ErrorCode errorCode) { this(errorCode, errorCode.name()); } @@ -135,26 +190,39 @@ public class WSSecurityException extends XMLSecurityException { public WSSecurityException(ErrorCode errorCode, String msgId) { super(msgId, new Object[]{}); this.errorCode = errorCode; + this.wss4jMessage = formatMessage(msgId); } public WSSecurityException(ErrorCode errorCode, Exception exception) { super(exception); this.errorCode = errorCode; + // No message id involved, the message is the wrapped exception's own + this.wss4jMessage = null; } public WSSecurityException(ErrorCode errorCode, Exception exception, String msgId) { super(exception, msgId); this.errorCode = errorCode; + // Mirrors I18n.getExceptionMessage(String, Exception), which formats the wrapped + // exception's message as the single argument + this.wss4jMessage = formatMessage(msgId, exception.getMessage()); } public WSSecurityException(ErrorCode errorCode, Exception exception, String msgId, Object[] arguments) { super(exception, msgId, arguments); this.errorCode = errorCode; + this.wss4jMessage = formatMessage(msgId, arguments); } public WSSecurityException(ErrorCode errorCode, String msgId, Object[] arguments) { super(msgId, arguments); this.errorCode = errorCode; + this.wss4jMessage = formatMessage(msgId, arguments); + } + + @Override + public String getMessage() { + return wss4jMessage != null ? wss4jMessage : super.getMessage(); } /** diff --git a/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java b/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java index f10d8b9d3..2bb1480ba 100644 --- a/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java +++ b/ws-security-common/src/test/java/org/apache/wss4j/common/crypto/MerlinAKIKeyIdentifierTest.java @@ -28,10 +28,10 @@ import org.apache.wss4j.common.util.Loader; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; /** * MerlinAKI finds the issuing certificate by matching the received certificate's @@ -77,7 +77,17 @@ public class MerlinAKIKeyIdentifierTest { // path validation - which would arrive here wrapping a CertPathValidatorException. assertNull(ex.getCause(), "Expected no issuer to be selected, but one was and it failed " + "path validation: " + ex.getMessage()); - assertTrue(ex.getMessage().contains("No trusted certs found"), ex.getMessage()); + assertEquals(WSSecurityException.ErrorCode.FAILURE, ex.getErrorCode()); + + // Deliberately asserted on the message ID rather than on the formatted message. The text + // is rendered through Santuario's global I18n bundle, which is whichever bundle is + // installed first in the JVM and never replaced afterwards. If anything initialises + // Santuario before WSProviderConfig.init() installs WSS4JResourceBundle, "certpath" stops + // resolving and every WSS4J message degrades to "No message with ID ... found in resource + // bundle ...". That is a property of the JVM the test happens to share, not of the code + // under test, and it made this assertion fail under a full module test run while passing + // when the class was run on its own. The message ID is stable either way. + assertEquals("certpath", ex.getMsgID()); } private static X509Certificate getCertificate(String keyStoreFile, String password, String alias) diff --git a/ws-security-common/src/test/java/org/apache/wss4j/common/ext/WSSecurityExceptionMessageTest.java b/ws-security-common/src/test/java/org/apache/wss4j/common/ext/WSSecurityExceptionMessageTest.java new file mode 100644 index 000000000..3e7182c33 --- /dev/null +++ b/ws-security-common/src/test/java/org/apache/wss4j/common/ext/WSSecurityExceptionMessageTest.java @@ -0,0 +1,132 @@ +/** + * 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.wss4j.common.ext; + +import java.lang.reflect.Field; +import java.util.Locale; +import java.util.ResourceBundle; + +import org.apache.wss4j.common.crypto.WSProviderConfig; +import org.apache.xml.security.utils.I18n; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Santuario's I18n holds one static resource bundle for the whole JVM and ignores every attempt + * to set it after the first, so the bundle WSS4J error messages are rendered from depends on + * whether WSProviderConfig ran before anything else initialised Santuario. WSS4J message ids must + * resolve either way. + */ +public class WSSecurityExceptionMessageTest { + + @BeforeAll + public static void setup() throws Exception { + WSProviderConfig.init(); + } + + /** + * The case that used to degrade: Santuario was initialised first, so I18n holds the + * xmlsecurity bundle and none of WSS4J's own message ids are in it. The message used to come + * out as 'No message with ID "certpath" found in resource bundle "..."'. + */ + @Test + public void testWSS4JMessageIdResolvesWhenSantuarioOwnsTheBundle() throws Exception { + ResourceBundle previous = installXMLSecBundle(); + try { + WSSecurityException ex = new WSSecurityException( + WSSecurityException.ErrorCode.FAILURE, "certpath", new Object[] {"No trusted certs found"}); + + assertEquals("Error during certificate path validation: No trusted certs found", ex.getMessage()); + } finally { + restoreBundle(previous); + } + } + + /** + * A message id with no arguments, taken from the WSS4J bundle rather than formatted from it. + */ + @Test + public void testArgumentLessWSS4JMessageIdResolvesWhenSantuarioOwnsTheBundle() throws Exception { + ResourceBundle previous = installXMLSecBundle(); + try { + WSSecurityException ex = new WSSecurityException(WSSecurityException.ErrorCode.FAILURE); + + assertEquals("General security error", ex.getMessage()); + } finally { + restoreBundle(previous); + } + } + + /** + * WSS4J also throws with message ids that belong to Santuario, such as "empty". Those are not + * in wss4j_errors.properties and must keep being resolved by I18n. + */ + @Test + public void testSantuarioMessageIdIsLeftToI18n() throws Exception { + WSSecurityException ex = new WSSecurityException( + WSSecurityException.ErrorCode.FAILURE, "empty", new Object[] {"Attachment not found: cid:foo"}); + + assertEquals("Attachment not found: cid:foo", ex.getMessage()); + } + + /** + * The wrapped exception's message is the single argument, as I18n does it. + */ + @Test + public void testWrappedExceptionMessageIsUsedAsTheArgument() throws Exception { + ResourceBundle previous = installXMLSecBundle(); + try { + WSSecurityException ex = new WSSecurityException( + WSSecurityException.ErrorCode.FAILURE, new IllegalStateException("no path"), "certpath"); + + assertEquals("Error during certificate path validation: no path", ex.getMessage()); + } finally { + restoreBundle(previous); + } + } + + private static ResourceBundle installXMLSecBundle() throws Exception { + ResourceBundle xmlSecBundle = + ResourceBundle.getBundle("org/apache/xml/security/resource/xmlsecurity", Locale.US); + return swapBundle(xmlSecBundle); + } + + private static void restoreBundle(ResourceBundle previous) throws Exception { + swapBundle(previous); + } + + private static ResourceBundle swapBundle(ResourceBundle bundle) throws Exception { + Field field; + try { + field = I18n.class.getDeclaredField("resourceBundle"); + field.setAccessible(true); //NOPMD + } catch (NoSuchFieldException | RuntimeException ex) { + // I18n is not ours, so do not fail the build if it is reshaped or locked down + Assumptions.abort("Cannot reach Santuario's I18n resource bundle: " + ex.getMessage()); + return null; + } + ResourceBundle previous = (ResourceBundle)field.get(null); + field.set(null, bundle); + return previous; + } +}
