From 70a4f055814f02dd10ebde8a5fa59dd0c80526d9 Mon Sep 17 00:00:00 2001 From: Philip Helger Date: Wed, 30 Sep 2026 12:49:45 +0200 Subject: [PATCH] Added crypto verification; #404 --- .../phase4/config/AS4Configuration.java | 25 ++++ .../phase4/incoming/AS4IncomingHandler.java | 76 ++++++++-- .../phase4/config/AS4ConfigurationTest.java | 18 +++ .../UserMessageEnforcePModeSecurityTest.java | 138 ++++++++++++++++++ 4 files changed, 244 insertions(+), 13 deletions(-) create mode 100644 phase4-test/src/test/java/com/helger/phase4/server/message/UserMessageEnforcePModeSecurityTest.java diff --git a/phase4-lib/src/main/java/com/helger/phase4/config/AS4Configuration.java b/phase4-lib/src/main/java/com/helger/phase4/config/AS4Configuration.java index 2f8da5f5c..02ab49867 100644 --- a/phase4-lib/src/main/java/com/helger/phase4/config/AS4Configuration.java +++ b/phase4-lib/src/main/java/com/helger/phase4/config/AS4Configuration.java @@ -26,6 +26,7 @@ import com.helger.annotation.CheckForSigned; import com.helger.annotation.concurrent.GuardedBy; +import com.helger.annotation.misc.ChangeNextMajorRelease; import com.helger.base.CGlobal; import com.helger.base.concurrent.SimpleReadWriteLock; import com.helger.base.debug.GlobalDebug; @@ -156,6 +157,18 @@ public final class AS4Configuration public static final String PROPERTY_PHASE4_INCOMING_SIGNATURE_REQUIRE_FULL_COVERAGE = "phase4.incoming.signature.requirefullcoverage"; public static final boolean DEFAULT_PHASE4_INCOMING_SIGNATURE_REQUIRE_FULL_COVERAGE = true; + /** + * The boolean property that defines, if the security settings of the effective PMode leg are + * enforced on incoming UserMessages. If enabled, an incoming UserMessage must be signed, if the + * PMode leg defines a signature algorithm, and it must be encrypted, if the PMode leg defines an + * encryption algorithm. Signal messages are not affected. + * + * @since 4.8.0 + */ + public static final String PROPERTY_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE = "phase4.incoming.security.enforcepmode"; + @ChangeNextMajorRelease ("Swap to true") + public static final boolean DEFAULT_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE = false; + private static final Logger LOGGER = Phase4LoggerFactory.getLogger (AS4Configuration.class); /** @@ -565,4 +578,16 @@ public static boolean isIncomingSignatureRequireFullCoverage () return getConfig ().getAsBoolean (PROPERTY_PHASE4_INCOMING_SIGNATURE_REQUIRE_FULL_COVERAGE, DEFAULT_PHASE4_INCOMING_SIGNATURE_REQUIRE_FULL_COVERAGE); } + + /** + * @return true if incoming UserMessages must be signed and/or encrypted as defined + * by the security settings of the effective PMode leg, false if not. + * Defaults to {@value #DEFAULT_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE}. + * @since 4.8.0 + */ + public static boolean isIncomingSecurityEnforcePMode () + { + return getConfig ().getAsBoolean (PROPERTY_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE, + DEFAULT_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE); + } } diff --git a/phase4-lib/src/main/java/com/helger/phase4/incoming/AS4IncomingHandler.java b/phase4-lib/src/main/java/com/helger/phase4/incoming/AS4IncomingHandler.java index 3efe405b5..5f889c5b8 100644 --- a/phase4-lib/src/main/java/com/helger/phase4/incoming/AS4IncomingHandler.java +++ b/phase4-lib/src/main/java/com/helger/phase4/incoming/AS4IncomingHandler.java @@ -98,6 +98,7 @@ import com.helger.phase4.model.message.MessageHelperMethods; import com.helger.phase4.model.pmode.IPMode; import com.helger.phase4.model.pmode.leg.PModeLeg; +import com.helger.phase4.model.pmode.leg.PModeLegSecurity; import com.helger.phase4.model.pmode.resolve.IAS4PModeResolver; import com.helger.phase4.profile.IAS4Profile; import com.helger.phase4.profile.IAS4ProfileValidator; @@ -155,7 +156,9 @@ public interface IAS4ParsedMessageCallback void handle (@NonNull HttpHeaderMap aHttpHeaders, @NonNull Document aSoapDocument, @NonNull ESoapVersion eSoapVersion, - @NonNull ICommonsList aIncomingAttachments) throws WSSecurityException, MessagingException, Phase4Exception; + @NonNull ICommonsList aIncomingAttachments) throws WSSecurityException, + MessagingException, + Phase4Exception; } private static final Logger LOGGER = Phase4LoggerFactory.getLogger (AS4IncomingHandler.class); @@ -179,7 +182,10 @@ public static void parseAS4Message (@NonNull final IAS4IncomingAttachmentFactory @NonNull @WillClose final InputStream aPayloadIS, @NonNull final HttpHeaderMap aHttpHeaders, @NonNull final IAS4ParsedMessageCallback aParsedMessageCallback, - @Nullable final IAS4IncomingDumper aIncomingDumper) throws Phase4Exception, IOException, MessagingException, WSSecurityException + @Nullable final IAS4IncomingDumper aIncomingDumper) throws Phase4Exception, + IOException, + MessagingException, + WSSecurityException { ValueEnforcer.notNull (aIAF, "IncomingAttachmentFactory"); ValueEnforcer.notNull (aResHelper, "ResHelper"); @@ -207,8 +213,8 @@ public static void parseAS4Message (@NonNull final IAS4IncomingAttachmentFactory } // Fallback to global dumper if none is provided - final IAS4IncomingDumper aRealIncomingDumper = aIncomingDumper != null ? aIncomingDumper - : AS4DumpManager.getIncomingDumper (); + final IAS4IncomingDumper aRealIncomingDumper = aIncomingDumper != null ? aIncomingDumper : AS4DumpManager + .getIncomingDumper (); Document aSoapDocument = null; ESoapVersion eSoapVersion = null; final ICommonsList aIncomingAttachments = new CommonsArrayList <> (); @@ -293,11 +299,10 @@ public static void parseAS4Message (@NonNull final IAS4IncomingAttachmentFactory try (final MultipartItemInputStream aBodyPartIS = aMulti.createInputStream ()) { // Limit the size of a single attachment (see issue #318) - final InputStream aPartIS = nIndex == 0 ? aBodyPartIS - : new AS4SizeLimitedInputStream (aBodyPartIS, - "The incoming attachment #" + - nIndex, - nMaxAttachmentSizeBytes); + final InputStream aPartIS = nIndex == 0 ? aBodyPartIS : new AS4SizeLimitedInputStream (aBodyPartIS, + "The incoming attachment #" + + nIndex, + nMaxAttachmentSizeBytes); // Read the headers only - the content stays on the stream and is // consumed in a streaming way (see issue #382) @@ -751,10 +756,9 @@ private static void _checkSignatureCoverage (@NonNull final Document aSoapDocume final Element aSoapHeader = XMLHelper.getFirstChildElementOfName (aSoapEnvelope, eSoapVersion.getNamespaceURI (), eSoapVersion.getHeaderElementName ()); - final Element aMessagingElement = aSoapHeader == null ? null - : XMLHelper.getFirstChildElementOfName (aSoapHeader, - CAS4.EBMS_NS, - "Messaging"); + final Element aMessagingElement = aSoapHeader == null ? null : XMLHelper.getFirstChildElementOfName (aSoapHeader, + CAS4.EBMS_NS, + "Messaging"); if (!aIncomingState.isElementSigned (aMessagingElement)) aUncoveredParts.add ("the ebMS Messaging header element"); } @@ -801,6 +805,44 @@ private static void _checkSignatureCoverage (@NonNull final Document aSoapDocume } } + /** + * Check that an incoming UserMessage was signed and/or encrypted, if the effective PMode leg + * requires it. See issue #404. + * + * @param aIncomingState + * The current incoming message state. Must contain the effective PMode leg. + * @param aEffectiveLeg + * The effective PMode leg of the incoming UserMessage. May not be null. + * @param aEbmsErrorMessagesTarget + * The error list to fill in case the security requirements are not met. + */ + private static void _checkSecurityAgainstPModeLeg (@NonNull final IAS4IncomingMessageState aIncomingState, + @NonNull final PModeLeg aEffectiveLeg, + @NonNull final AS4ErrorList aEbmsErrorMessagesTarget) + { + final PModeLegSecurity aSecurity = aEffectiveLeg.getSecurity (); + if (aSecurity != null) + { + final ICommonsList aMissing = new CommonsArrayList <> (); + if (aSecurity.hasX509SignatureAlgorithm () && !aIncomingState.isSoapSignatureChecked ()) + aMissing.add ("signed"); + if (aSecurity.hasX509EncryptionAlgorithm () && !aIncomingState.isSoapDecrypted ()) + aMissing.add ("encrypted"); + + if (aMissing.isNotEmpty ()) + { + final String sDetails = "The incoming UserMessage is not " + + StringImplode.imploder ().source (aMissing).separator (" and ").build () + + ", but the effective PMode leg requires it"; + LOGGER.error (sDetails + ". Rejecting the message."); + aEbmsErrorMessagesTarget.add (EEbmsError.EBMS_POLICY_NONCOMPLIANCE.errorBuilder (aIncomingState.getLocale ()) + .refToMessageInError (aIncomingState.getMessageID ()) + .errorDetail (sDetails) + .build ()); + } + } + } + /** * Create an input stream provider that can be read multiple times, by lazily copying the data of * the provided single-read input stream provider to a temporary file on first access. @@ -1061,6 +1103,14 @@ public static IAS4IncomingMessageState processEbmsMessage (@NonNull @WillNotClos if (aEffectiveLeg == null) throw new Phase4IncomingException ("No AS4 P-Mode leg could be determined!").setRetryFeasible (false); + // Make sure the message was secured as the PMode leg requires it (see issue #404) + if (AS4Configuration.isIncomingSecurityEnforcePMode ()) + { + _checkSecurityAgainstPModeLeg (aIncomingState, aEffectiveLeg, aEbmsErrorMessagesTarget); + if (aEbmsErrorMessagesTarget.isNotEmpty ()) + return aIncomingState; + } + // Only do profile checks if a profile is set // Profile Checks gets set when started with Server if (aValidator != null) diff --git a/phase4-lib/src/test/java/com/helger/phase4/config/AS4ConfigurationTest.java b/phase4-lib/src/test/java/com/helger/phase4/config/AS4ConfigurationTest.java index 29881ba53..a80fcd320 100644 --- a/phase4-lib/src/test/java/com/helger/phase4/config/AS4ConfigurationTest.java +++ b/phase4-lib/src/test/java/com/helger/phase4/config/AS4ConfigurationTest.java @@ -140,4 +140,22 @@ public void testIncomingSignatureRequireFullCoverage () SystemProperties.removePropertyValue (sKey); } } + + @Test + public void testIncomingSecurityEnforcePMode () + { + // Disabled by default for backwards compatibility + assertFalse (AS4Configuration.isIncomingSecurityEnforcePMode ()); + + final String sKey = AS4Configuration.PROPERTY_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE; + try + { + SystemProperties.setPropertyValue (sKey, "true"); + assertTrue (AS4Configuration.isIncomingSecurityEnforcePMode ()); + } + finally + { + SystemProperties.removePropertyValue (sKey); + } + } } diff --git a/phase4-test/src/test/java/com/helger/phase4/server/message/UserMessageEnforcePModeSecurityTest.java b/phase4-test/src/test/java/com/helger/phase4/server/message/UserMessageEnforcePModeSecurityTest.java new file mode 100644 index 000000000..c3a2a520f --- /dev/null +++ b/phase4-test/src/test/java/com/helger/phase4/server/message/UserMessageEnforcePModeSecurityTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2015-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed 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 com.helger.phase4.server.message; + +import static org.junit.Assert.assertTrue; + +import java.util.Collection; + +import org.jspecify.annotations.NonNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import com.helger.base.system.SystemProperties; +import com.helger.collection.commons.CommonsArrayList; +import com.helger.io.resource.ClassPathResource; +import com.helger.phase4.AS4TestConstants; +import com.helger.phase4.config.AS4Configuration; +import com.helger.phase4.messaging.crypto.AS4Encryptor; +import com.helger.phase4.messaging.http.HttpXMLEntity; +import com.helger.phase4.model.ESoapVersion; +import com.helger.phase4.model.error.EEbmsError; +import com.helger.xml.serialize.read.DOMReader; + +/** + * Test the enforcement of the PMode leg security settings on incoming UserMessages. The PMode used + * by the mock messages defines both a signature and an encryption algorithm. See issue #404. + * + * @author Philip Helger + */ +@RunWith (Parameterized.class) +public final class UserMessageEnforcePModeSecurityTest extends AbstractUserMessageTestSetUp +{ + @Parameters (name = "{index}: {0}") + public static Collection data () + { + return new CommonsArrayList <> (ESoapVersion.values (), x -> new Object [] { x }); + } + + private final ESoapVersion m_eSoapVersion; + + public UserMessageEnforcePModeSecurityTest (@NonNull final ESoapVersion eSOAPVersion) + { + m_eSoapVersion = eSOAPVersion; + } + + @Before + public void enableEnforcement () + { + SystemProperties.setPropertyValue (AS4Configuration.PROPERTY_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE, "true"); + } + + @After + public void resetEnforcement () + { + SystemProperties.removePropertyValue (AS4Configuration.PROPERTY_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE); + } + + @NonNull + private static Node _readPayload () + { + return DOMReader.readXMLDOM (new ClassPathResource (AS4TestConstants.TEST_SOAP_BODY_PAYLOAD_XML)); + } + + @Test + public void testNotSignedNotEncryptedShouldFail () throws Exception + { + final Node aPayload = _readPayload (); + final Document aDoc = MockMessages.createUserMessageNotSigned (m_eSoapVersion, aPayload, null) + .getAsSoapDocument (aPayload); + + sendPlainMessageExpectError (new HttpXMLEntity (aDoc, m_eSoapVersion.getMimeType ()), + EEbmsError.EBMS_POLICY_NONCOMPLIANCE.getErrorCode ()); + } + + @Test + public void testSignedNotEncryptedShouldFail () throws Exception + { + final Document aDoc = MockMessages.createUserMessageSigned (m_eSoapVersion, _readPayload (), null, s_aResMgr); + + sendPlainMessageExpectError (new HttpXMLEntity (aDoc, m_eSoapVersion.getMimeType ()), + EEbmsError.EBMS_POLICY_NONCOMPLIANCE.getErrorCode ()); + } + + @Test + public void testEncryptedNotSignedShouldFail () throws Exception + { + final Node aPayload = _readPayload (); + Document aDoc = MockMessages.createUserMessageNotSigned (m_eSoapVersion, aPayload, null) + .getAsSoapDocument (aPayload); + aDoc = AS4Encryptor.encryptSoapBodyPayload (m_aCryptoFactory, m_eSoapVersion, aDoc, false, m_aCryptParams); + + sendPlainMessageExpectError (new HttpXMLEntity (aDoc, m_eSoapVersion.getMimeType ()), + EEbmsError.EBMS_POLICY_NONCOMPLIANCE.getErrorCode ()); + } + + @Test + public void testSignedAndEncryptedSuccess () throws Exception + { + Document aDoc = MockMessages.createUserMessageSigned (m_eSoapVersion, _readPayload (), null, s_aResMgr); + aDoc = AS4Encryptor.encryptSoapBodyPayload (m_aCryptoFactory, m_eSoapVersion, aDoc, false, m_aCryptParams); + + final String sResponse = sendPlainMessageExpectSuccess (new HttpXMLEntity (aDoc, m_eSoapVersion.getMimeType ())); + assertTrue (sResponse.contains (AS4TestConstants.RECEIPT_ASSERTCHECK)); + } + + @Test + public void testNotSignedNotEncryptedSuccessIfDisabled () throws Exception + { + SystemProperties.setPropertyValue (AS4Configuration.PROPERTY_PHASE4_INCOMING_SECURITY_ENFORCE_PMODE, "false"); + + final Node aPayload = _readPayload (); + final Document aDoc = MockMessages.createUserMessageNotSigned (m_eSoapVersion, aPayload, null) + .getAsSoapDocument (aPayload); + + final String sResponse = sendPlainMessageExpectSuccess (new HttpXMLEntity (aDoc, m_eSoapVersion.getMimeType ())); + assertTrue (sResponse.contains (AS4TestConstants.RECEIPT_ASSERTCHECK)); + } +}