normalised = new HashSet<>();
+ for (final String value : configured) {
+ try {
+ normalised.add(JID.domainprep(value));
+ } catch (final IllegalArgumentException e) {
+ Log.warn("Ignoring a value of {} that is not a valid domain name: '{}'", ALLOWABLE_DOMAINS.getKey(), value);
+ }
+ }
+ current = new NormalisedDomains(List.copyOf(configured), Set.copyOf(normalised));
+ normalisedDomains = current;
+ }
+ return current.normalised();
}
}
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java
index f53a92a8b3..523745e56a 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/server/ServerDialback.java
@@ -105,7 +105,6 @@ private enum VerifyResult {
private Connection connection;
private DomainPair domainPair;
private final SessionManager sessionManager = SessionManager.getInstance();
- private final RoutingTable routingTable = XMPPServer.getInstance().getRoutingTable();
/**
* Returns true if server dialback is enabled. When enabled remote servers may connect to this
@@ -426,7 +425,7 @@ public LocalIncomingServerSession createIncomingSession(XMPPPacketReader reader)
String recipient = doc.attributeValue("to");
Log.debug("ServerDialback: RS - Validating remote domain for incoming session from {} to {}", hostname, recipient);
try {
- validateRemoteDomain(doc, streamID);
+ final String localDomain = validateRemoteDomain(doc, streamID);
Log.debug("ServerDialback: RS - Validation of remote domain for incoming session from {} to {} was successful.", hostname, recipient);
// Create a server Session for the remote server
LocalIncomingServerSession session = sessionManager.
@@ -436,7 +435,7 @@ public LocalIncomingServerSession createIncomingSession(XMPPPacketReader reader)
session.setAuthenticationMethod(ServerSession.AuthenticationMethod.DIALBACK);
// Set the domain or subdomain of the local server used when
// validating the session
- session.setLocalDomain(recipient);
+ session.setLocalDomain(localDomain);
// After the session has been created, inform all listeners as well.
ServerSessionEventDispatcher.dispatchEvent(session, ServerSessionEventDispatcher.EventType.session_created);
return session;
@@ -502,8 +501,8 @@ protected void dialbackError(String from, String to, PacketError err) {
}
/**
- * Returns true if the domain requested by the remote server was validated by the Authoritative
- * Server. To validate the domain a new TCP connection will be established to the
+ * Validates the domain requested by the remote server with the Authoritative Server, and returns that domain in
+ * normalised form. To validate the domain a new TCP connection will be established to the
* Authoritative Server. The Authoritative Server may be the same Originating Server or
* some other machine in the Originating Server's network.
*
@@ -512,9 +511,10 @@ protected void dialbackError(String from, String to, PacketError err) {
*
* @param doc the request for validating the new domain.
* @param streamID the stream id generated by this server for the Originating Server.
+ * @return the validated domain, normalised to the form in which the local XMPP service knows it.
* @throws StreamErrorException when validation did not succeed.
*/
- public void validateRemoteDomain(Element doc, StreamID streamID) throws StreamErrorException, ServerDialbackErrorException, ServerDialbackKeyInvalidException
+ public String validateRemoteDomain(Element doc, StreamID streamID) throws StreamErrorException, ServerDialbackErrorException, ServerDialbackKeyInvalidException
{
StringBuilder sb;
String recipient = doc.attributeValue("to");
@@ -539,6 +539,10 @@ else if (isHostUnknown(recipient)) {
throw new ServerDialbackErrorException(recipient, remoteDomain, new PacketError(PacketError.Condition.item_not_found, PacketError.Type.cancel, "Service not hosted here"));
}
else {
+ // The domain is serviced. Continue with, and return, its normalised form, so that the session is created
+ // for, and the result is reported from, the domain as the local XMPP service knows it.
+ recipient = JID.domainprep(recipient);
+
log.debug( "Check if the remote domain already has a connection to the target domain/subdomain" );
boolean alreadyExists = false;
for (IncomingServerSession session : sessionManager.getIncomingServerSessions(remoteDomain)) {
@@ -554,7 +558,7 @@ else if (isHostUnknown(recipient)) {
if (SASLAuthentication.verifyCertificates(connection.getPeerCertificates(), remoteDomain, true)) {
log.debug( "Host authenticated based on SASL. Weaker dialback-based authentication is skipped." );
log.debug( "Domain validated successfully!" );
- return;
+ return recipient;
}
log.debug( "Unable to authenticate host based on stronger SASL. Proceeding with dialback..." );
@@ -623,7 +627,7 @@ else if (isHostUnknown(recipient)) {
switch(result) {
case valid:
log.debug( "Successfully validated domain!" );
- return;
+ return recipient;
case invalid:
throw new ServerDialbackKeyInvalidException(recipient, remoteDomain);
@@ -643,19 +647,19 @@ else if (isHostUnknown(recipient)) {
}
}
- private boolean isHostUnknown(String recipient) {
- boolean host_unknown = !domainPair.getLocal().equals(recipient);
- // If the recipient does not match the local domain then check if it matches a subdomain. This
- // trick is useful when subdomains of this server are registered in the DNS so remote
- // servers may establish connections directly to a subdomain of this server
- if (host_unknown && recipient.contains(domainPair.getLocal())) {
- host_unknown = !routingTable.hasComponentRoute(new JID(recipient));
- }
-
- if (host_unknown) {
- host_unknown = !Trunking.isTrunkingEnabledFor(recipient);
- }
- return host_unknown;
+ /**
+ * Checks if the domain that the remote server addresses is not serviced by the local XMPP service. Subdomains of its
+ * XMPP domain are serviced when they have a component route, which is useful when they are registered in the DNS so
+ * that remote servers can establish connections directly to them.
+ *
+ * XEP-0220 requires the 'to' attribute on dialback requests, so a request that has none is for an unknown host.
+ *
+ * @param recipient the domain addressed by the remote server (can be null).
+ * @return true if the domain is not serviced by the local XMPP service.
+ * @see ServerStanzaHandler#isLocallyServiced(String)
+ */
+ boolean isHostUnknown(String recipient) {
+ return recipient == null || !ServerStanzaHandler.isLocallyServiced(recipient);
}
private VerifyResult sendVerifyKey(String key, StreamID streamID, String recipient, String remoteDomain, Writer writer, XMPPPacketReader reader, Socket socket, boolean skipTLS, boolean directTLS) throws IOException, XmlPullParserException, RemoteConnectionFailedException {
diff --git a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java
index b25a217d03..ee6ec1b2e6 100644
--- a/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java
+++ b/xmppserver/src/main/java/org/jivesoftware/openfire/session/LocalIncomingServerSession.java
@@ -248,9 +248,7 @@ public boolean validateSubsequentDomain(Element dbResult) {
final DomainPair domainPair = new DomainPair(getServerName(), fromDomain);
ServerDialback method = new ServerDialback(getConnection(), domainPair);
try {
- method.validateRemoteDomain(dbResult, getStreamID());
-
- final String recipient = dbResult.attributeValue("to");
+ final String recipient = method.validateRemoteDomain(dbResult, getStreamID());
final String remoteDomain = dbResult.attributeValue("from");
// Add the validated domain as a valid domain. Do this before notifying the remote domain of success! (OF-2626)
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/ServerStanzaHandlerHostValidationTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/ServerStanzaHandlerHostValidationTest.java
new file mode 100644
index 0000000000..a9a3b2f280
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/ServerStanzaHandlerHostValidationTest.java
@@ -0,0 +1,416 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * 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 org.jivesoftware.openfire.net;
+
+import org.dom4j.io.XMPPPacketReader;
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.Connection;
+import org.jivesoftware.openfire.PacketRouter;
+import org.jivesoftware.openfire.RoutingTable;
+import org.jivesoftware.openfire.XMPPServer;
+import org.jivesoftware.openfire.session.LocalIncomingServerSession;
+import org.jivesoftware.openfire.spi.BasicStreamIDFactory;
+import org.jivesoftware.util.JiveGlobals;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserFactory;
+import org.xmpp.packet.JID;
+
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Verifies the validation of the 'to' attribute of stream headers that are sent by remote servers (OF-2643).
+ *
+ * When validation is enabled, a stream that names a domain that is not serviced by the local XMPP service is refused
+ * with a host-unknown stream error. Serviced domains are the XMPP domain of the service, subdomains of it that have a
+ * component route, and domains for which trunking is enabled. A stream header that has no 'to' attribute is accepted,
+ * as RFC 6120 section 4.7.2 asks to be liberal towards peers that omit it.
+ *
+ * Stream headers are validated both when the stream is first opened, and when it is restarted after TLS has been
+ * negotiated: RFC 6120 section 5.4.3.3 requires values obtained before TLS took effect to be discarded, which makes the
+ * header that follows TLS negotiation the one that counts.
+ */
+public class ServerStanzaHandlerHostValidationTest
+{
+ private static final String COMPONENT_DOMAIN = "conference." + Fixtures.XMPP_DOMAIN;
+
+ private static final String TRUNKED_DOMAIN = "trunked.example.net";
+
+ private static final String UNSERVICED_DOMAIN = "unserviced.example.com";
+
+ private Connection connection;
+
+ private RoutingTable routingTable;
+
+ private RecordingServerStanzaHandler handler;
+
+ private XMPPPacketReader reader;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ public static void tearDownClass()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ public void setUp() throws Exception
+ {
+ Fixtures.clearExistingProperties();
+ JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN);
+
+ final XMPPServer xmppServer = Fixtures.mockXMPPServer();
+ routingTable = Fixtures.mockRoutingTable();
+ doReturn(true).when(routingTable).hasComponentRoute(new JID(COMPONENT_DOMAIN));
+ doReturn(routingTable).when(xmppServer).getRoutingTable();
+ XMPPServer.setInstance(xmppServer);
+
+ connection = mock(Connection.class, withSettings().lenient());
+ handler = new RecordingServerStanzaHandler(mock(PacketRouter.class), connection);
+ reader = newPacketReader();
+ }
+
+ /**
+ * Asserts that validation is disabled by default, so that existing installations keep accepting the connections
+ * that they accepted before validation was introduced.
+ */
+ @Test
+ public void testValidationIsDisabledByDefault()
+ {
+ assertFalse(handler.validateHost(), "Host validation must be disabled when it has not been configured.");
+ }
+
+ /**
+ * Asserts that, with validation enabled, a stream that names a domain that is not serviced is refused with a
+ * host-unknown stream error before a session is created.
+ */
+ @Test
+ public void testInitialStreamToUnservicedDomainIsRefused() throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(UNSERVICED_DOMAIN), reader);
+
+ // Verify result.
+ assertEquals(0, handler.sessionsCreated, "No session must be created for a stream that names a domain that is not serviced.");
+ assertTrue(deliveredText().contains("host-unknown"), "The peer must be told that the host is unknown.");
+ assertTrue(isOpenedFromAuthoritativeNameOrEmptyString(deliveredText()), "The refusal must be sent from an authoritative name of the local XMPP service, or from the empty string.");
+ verify(connection, times(1).description("The connection must be closed after the refusal.")).close();
+ }
+
+ /**
+ * Asserts that, with validation disabled, a stream that names a domain that is not serviced is accepted, as it was
+ * before validation was introduced.
+ */
+ @Test
+ public void testInitialStreamToUnservicedDomainIsAcceptedWhenValidationIsDisabled() throws Exception
+ {
+ // Execute system under test.
+ handler.initiateSession(streamHeader(UNSERVICED_DOMAIN), reader);
+
+ // Verify result.
+ assertEquals(1, handler.sessionsCreated, "A session must be created when validation is disabled.");
+ }
+
+ /**
+ * Asserts that, with validation enabled, streams that name a serviced domain are accepted.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {Fixtures.XMPP_DOMAIN, COMPONENT_DOMAIN, TRUNKED_DOMAIN})
+ public void testInitialStreamToServicedDomainIsAccepted(final String domain) throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(domain), reader);
+
+ // Verify result.
+ assertEquals(1, handler.sessionsCreated, "A session must be created for a stream that names '" + domain + "'.");
+ }
+
+ /**
+ * Asserts that, with validation enabled, a stream that does not name a domain at all is accepted, as RFC 6120
+ * section 4.7.2 asks to be liberal towards peers that omit the 'to' attribute.
+ */
+ @Test
+ public void testInitialStreamWithoutToIsAccepted() throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(null), reader);
+
+ // Verify result.
+ assertEquals(1, handler.sessionsCreated, "A session must be created for a stream that has no 'to' attribute.");
+ }
+
+ /**
+ * Asserts that, with validation disabled, a stream that names a domain that is not serviced, and that is refused
+ * for another reason, is answered from an authoritative name of the local XMPP service or from the empty string,
+ * as RFC 6120 section 4.9.1.3 allows.
+ */
+ @Test
+ public void testStreamToUnservicedDomainRefusedForOtherReasonIsRefusedFromAuthoritativeNameOrEmptyString() throws Exception
+ {
+ // Execute system under test.
+ handler.initiateSession(streamHeaderWithNamespace(UNSERVICED_DOMAIN, "jabber:client"), reader);
+
+ // Verify result.
+ final String delivered = deliveredText();
+ assertTrue(delivered.contains("invalid-namespace"), "The stream must be refused for its namespace.");
+ assertTrue(isOpenedFromAuthoritativeNameOrEmptyString(delivered), "The refusal must be sent from an authoritative name of the local XMPP service, or from the empty string.");
+ assertFalse(delivered.contains("from=\"" + UNSERVICED_DOMAIN + "\""), "The refusal must not be sent from the domain that is not serviced.");
+ }
+
+ /**
+ * Asserts that, with validation enabled, a stream that is opened after TLS is negotiated without declaring a
+ * content namespace, and that names a domain that is not serviced, is refused with a host-unknown stream error.
+ */
+ @Test
+ public void testStreamAfterTlsWithoutNamespaceToUnservicedDomainIsRefused() throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+ handler.initiateSession(streamHeader(Fixtures.XMPP_DOMAIN), reader);
+ handler.startedTLS = true;
+
+ // Execute system under test.
+ handler.initiateSession("", reader);
+
+ // Verify result.
+ assertTrue(deliveredText().contains("host-unknown"), "The peer must be told that the host is unknown.");
+ verify(connection, times(1).description("The connection must be closed after the refusal.")).close();
+ }
+
+ /**
+ * Asserts that a stream that is refused for a reason other than its host is answered from the domain that it names,
+ * when that domain is serviced.
+ */
+ @Test
+ public void testStreamToServicedDomainRefusedForOtherReasonIsRefusedFromThatDomain() throws Exception
+ {
+ // Execute system under test.
+ handler.initiateSession(streamHeaderWithNamespace(COMPONENT_DOMAIN, "jabber:client"), reader);
+
+ // Verify result.
+ final String delivered = deliveredText();
+ assertTrue(delivered.contains("invalid-namespace"), "The stream must be refused for its namespace.");
+ assertTrue(delivered.contains("from=\"" + COMPONENT_DOMAIN + "\""), "The refusal must be sent from the serviced domain that the peer addressed.");
+ }
+
+ /**
+ * Asserts that, with validation enabled, a stream that names a serviced domain before TLS is negotiated, but a
+ * domain that is not serviced after, is refused with a host-unknown stream error.
+ */
+ @Test
+ public void testStreamAfterTlsToUnservicedDomainIsRefused() throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+ handler.initiateSession(streamHeader(Fixtures.XMPP_DOMAIN), reader);
+ handler.startedTLS = true;
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(UNSERVICED_DOMAIN), reader);
+
+ // Verify result.
+ assertEquals(1, handler.sessionsCreated, "The session must not be re-created for a stream that names a domain that is not serviced.");
+ assertTrue(deliveredText().contains("host-unknown"), "The peer must be told that the host is unknown.");
+ assertFalse(deliveredText().contains("from=\"" + UNSERVICED_DOMAIN + "\""), "The refusal must not be sent from the domain that is not serviced.");
+ verify(connection, times(1).description("The connection must be closed after the refusal.")).close();
+ }
+
+ /**
+ * Asserts that, with validation enabled, a stream that names a serviced domain after TLS is negotiated is
+ * accepted. Peers that use trunking typically negotiate TLS before authenticating with Server Dialback, which
+ * makes this the path by which trunked domains are normally validated.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {Fixtures.XMPP_DOMAIN, COMPONENT_DOMAIN, TRUNKED_DOMAIN})
+ public void testStreamAfterTlsToServicedDomainIsAccepted(final String domain) throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+ handler.initiateSession(streamHeader(domain), reader);
+ handler.startedTLS = true;
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(domain), reader);
+
+ // Verify result.
+ assertEquals(2, handler.sessionsCreated, "The session must be re-created for the stream that follows TLS negotiation, when it names '" + domain + "'.");
+ assertFalse(deliveredText().contains("host-unknown"), "The stream must not be refused.");
+ }
+
+ /**
+ * Asserts that, with validation enabled, a stream that does not name a domain after TLS is negotiated is accepted,
+ * in the same way as a stream that does not name a domain before TLS is negotiated.
+ */
+ @Test
+ public void testStreamAfterTlsWithoutToIsAccepted() throws Exception
+ {
+ // Setup test fixture.
+ ServerStanzaHandler.VALIDATE_HOST.setValue(true);
+ handler.initiateSession(streamHeader(Fixtures.XMPP_DOMAIN), reader);
+ handler.startedTLS = true;
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(null), reader);
+
+ // Verify result.
+ assertEquals(2, handler.sessionsCreated, "The session must be re-created for the stream that follows TLS negotiation, when it has no 'to' attribute.");
+ assertFalse(deliveredText().contains("host-unknown"), "The stream must not be refused.");
+ }
+
+ /**
+ * Asserts that, with validation disabled, a stream that names a domain that is not serviced after TLS is
+ * negotiated is accepted, as it was before validation was introduced.
+ */
+ @Test
+ public void testStreamAfterTlsToUnservicedDomainIsAcceptedWhenValidationIsDisabled() throws Exception
+ {
+ // Setup test fixture.
+ handler.initiateSession(streamHeader(Fixtures.XMPP_DOMAIN), reader);
+ handler.startedTLS = true;
+
+ // Execute system under test.
+ handler.initiateSession(streamHeader(UNSERVICED_DOMAIN), reader);
+
+ // Verify result.
+ assertEquals(2, handler.sessionsCreated, "The session must be re-created when validation is disabled.");
+ }
+
+ /**
+ * Returns an opening stream header as sent by a remote server.
+ *
+ * @param to the value of the 'to' attribute, or null to omit the attribute.
+ * @return a stream header.
+ */
+ private static String streamHeader(final String to)
+ {
+ return streamHeaderWithNamespace(to, "jabber:server");
+ }
+
+ /**
+ * Returns an opening stream header as sent by a remote server, with the provided content namespace.
+ *
+ * @param to the value of the 'to' attribute, or null to omit the attribute.
+ * @param namespace the content namespace.
+ * @return a stream header.
+ */
+ private static String streamHeaderWithNamespace(final String to, final String namespace)
+ {
+ return "";
+ }
+
+ /**
+ * Returns all text that was delivered to the peer, concatenated.
+ *
+ * @return delivered text.
+ */
+ private String deliveredText()
+ {
+ final ArgumentCaptor captor = ArgumentCaptor.forClass(String.class);
+ verify(connection, atLeastOnce().description("Text must have been delivered to the peer.")).deliverRawText(captor.capture());
+ return String.join("", captor.getAllValues());
+ }
+
+ /**
+ * Checks if text that was delivered to the peer opens a stream from a value that RFC 6120 section 4.9.1.3 allows
+ * when the peer addressed no host or an unknown host: an authoritative name for the receiving entity (in these
+ * tests, the XMPP domain), or the empty string.
+ *
+ * @param delivered text that was delivered to the peer.
+ * @return true if the stream is opened from an allowed value.
+ */
+ private static boolean isOpenedFromAuthoritativeNameOrEmptyString(final String delivered)
+ {
+ return delivered.contains("from=\"" + Fixtures.XMPP_DOMAIN + "\"") || delivered.contains("from=\"\"");
+ }
+
+ /**
+ * Returns a namespace-aware packet reader, configured as the production code configures its own.
+ *
+ * @return a packet reader.
+ */
+ private static XMPPPacketReader newPacketReader() throws Exception
+ {
+ final XmlPullParserFactory factory = XmlPullParserFactory.newInstance(MXParser.class.getName(), null);
+ factory.setNamespaceAware(true);
+ final XMPPPacketReader reader = new XMPPPacketReader();
+ reader.setXPPFactory(factory);
+ return reader;
+ }
+
+ /**
+ * A {@link ServerStanzaHandler} that replaces the creation of a session with a stub, and counts how often a session
+ * is created. A real session requires collaborators (an identity store, a session manager) that are not what these
+ * tests are about.
+ */
+ private static class RecordingServerStanzaHandler extends ServerStanzaHandler
+ {
+ private int sessionsCreated;
+
+ RecordingServerStanzaHandler(final PacketRouter router, final Connection connection)
+ {
+ super(router, connection, false);
+ }
+
+ @Override
+ void createSession(final String serverName, final XmlPullParser xpp, final Connection connection)
+ {
+ sessionsCreated++;
+ final LocalIncomingServerSession stub = mock(LocalIncomingServerSession.class, withSettings().lenient());
+ doReturn(new BasicStreamIDFactory().createStreamID()).when(stub).getStreamID();
+ doReturn(Locale.ENGLISH).when(stub).getLanguage();
+ doReturn(List.of()).when(stub).getAvailableStreamFeatures();
+ session = stub;
+ }
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/ServerStanzaHandlerLocallyServicedTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/ServerStanzaHandlerLocallyServicedTest.java
new file mode 100644
index 0000000000..d14a4fcfb6
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/ServerStanzaHandlerLocallyServicedTest.java
@@ -0,0 +1,249 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * 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 org.jivesoftware.openfire.net;
+
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.RoutingTable;
+import org.jivesoftware.openfire.XMPPServer;
+import org.jivesoftware.util.JiveGlobals;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.xmpp.packet.JID;
+
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+
+/**
+ * Verifies {@link ServerStanzaHandler#isLocallyServiced(String)}, which decides whether a domain that a remote server
+ * addresses is serviced by the local XMPP service (OF-2643). Both the validation of stream headers and Server Dialback
+ * use it.
+ */
+public class ServerStanzaHandlerLocallyServicedTest
+{
+ private static final String COMPONENT_DOMAIN = "conference." + Fixtures.XMPP_DOMAIN;
+
+ private static final String TRUNKED_DOMAIN = "trunked.example.net";
+
+ private static final String UNSERVICED_DOMAIN = "unserviced.example.com";
+
+ /**
+ * A value that cannot be a domain name, as its first label exceeds 63 characters, which is the maximum length of a
+ * label that RFC 1035 section 2.3.4 defines.
+ *
+ * @see RFC 1035 section 2.3.4: Size limits
+ */
+ private static final String INVALID_DOMAIN = "a".repeat(64) + ".example.org";
+
+ private RoutingTable routingTable;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ public static void tearDownClass()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ public void setUp()
+ {
+ Fixtures.clearExistingProperties();
+ JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN);
+
+ final XMPPServer xmppServer = Fixtures.mockXMPPServer();
+ routingTable = Fixtures.mockRoutingTable();
+ doReturn(true).when(routingTable).hasComponentRoute(new JID(COMPONENT_DOMAIN));
+ doReturn(routingTable).when(xmppServer).getRoutingTable();
+ XMPPServer.setInstance(xmppServer);
+ }
+
+ /**
+ * Asserts that the XMPP domain of the local XMPP service is serviced.
+ */
+ @Test
+ public void testXmppDomainIsServiced()
+ {
+ assertTrue(ServerStanzaHandler.isLocallyServiced(Fixtures.XMPP_DOMAIN), "The XMPP domain must be serviced.");
+ }
+
+ /**
+ * Asserts that the XMPP domain of the local XMPP service is serviced when a peer names it in a different case.
+ * Domain names are compared after normalisation, as RFC 6120 requires.
+ */
+ @Test
+ public void testXmppDomainInDifferentCaseIsServiced()
+ {
+ assertTrue(ServerStanzaHandler.isLocallyServiced(Fixtures.XMPP_DOMAIN.toUpperCase(Locale.ROOT)), "The XMPP domain must be serviced when it is written in a different case.");
+ }
+
+ /**
+ * Asserts that a subdomain of the XMPP domain that has a component route (such as a multi-user chat service) is
+ * serviced.
+ */
+ @Test
+ public void testComponentSubdomainIsServiced()
+ {
+ assertTrue(ServerStanzaHandler.isLocallyServiced(COMPONENT_DOMAIN), "A subdomain that has a component route must be serviced.");
+ }
+
+ /**
+ * Asserts that a subdomain of the XMPP domain that has no component route is not serviced.
+ */
+ @Test
+ public void testSubdomainWithoutComponentIsNotServiced()
+ {
+ assertFalse(ServerStanzaHandler.isLocallyServiced("unknown." + Fixtures.XMPP_DOMAIN), "A subdomain that has no component route must not be serviced.");
+ }
+
+ /**
+ * Asserts that a domain that merely ends with the text of the XMPP domain, without being a subdomain of it, is not
+ * serviced, even when a component route exists for it.
+ */
+ @Test
+ public void testDomainThatContainsXmppDomainIsNotServiced()
+ {
+ // Setup test fixture.
+ final String lookalike = "not" + Fixtures.XMPP_DOMAIN;
+ doReturn(true).when(routingTable).hasComponentRoute(new JID(lookalike));
+
+ // Execute system under test & verify result.
+ assertFalse(ServerStanzaHandler.isLocallyServiced(lookalike), "A domain that is not a subdomain of the XMPP domain must not be treated as one.");
+ }
+
+ /**
+ * Asserts that a domain for which trunking is enabled is serviced.
+ */
+ @Test
+ public void testTrunkedDomainIsServiced()
+ {
+ // Setup test fixture.
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+
+ // Execute system under test & verify result.
+ assertTrue(ServerStanzaHandler.isLocallyServiced(TRUNKED_DOMAIN), "A domain for which trunking is enabled must be serviced.");
+ }
+
+ /**
+ * Asserts that a domain that is configured for trunking is not serviced when trunking itself is disabled.
+ */
+ @Test
+ public void testTrunkedDomainIsNotServicedWhenTrunkingIsDisabled()
+ {
+ // Setup test fixture.
+ Trunking.ENABLED.setValue(false);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+
+ // Execute system under test & verify result.
+ assertFalse(ServerStanzaHandler.isLocallyServiced(TRUNKED_DOMAIN), "A domain that is configured for trunking must not be serviced while trunking is disabled.");
+ }
+
+ /**
+ * Asserts that a subdomain of a domain for which trunking is enabled is not serviced: trunking applies to the
+ * domains that are configured, and nothing else.
+ */
+ @Test
+ public void testSubdomainOfTrunkedDomainIsNotServiced()
+ {
+ // Setup test fixture.
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+
+ // Execute system under test & verify result.
+ assertFalse(ServerStanzaHandler.isLocallyServiced("conference." + TRUNKED_DOMAIN), "A subdomain of a domain for which trunking is enabled must not be serviced.");
+ }
+
+ /**
+ * Asserts that a change to the domains that are configured for trunking takes effect for the checks that follow
+ * it: a domain that is added becomes serviced, and a domain that is removed stops being serviced.
+ */
+ @Test
+ public void testChangeOfTrunkedDomainsTakesEffect()
+ {
+ // Setup test fixture.
+ final String otherTrunkedDomain = "other-trunked.example.net";
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+ assertTrue(ServerStanzaHandler.isLocallyServiced(TRUNKED_DOMAIN), "The initially configured domain must be serviced.");
+
+ // Execute system under test.
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(otherTrunkedDomain));
+
+ // Verify result.
+ assertTrue(ServerStanzaHandler.isLocallyServiced(otherTrunkedDomain), "A domain that is added must be serviced.");
+ assertFalse(ServerStanzaHandler.isLocallyServiced(TRUNKED_DOMAIN), "A domain that is removed must no longer be serviced.");
+ }
+
+ /**
+ * Asserts that a domain for which trunking is enabled is serviced when the configuration also lists a value that
+ * is not a valid domain name.
+ */
+ @Test
+ public void testTrunkedDomainIsServicedDespiteInvalidConfiguredDomain()
+ {
+ // Setup test fixture.
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(INVALID_DOMAIN, TRUNKED_DOMAIN));
+
+ // Execute system under test & verify result.
+ assertTrue(ServerStanzaHandler.isLocallyServiced(TRUNKED_DOMAIN), "A domain for which trunking is enabled must be serviced when another configured value is invalid.");
+ }
+
+ /**
+ * Asserts that a domain that is not serviced is reported as such, rather than causing an exception, when the
+ * configuration for trunking lists a value that is not a valid domain name.
+ */
+ @Test
+ public void testUnrelatedDomainIsNotServicedDespiteInvalidConfiguredDomain()
+ {
+ // Setup test fixture.
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(INVALID_DOMAIN));
+
+ // Execute system under test & verify result.
+ assertFalse(ServerStanzaHandler.isLocallyServiced(UNSERVICED_DOMAIN), "An unrelated domain must not be serviced when a configured trunking value is invalid.");
+ }
+
+ /**
+ * Asserts that a domain that is unrelated to the local XMPP service is not serviced.
+ */
+ @Test
+ public void testUnrelatedDomainIsNotServiced()
+ {
+ assertFalse(ServerStanzaHandler.isLocallyServiced(UNSERVICED_DOMAIN), "An unrelated domain must not be serviced.");
+ }
+
+ /**
+ * Asserts that a value that cannot be a domain name (here, because a label exceeds 63 characters) is not serviced,
+ * rather than causing an exception.
+ */
+ @Test
+ public void testInvalidDomainIsNotServiced()
+ {
+ assertFalse(ServerStanzaHandler.isLocallyServiced(INVALID_DOMAIN), "A value that is not a valid domain name must not be serviced.");
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerRefuseStreamTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerRefuseStreamTest.java
index 8ad656af65..cead7af8c2 100644
--- a/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerRefuseStreamTest.java
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/net/StanzaHandlerRefuseStreamTest.java
@@ -28,6 +28,8 @@
import org.mockito.ArgumentCaptor;
import org.xmlpull.v1.XmlPullParserFactory;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.atLeastOnce;
@@ -37,19 +39,8 @@
import static org.mockito.Mockito.withSettings;
/**
- * Verifies that {@link StanzaHandler} delivers a stream error to the peer when it refuses a stream header that does
- * not declare a content namespace.
- *
- * A peer can open a stream with a header that names a stream namespace but no default (content) namespace, such as
- * {@code }. When such a
- * header is refused, building the stream that carries the error used to copy the content namespace of the refused
- * header, which is absent here, so the peer never received the stream error.
- *
- * These tests trigger the refusal with a stream namespace that is not the one RFC 6120 section 4.8.1 requires. That is
- * a reason to refuse which is independent of the missing content namespace itself, so the tests exercise the
- * error-building defect without asserting that a header which omits the content namespace is refused in its own right.
- *
- * @author Matthew Vivian
+ * Verifies how {@link StanzaHandler} refuses a stream header that it cannot accept: it opens a stream that carries
+ * the stream error, and closes the connection without creating a session.
*/
public class StanzaHandlerRefuseStreamTest
{
@@ -61,6 +52,8 @@ public class StanzaHandlerRefuseStreamTest
private Connection connection;
+ private ClientStanzaHandler handler;
+
private XMPPPacketReader reader;
@BeforeAll
@@ -84,6 +77,7 @@ public void setUp() throws Exception
XMPPServer.setInstance(Fixtures.mockXMPPServer());
connection = mock(Connection.class, withSettings().lenient());
+ handler = new ClientStanzaHandler(mock(PacketRouter.class), connection);
final XmlPullParserFactory factory = XmlPullParserFactory.newInstance(MXParser.class.getName(), null);
factory.setNamespaceAware(true);
@@ -91,21 +85,39 @@ public void setUp() throws Exception
reader.setXPPFactory(factory);
}
+ /**
+ * Asserts that a stream header with a content namespace that does not match the handler is refused with an
+ * invalid-namespace stream error.
+ */
+ @Test
+ public void testStreamWithInvalidNamespaceIsRefused() throws Exception
+ {
+ // Execute system under test.
+ handler.initiateSession("", reader);
+
+ // Verify result.
+ final String delivered = deliveredText();
+ assertTrue(delivered.contains("", reader);
// Verify result.
- final String delivered = deliveredText(connection);
+ final String delivered = deliveredText();
assertTrue(delivered.contains("", reader);
+ serverHandler.initiateSession("", reader);
// Verify result.
- final String delivered = deliveredText(connection);
+ final String delivered = deliveredText();
assertTrue(delivered.contains("", reader);
+
+ // Verify result.
+ final String delivered = deliveredText();
+ assertTrue(delivered.contains("", reader);
+
+ // Verify result.
+ final String delivered = deliveredText();
+ assertTrue(isOpenedFromAuthoritativeNameOrEmptyString(delivered), "The stream must be opened from an authoritative name of the local XMPP service, or from the empty string.");
+ assertFalse(delivered.contains("from=\"unserviced.example.com\""), "The stream must not be opened from the unknown host.");
+ }
+
+ /**
+ * Asserts that a stream header that does not address a host, and that is refused, is answered with a stream that
+ * is opened from an authoritative name of the local XMPP service or from the empty string, as RFC 6120 section
+ * 4.9.1.3 requires.
+ */
+ @Test
+ public void testStreamWithoutToIsRefusedFromAuthoritativeNameOrEmptyString() throws Exception
+ {
+ // Execute system under test.
+ handler.initiateSession("", reader);
+
+ // Verify result.
+ final String delivered = deliveredText();
+ assertTrue(delivered.contains(" captor = ArgumentCaptor.forClass(String.class);
verify(connection, atLeastOnce().description("Text must have been delivered to the peer.")).deliverRawText(captor.capture());
return String.join("", captor.getAllValues());
}
+
+ /**
+ * Checks if text that was delivered to the peer opens a stream from a value that RFC 6120 section 4.9.1.3 allows
+ * when the peer addressed no host or an unknown host: an authoritative name for the receiving entity (in these
+ * tests, the XMPP domain), or the empty string.
+ *
+ * @param delivered text that was delivered to the peer.
+ * @return true if the stream is opened from an allowed value.
+ */
+ private static boolean isOpenedFromAuthoritativeNameOrEmptyString(final String delivered)
+ {
+ return delivered.contains("from=\"" + Fixtures.XMPP_DOMAIN + "\"") || delivered.contains("from=\"\"");
+ }
+
+ /**
+ * Counts the occurrences of a fragment in a text.
+ *
+ * @param text the text to search.
+ * @param fragment the fragment to count.
+ * @return the number of occurrences.
+ */
+ private static int countOccurrences(final String text, final String fragment)
+ {
+ int count = 0;
+ for (int index = text.indexOf(fragment); index >= 0; index = text.indexOf(fragment, index + 1)) {
+ count++;
+ }
+ return count;
+ }
}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackCreateIncomingSessionTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackCreateIncomingSessionTest.java
new file mode 100644
index 0000000000..9212a6486e
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackCreateIncomingSessionTest.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * 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 org.jivesoftware.openfire.server;
+
+import org.dom4j.Document;
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.dom4j.Namespace;
+import org.dom4j.QName;
+import org.dom4j.io.XMPPPacketReader;
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.Connection;
+import org.jivesoftware.openfire.SessionManager;
+import org.jivesoftware.openfire.StreamID;
+import org.jivesoftware.openfire.XMPPServer;
+import org.jivesoftware.openfire.net.MXParser;
+import org.jivesoftware.openfire.net.SASLAuthentication;
+import org.jivesoftware.openfire.session.DomainPair;
+import org.jivesoftware.openfire.session.LocalIncomingServerSession;
+import org.jivesoftware.openfire.spi.BasicStreamIDFactory;
+import org.jivesoftware.openfire.spi.ConnectionConfiguration;
+import org.jivesoftware.util.JiveGlobals;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.security.cert.Certificate;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Verifies that {@link ServerDialback#createIncomingSession(XMPPPacketReader)} records the normalised form of the
+ * validated domain as the local domain of the session it creates (OF-2643).
+ *
+ * Server Dialback validates the domain through {@link ServerDialback#validateRemoteDomain(Element, StreamID)}, which
+ * returns the domain as the local XMPP service knows it. This test checks that the created session's local domain is
+ * that returned value, rather than the form the peer wrote. The reader, the session factory, the remote-server
+ * permission check and the certificate check are replaced with stubs, as they parse a live stream, create sessions,
+ * read the database and read the peer's certificates, which are not what this test is about.
+ *
+ * @author Matthew Vivian
+ */
+public class ServerDialbackCreateIncomingSessionTest
+{
+ private static final String REMOTE_DOMAIN = "remote.example.org";
+
+ private SessionManager sessionManager;
+
+ private Connection connection;
+
+ private ServerDialback dialback;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ public static void tearDownClass()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ public void setUp()
+ {
+ Fixtures.clearExistingProperties();
+ JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN);
+
+ final XMPPServer xmppServer = Fixtures.mockXMPPServer();
+ doReturn(Fixtures.mockRoutingTable()).when(xmppServer).getRoutingTable();
+ sessionManager = xmppServer.getSessionManager();
+ doReturn(List.of()).when(sessionManager).getIncomingServerSessions(REMOTE_DOMAIN);
+ XMPPServer.setInstance(xmppServer);
+
+ final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class, withSettings().lenient());
+ doReturn(Connection.TLSPolicy.optional).when(configuration).getTlsPolicy();
+ connection = mock(Connection.class, withSettings().lenient());
+ doReturn(configuration).when(connection).getConfiguration();
+ doReturn(new Certificate[0]).when(connection).getPeerCertificates();
+
+ dialback = new ServerDialback(connection, new DomainPair(Fixtures.XMPP_DOMAIN, REMOTE_DOMAIN));
+ }
+
+ /**
+ * Asserts that, when a peer opens a dialback session addressing the XMPP domain in a different case, the session
+ * that is created records the normalised domain as its local domain, rather than the form the peer wrote.
+ */
+ @Test
+ public void testCreatedSessionRecordsNormalisedLocalDomain() throws Exception
+ {
+ // Setup test fixture: a reader that yields a db:result addressed to the XMPP domain in a different case.
+ final Document document = DocumentHelper.createDocument();
+ final Element dbResult = document.addElement(QName.get("result", Namespace.get("db", "jabber:server:dialback")));
+ dbResult.addAttribute("from", REMOTE_DOMAIN);
+ dbResult.addAttribute("to", Fixtures.XMPP_DOMAIN.toUpperCase(Locale.ROOT));
+ dbResult.setText("key");
+
+ final MXParser parser = mock(MXParser.class);
+ doReturn("jabber:server:dialback").when(parser).getNamespace("db");
+ final XMPPPacketReader reader = mock(XMPPPacketReader.class);
+ doReturn(parser).when(reader).getXPPParser();
+ doReturn(document).when(reader).parseDocument();
+
+ final StreamID streamID = new BasicStreamIDFactory().createStreamID();
+ doReturn(streamID).when(sessionManager).nextStreamID();
+
+ final LocalIncomingServerSession createdSession = mock(LocalIncomingServerSession.class);
+ doReturn(createdSession).when(sessionManager).createIncomingServerSession(connection, streamID, REMOTE_DOMAIN);
+
+ // Execute system under test.
+ final LocalIncomingServerSession result;
+ try (final MockedStatic remoteServers = mockStatic(RemoteServerManager.class);
+ final MockedStatic sasl = mockStatic(SASLAuthentication.class))
+ {
+ remoteServers.when(() -> RemoteServerManager.canAccess(anyString())).thenReturn(true);
+ sasl.when(() -> SASLAuthentication.verifyCertificates(any(), anyString(), anyBoolean())).thenReturn(true);
+
+ result = dialback.createIncomingSession(reader);
+ }
+
+ // Verify result.
+ assertSame(createdSession, result, "The session that was created must be returned.");
+ verify(createdSession, times(1).description("The new session must record the normalised domain as its local domain.")).setLocalDomain(Fixtures.XMPP_DOMAIN);
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackHostUnknownTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackHostUnknownTest.java
new file mode 100644
index 0000000000..a2fad3e307
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackHostUnknownTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * 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 org.jivesoftware.openfire.server;
+
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.Connection;
+import org.jivesoftware.openfire.RoutingTable;
+import org.jivesoftware.openfire.XMPPServer;
+import org.jivesoftware.openfire.net.Trunking;
+import org.jivesoftware.openfire.session.DomainPair;
+import org.jivesoftware.util.JiveGlobals;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.xmpp.packet.JID;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Verifies how Server Dialback, acting as the Receiving Server, decides whether the domain that a remote server asks
+ * to be authenticated for is serviced by the local XMPP service (OF-2643).
+ *
+ * This decision is shared with the validation of stream headers, so that both paths accept the same domains.
+ */
+public class ServerDialbackHostUnknownTest
+{
+ private static final String COMPONENT_DOMAIN = "conference." + Fixtures.XMPP_DOMAIN;
+
+ private static final String TRUNKED_DOMAIN = "trunked.example.net";
+
+ private RoutingTable routingTable;
+
+ private ServerDialback dialback;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ public static void tearDownClass()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ public void setUp()
+ {
+ Fixtures.clearExistingProperties();
+ JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN);
+
+ final XMPPServer xmppServer = Fixtures.mockXMPPServer();
+ routingTable = Fixtures.mockRoutingTable();
+ doReturn(true).when(routingTable).hasComponentRoute(new JID(COMPONENT_DOMAIN));
+ doReturn(routingTable).when(xmppServer).getRoutingTable();
+ XMPPServer.setInstance(xmppServer);
+
+ dialback = new ServerDialback(mock(Connection.class), new DomainPair(Fixtures.XMPP_DOMAIN, "remote.example.org"));
+ }
+
+ /**
+ * Asserts that the XMPP domain of the local XMPP service is not unknown.
+ */
+ @Test
+ public void testXmppDomainIsKnown()
+ {
+ assertFalse(dialback.isHostUnknown(Fixtures.XMPP_DOMAIN), "The XMPP domain must not be unknown.");
+ }
+
+ /**
+ * Asserts that a subdomain of the XMPP domain that has a component route is not unknown.
+ */
+ @Test
+ public void testComponentSubdomainIsKnown()
+ {
+ assertFalse(dialback.isHostUnknown(COMPONENT_DOMAIN), "A subdomain that has a component route must not be unknown.");
+ }
+
+ /**
+ * Asserts that a domain for which trunking is enabled is not unknown.
+ */
+ @Test
+ public void testTrunkedDomainIsKnown()
+ {
+ // Setup test fixture.
+ Trunking.ENABLED.setValue(true);
+ Trunking.ALLOWABLE_DOMAINS.setValue(List.of(TRUNKED_DOMAIN));
+
+ // Execute system under test & verify result.
+ assertFalse(dialback.isHostUnknown(TRUNKED_DOMAIN), "A domain for which trunking is enabled must not be unknown.");
+ }
+
+ /**
+ * Asserts that a domain that is unrelated to the local XMPP service is unknown.
+ */
+ @Test
+ public void testUnrelatedDomainIsUnknown()
+ {
+ assertTrue(dialback.isHostUnknown("unserviced.example.com"), "An unrelated domain must be unknown.");
+ }
+
+ /**
+ * Asserts that a domain that merely ends with the text of the XMPP domain, without being a subdomain of it, is
+ * unknown, even when a component route exists for it.
+ */
+ @Test
+ public void testDomainThatContainsXmppDomainIsUnknown()
+ {
+ // Setup test fixture.
+ final String lookalike = "not" + Fixtures.XMPP_DOMAIN;
+ doReturn(true).when(routingTable).hasComponentRoute(new JID(lookalike));
+
+ // Execute system under test & verify result.
+ assertTrue(dialback.isHostUnknown(lookalike), "A domain that is not a subdomain of the XMPP domain must not be treated as one.");
+ }
+
+ /**
+ * Asserts that a dialback request that does not name the domain it is for is treated as being for an unknown
+ * domain, rather than causing an exception. XEP-0220 requires the 'to' attribute on these requests.
+ */
+ @Test
+ public void testMissingDomainIsUnknown()
+ {
+ assertTrue(dialback.isHostUnknown(null), "A dialback request without a 'to' attribute must be treated as being for an unknown host.");
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackValidateRemoteDomainTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackValidateRemoteDomainTest.java
new file mode 100644
index 0000000000..9769ee2eab
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/server/ServerDialbackValidateRemoteDomainTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * 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 org.jivesoftware.openfire.server;
+
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.Connection;
+import org.jivesoftware.openfire.SessionManager;
+import org.jivesoftware.openfire.XMPPServer;
+import org.jivesoftware.openfire.net.SASLAuthentication;
+import org.jivesoftware.openfire.session.DomainPair;
+import org.jivesoftware.openfire.session.IncomingServerSession;
+import org.jivesoftware.openfire.spi.BasicStreamIDFactory;
+import org.jivesoftware.openfire.spi.ConnectionConfiguration;
+import org.jivesoftware.util.JiveGlobals;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import java.security.cert.Certificate;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Verifies how {@link ServerDialback#validateRemoteDomain(Element, org.jivesoftware.openfire.StreamID)} treats the
+ * domain that a remote server asks to be authenticated for, once that domain is accepted as serviced (OF-2643).
+ *
+ * The remote-server permission check and the certificate check are replaced with stubs, as they read the database and
+ * the peer's certificates, which are not what these tests are about.
+ *
+ * @author Matthew Vivian
+ */
+public class ServerDialbackValidateRemoteDomainTest
+{
+ private static final String REMOTE_DOMAIN = "remote.example.org";
+
+ private SessionManager sessionManager;
+
+ private ServerDialback dialback;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ public static void tearDownClass()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ public void setUp()
+ {
+ Fixtures.clearExistingProperties();
+ JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN);
+
+ final XMPPServer xmppServer = Fixtures.mockXMPPServer();
+ doReturn(Fixtures.mockRoutingTable()).when(xmppServer).getRoutingTable();
+ sessionManager = xmppServer.getSessionManager();
+ XMPPServer.setInstance(xmppServer);
+
+ final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class, withSettings().lenient());
+ doReturn(Connection.TLSPolicy.optional).when(configuration).getTlsPolicy();
+ final Connection connection = mock(Connection.class, withSettings().lenient());
+ doReturn(configuration).when(connection).getConfiguration();
+ doReturn(new Certificate[0]).when(connection).getPeerCertificates();
+
+ dialback = new ServerDialback(connection, new DomainPair(Fixtures.XMPP_DOMAIN, REMOTE_DOMAIN));
+ }
+
+ /**
+ * Asserts that a request to authenticate for the XMPP domain written in a different case is recognised as a
+ * duplicate of an existing session for the XMPP domain, as domain names are compared after normalisation.
+ */
+ @Test
+ public void testExistingSessionIsDetectedForDomainInDifferentCase()
+ {
+ // Setup test fixture.
+ final IncomingServerSession existing = mock(IncomingServerSession.class, withSettings().lenient());
+ doReturn(Fixtures.XMPP_DOMAIN).when(existing).getLocalDomain();
+ doReturn(List.of(existing)).when(sessionManager).getIncomingServerSessions(REMOTE_DOMAIN);
+ doReturn(false).when(sessionManager).isMultipleServerConnectionsAllowed();
+
+ final Element dbResult = DocumentHelper.createElement("result");
+ dbResult.addAttribute("from", REMOTE_DOMAIN);
+ dbResult.addAttribute("to", Fixtures.XMPP_DOMAIN.toUpperCase(Locale.ROOT));
+ dbResult.setText("key");
+
+ try (final MockedStatic remoteServers = mockStatic(RemoteServerManager.class);
+ final MockedStatic sasl = mockStatic(SASLAuthentication.class))
+ {
+ remoteServers.when(() -> RemoteServerManager.canAccess(anyString())).thenReturn(true);
+ sasl.when(() -> SASLAuthentication.verifyCertificates(any(), anyString(), anyBoolean())).thenReturn(true);
+
+ // Execute system under test & verify result.
+ final ServerDialbackErrorException exception = assertThrows(ServerDialbackErrorException.class,
+ () -> dialback.validateRemoteDomain(dbResult, new BasicStreamIDFactory().createStreamID()),
+ "A second session for the same domain, written in a different case, must be refused.");
+ assertEquals("resource-constraint", exception.getError().getCondition().toXMPP(), "The refusal must report that a session for the domain already exists.");
+ }
+ }
+
+ /**
+ * Asserts that, once accepted, the domain that a remote server asks to be authenticated for is returned in
+ * normalised form, so that the session is created for, and the result is reported from, the normalised domain.
+ */
+ @Test
+ public void testAcceptedDomainIsNormalised() throws Exception
+ {
+ // Setup test fixture.
+ doReturn(List.of()).when(sessionManager).getIncomingServerSessions(REMOTE_DOMAIN);
+
+ final Element dbResult = DocumentHelper.createElement("result");
+ dbResult.addAttribute("from", REMOTE_DOMAIN);
+ dbResult.addAttribute("to", Fixtures.XMPP_DOMAIN.toUpperCase(Locale.ROOT));
+ dbResult.setText("key");
+
+ final String result;
+ try (final MockedStatic remoteServers = mockStatic(RemoteServerManager.class);
+ final MockedStatic sasl = mockStatic(SASLAuthentication.class))
+ {
+ remoteServers.when(() -> RemoteServerManager.canAccess(anyString())).thenReturn(true);
+ sasl.when(() -> SASLAuthentication.verifyCertificates(any(), anyString(), anyBoolean())).thenReturn(true);
+
+ // Execute system under test.
+ result = dialback.validateRemoteDomain(dbResult, new BasicStreamIDFactory().createStreamID());
+ }
+
+ // Verify result.
+ assertEquals(Fixtures.XMPP_DOMAIN, result, "The accepted domain must be returned in normalised form.");
+ }
+}
diff --git a/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalIncomingServerSessionSubsequentDomainTest.java b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalIncomingServerSessionSubsequentDomainTest.java
new file mode 100644
index 0000000000..c3eceede6d
--- /dev/null
+++ b/xmppserver/src/test/java/org/jivesoftware/openfire/session/LocalIncomingServerSessionSubsequentDomainTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2026 Ignite Realtime Foundation. All rights reserved.
+ *
+ * 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 org.jivesoftware.openfire.session;
+
+import org.dom4j.DocumentHelper;
+import org.dom4j.Element;
+import org.jivesoftware.Fixtures;
+import org.jivesoftware.openfire.Connection;
+import org.jivesoftware.openfire.SessionManager;
+import org.jivesoftware.openfire.XMPPServer;
+import org.jivesoftware.openfire.net.SASLAuthentication;
+import org.jivesoftware.openfire.server.RemoteServerManager;
+import org.jivesoftware.openfire.spi.BasicStreamIDFactory;
+import org.jivesoftware.openfire.spi.ConnectionConfiguration;
+import org.jivesoftware.util.JiveGlobals;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+import java.security.cert.Certificate;
+import java.util.List;
+import java.util.Locale;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.withSettings;
+
+/**
+ * Verifies that {@link LocalIncomingServerSession#validateSubsequentDomain(Element)} reports success to the peer from
+ * the normalised form of the validated domain (OF-2643).
+ *
+ * Server Dialback validates the domain through {@link org.jivesoftware.openfire.server.ServerDialback}, which returns
+ * the domain as the local XMPP service knows it. This test checks that the {@code } reply uses
+ * that returned value, rather than the form the peer wrote. The remote-server permission check and the certificate
+ * check are replaced with stubs, as they read the database and the peer's certificates, which are not what this test
+ * is about.
+ *
+ * @author Matthew Vivian
+ */
+public class LocalIncomingServerSessionSubsequentDomainTest
+{
+ private static final String REMOTE_DOMAIN = "remote.example.org";
+
+ private Connection connection;
+
+ private LocalIncomingServerSession session;
+
+ @BeforeAll
+ public static void setUpClass() throws Exception
+ {
+ Fixtures.reconfigureOpenfireHome();
+ Fixtures.disableDatabasePersistence();
+ }
+
+ @AfterAll
+ public static void tearDownClass()
+ {
+ Fixtures.clearExistingProperties();
+ }
+
+ @BeforeEach
+ public void setUp()
+ {
+ Fixtures.clearExistingProperties();
+ JiveGlobals.setProperty("xmpp.domain", Fixtures.XMPP_DOMAIN);
+
+ final XMPPServer xmppServer = Fixtures.mockXMPPServer();
+ doReturn(Fixtures.mockRoutingTable()).when(xmppServer).getRoutingTable();
+ final SessionManager sessionManager = xmppServer.getSessionManager();
+ doReturn(List.of()).when(sessionManager).getIncomingServerSessions(REMOTE_DOMAIN);
+ XMPPServer.setInstance(xmppServer);
+
+ final ConnectionConfiguration configuration = mock(ConnectionConfiguration.class, withSettings().lenient());
+ doReturn(Connection.TLSPolicy.optional).when(configuration).getTlsPolicy();
+ connection = mock(Connection.class, withSettings().lenient());
+ doReturn(configuration).when(connection).getConfiguration();
+ doReturn(new Certificate[0]).when(connection).getPeerCertificates();
+
+ session = new LocalIncomingServerSession(Fixtures.XMPP_DOMAIN, connection, new BasicStreamIDFactory().createStreamID(), REMOTE_DOMAIN);
+ }
+
+ /**
+ * Asserts that, when a peer addresses a subsequent domain in a different case from the XMPP domain, the reply that
+ * reports success carries the normalised domain in its 'from' attribute, rather than the form the peer wrote.
+ */
+ @Test
+ public void testSubsequentDomainReplyReportsNormalisedDomain()
+ {
+ // Setup test fixture.
+ final Element dbResult = DocumentHelper.createElement("result");
+ dbResult.addAttribute("from", REMOTE_DOMAIN);
+ dbResult.addAttribute("to", Fixtures.XMPP_DOMAIN.toUpperCase(Locale.ROOT));
+ dbResult.setText("key");
+
+ // Execute system under test.
+ try (final MockedStatic remoteServers = mockStatic(RemoteServerManager.class);
+ final MockedStatic sasl = mockStatic(SASLAuthentication.class))
+ {
+ remoteServers.when(() -> RemoteServerManager.canAccess(anyString())).thenReturn(true);
+ sasl.when(() -> SASLAuthentication.verifyCertificates(any(), anyString(), anyBoolean())).thenReturn(true);
+
+ final boolean result = session.validateSubsequentDomain(dbResult);
+ assertTrue(result, "The subsequent domain must be validated successfully.");
+ }
+
+ // Verify result.
+ final ArgumentCaptor captor = ArgumentCaptor.forClass(String.class);
+ verify(connection, atLeastOnce().description("A reply must have been delivered to the peer.")).deliverRawText(captor.capture());
+ final String delivered = String.join("", captor.getAllValues());
+ assertTrue(delivered.contains("from=\"" + Fixtures.XMPP_DOMAIN + "\""), "The reply must report success from the normalised domain.");
+ assertFalse(delivered.contains("from=\"" + Fixtures.XMPP_DOMAIN.toUpperCase(Locale.ROOT) + "\""), "The reply must not report success from the domain in the case the peer wrote.");
+ }
+}