Skip to content

[#1196] Accept the DNS aliases of the realms as valid host names, so that a realm alias no longer needs its own entry in the FQDN map - #1197

Open
vharseko wants to merge 5 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1196-realm-dns-alias-fqdn
Open

vharseko wants to merge 5 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1196-realm-dns-alias-fqdn

Conversation

@vharseko

@vharseko vharseko commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Fixes #1196

Problem

A request addressed to a realm DNS alias is rejected by the FQDN check before the realm is resolved, unless the alias also has its own com.sun.identity.server.fqdnMap[...] entry. The valid host names come only from the FQDN map, the server host and the site hosts that NamingService.updateFqdnMappings adds, so a realm alias is never among them. Every place that checks the host is affected:

  • FQDNValidationFilter (/XUI/*, /oauth2/authorize, /oauth2/device/user) redirects with 302 to the default host.
  • LoginServlet (/UI/Login) redirects with 302 to the default host.
  • RealmContextFilter (REST) answers 400 FQDN "acme.example.com" is not valid.
  • ConsoleServletBase (admin console) redirects to the server host.

The strict check came with OPENAM-7340 to stop Host header injection, and the realm aliases were left out of it. The OpenAM guide does not ask for the extra FQDN map entry. PingAM, which continues ForgeRock AM, adds that entry automatically when an alias is saved in the admin UI.

Fix

  • FqdnValidator.isHostnameValid also accepts a host name that is a DNS alias of a realm. It consults the aliases only for a host that is neither a value nor a key of the FQDN map, and only once OpenAM is configured (AMSetupServlet.isCurrentConfigurationValid(), as MonitoringConfiguration does). A host that the FQDN map maps to another host name is still redirected there even if it is a realm alias, so a short-name mapping such as fqdnMap[openam]=openam.example.com keeps working although openam is an alias of the root realm by default. Requests to mapped hosts and the configurator are not affected. getFullyQualifiedHostName, which the filters, LoginServlet (through AuthClientUtils.getValidFQDNResource) and ConsoleServletBase call, follows isHostnameValid.
  • The new RealmDnsAliases keeps the aliases of all realms in memory, in lower case. A listener on sunIdentityRepositoryService, the same one CachingRealmLookup relies on, makes it read them again after the realm configuration changes. The aliases are read from the realms themselves, so it does not matter whether an alias was changed through the admin console, REST or ssoadm, and nothing is left behind in the FQDN map when an alias is removed.
  • A request with an unknown Host header does not search the configuration store: the realms are read once after each change. The listener is registered before the realms are read for the first time, unless addListener returns null, and a concurrent first request waits for the registration. A change notified while the realms are being read makes the next call read them again. If the idRepo service or the admin token cannot be obtained, or the realms cannot be read, for any reason including an unchecked exception, the host is rejected and the next request tries again. If addListener returns null, the realms are read without a listener and read again once a later request registers it.
  • In the coexistence mode with an AMSDK data store the aliases are read from the AMSDK organization, which OrganizationConfigManager.setAttributes writes after the realm entry. Aliases read in between are kept until the next change to the realm configuration; the Javadoc of RealmDnsAliases says so.
  • Creating, cloning or importing a server and creating a site add the host name of its URL to the aliases of the top-level realm, but nothing removed it, so the host names of deleted servers and sites would stay valid host names. ServerConfiguration.deleteServerInstance and SiteConfiguration.deleteSite now remove the host name of the deleted server or site, and SiteConfiguration.setSitePrimaryURL the host name of the replaced URL, unless the URL of a remaining server or site, primary or secondary, has the same host name. The host name is compared and removed regardless of letter case. A site whose access point has no primary URL is still deleted. If the host name cannot be removed at that moment, for example because the aliases or the URLs of the other servers and sites cannot be read or updated, the server or site is still deleted or its URL still changed, an error is logged and the host name stays. Host names left behind by servers and sites deleted before the upgrade stay, and a host name that the administrator added to these aliases by hand is removed together with the last server or site that has it. A new [IMPORTANT] block in the upgrade guide (chap-upgrade-servers.adoc) describes both and asks to review the aliases of every realm before promoting the upgrade.
  • RealmGuiceModule binds and exposes RealmDnsAliases, because the @Named(REPO_SERVICE) ServiceConfigManager it needs is bound only inside that private module.

An alias is configured by the administrator, just like an FQDN map entry, so the list of accepted host names stays under the administrator's control. A realm alias now behaves as the workaround entry fqdnMap[<alias>]=<alias> did, the admin console included: the console is served on an alias host instead of being redirected to the server host, and an administrator still has to log in. By default the root realm's aliases hold the host names of the servers and sites and the RDN value of the root suffix (for example openam), so these become valid host names as well, unless the FQDN map maps them to another host name.

Test

  • RealmDnsAliasesTest (20 tests): aliases of the root realm and of subrealms, letter case, an alias added or removed and a realm created after a configuration change, one read while the configuration is unchanged, a change notified during the read, a failed read, an admin token or an idRepo service that throws, a listener registration retried after a failure with the aliases read before it discarded, and a concurrent first request that waits for the registration.
  • RealmDnsAliasesConfigurationTest: the aliases of a nested realm are read through OrganizationConfigManager. It runs under PowerMock with @SuppressStaticInitializationFor, because the static initialiser of OrganizationConfigManager needs the configuration store.
  • FqdnValidatorTest (7 tests): a realm alias is accepted and not redirected, an unknown host is still redirected to the default host, a key of the FQDN map that is also a realm alias is still redirected to the host it is mapped to, and the aliases are not consulted for a host that is a key or a value of the FQDN map or before OpenAM is configured.
  • RealmGuiceModuleTest: the injector provides RealmDnsAliases as a single instance.
  • RootRealmAliasesTest (15 tests): deleting a server or a site, or replacing the primary URL of a site, removes its host name from the aliases of the top-level realm, also when the aliases hold it in another letter case, and keeps it while another server (also in other letter case), the primary or a secondary URL of a site, or the new URL of the site has it. A failure to read or update the aliases fails neither the delete of a server or site nor the change of the primary URL, a site without an access point is still rejected with ConfigurationException, and a site whose access point has no primary URL is still deleted. It runs the real ServerConfiguration and SiteConfiguration code against an in-memory platform configuration and top-level realm, under PowerMock (whenNew for ServiceConfigManager and OrganizationConfigManager).
  • Mutation checks: storing the snapshot with the version taken after the read fails the concurrent-change test, and dropping expose(RealmDnsAliases.class) fails the Guice test with ChildBindingAlreadySet. Not discarding the snapshot after the registration, or marking the instance initialised although addListener returned null, fails the retry test; registering without synchronized fails the concurrent-first-request test. Reading the subrealms non-recursively, reading sunOrganizationStatus instead of the aliases, or dropping the / before a subrealm name fails RealmDnsAliasesConfigurationTest. Checking the realm aliases before the FQDN map keys fails shouldNotReadRealmDnsAliasesForHostInFqdnMap. In RootRealmAliasesTest, removing the host name without checking the remaining URLs, comparing host names case-sensitively, ignoring the site URLs or the server URLs, or removing it before the site or its old URL is gone each fails at least one test, and so do removing the alias only in the exact letter case, letting an exception from the cleanup escape, reading the primary URL of a site before listServers, and reading it with getSitePrimaryURL, which throws for an access point without a primary URL.
  • mvn -pl openam-core,openam-rest -am clean test: openam-core 2258 tests and openam-rest 285 tests, no failures.

The change was not tried on a running OpenAM.

@vharseko
vharseko requested a review from maximthomas October 23, 2026 06:49
@vharseko vharseko added bug java Pull requests that update java code tests Test suite: coverage, fixtures, or test infrastructure labels Oct 23, 2026

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The snapshot logic is careful, and the two tests that guard it really do catch regressions.

  • RealmDnsAliases.reload() takes configVersion before the read, so a change notified during the read forces another read. shouldReadRealmsAgainWhenRealmConfigurationChangesWhileTheyAreRead holds it: with the version taken after the read it goes red (1 of 15 tests fails).
  • expose(RealmDnsAliases.class) is pinned too. Without it, RealmGuiceModuleTest fails with Guice/ChildBindingAlreadySet.
  • FqdnValidator.isRealmDnsAlias runs only for a host that is not an fqdnMap value, and only after AMSetupServlet.isCurrentConfigurationValid().

issue (blocking): init() marks itself initialised before the listener is registered. If the registration fails or comes late, the alias set stays frozen until restart.

openam-core/src/main/java/org/forgerock/openam/core/realms/RealmDnsAliases.java:82-89

compareAndSet(false, true) runs before idRepoServiceProvider.get().addListener(...), and nothing ever resets the flag. Three things can leave the instance without a listener for the life of the JVM, with no retry:

  • the provider throws. RealmGuiceModule.provideIdRepoService wraps SMSException/SSOException in a RuntimeException, and the admin token action can also throw.
  • addListener returns null.
  • a concurrent first caller loses the CAS. It then reads and stores a snapshot before the winner has registered, and a realm change in that gap is never counted.

From then on configVersion never moves, so the first stored snapshot is served for good. An alias the admin removes stays an accepted Host, which master rejects, and a new alias keeps getting the 302/400. The description says "a stale set is never kept".

    private void init() {
        if (initialised.get()) {
            return;
        }
        synchronized (this) {
            if (initialised.get()) {
                return;
            }
            if (idRepoServiceProvider.get().addListener(new RealmsChangeListener()) == null) {
                logger.warn("RealmDnsAliases: Failed to add ServiceListener, will retry on the next request");
                return;
            }
            // Discards a snapshot read while no listener was registered
            configVersion.incrementAndGet();
            initialised.set(true);
        }
    }

If the provider throws, initialised now stays false and the next call retries. The throw itself is the non-blocking issue below. shouldRegisterServiceListenerOnlyOnce stays green.

Pin: let addListener return null and then "LISTENER_ID", and in a second case let the idRepo provider throw once. In both, change aliasesByRealm between two contains() calls without notifying, and assert that the second call sees the change. Both cases are red on the head.


question (blocking): Should a host that is an fqdnMap key and also a realm alias still be redirected to its mapped FQDN?

openam-core/src/main/java/com/sun/identity/common/FqdnValidator.java:110, :126-131; openam-core/src/main/java/com/sun/identity/setup/ConfigureData.java:134-138

getFullyQualifiedHostName(h) reads fqdnMap.get(h) only when isHostnameValid(h) is false. isHostnameValid checks the map's values and, since this PR, the realm aliases, but never the map's keys. By default the root realm's aliases include the RDN of the root suffix, which is openam for dc=openam,dc=openidentityplatform,dc=org. Now take com.sun.identity.server.fqdnMap[openam]=openam.example.com, the documented short-name mapping. On master, GET http://openam:8080/openam/XUI/ gets a 302 to openam.example.com. On this head the request is served on the short host, where cookies scoped to .example.com do not apply. ConsoleServletBase.validateHost also loses the mapping. The description says that mapped hosts are not affected. That is true for map values but not for keys.

If it is intended, the description must say that such mappings stop redirecting, and the test suggested below must pin the head's result (null). If it is not intended, it is a blocking regression, and this is the fix:

        return fqdnMap.values().contains(lowerCaseHostname)
                || (!fqdnMap.containsKey(lowerCaseHostname) && isRealmDnsAlias(lowerCaseHostname));

fqdnMap keys are already lower-cased (FqdnValidator.java:86). getFullyQualifiedHostName and the console both go through isHostnameValid, so they follow.


issue (non-blocking): A RuntimeException from the alias lookup now escapes isHostnameValid. The result is a 500 instead of the rejection that the description promises.

openam-core/src/main/java/org/forgerock/openam/core/realms/RealmDnsAliases.java:74-80, :109; openam-core/src/main/java/com/sun/identity/common/FqdnValidator.java:113-117

reload() catches only SMSException. Neither contains, isRealmDnsAlias, AuthClientUtils.isValidFQDNRequest nor CoreWrapper.isValidFQDN catches anything else. The exception can come from the AMSecurityPropertiesException of AdminTokenAction through CoreGuiceModule.provideAdminSSOToken, or from the RuntimeException that the idRepo provider throws in init(). In both cases a request with an unmapped Host gets a container 500 from FQDNValidationFilter and an unchecked error from RealmContextFilter. Before this PR it got a 302 or a 400, because isHostnameValid could not throw. Catching in contains() covers both sources, init() included:

    public boolean contains(String hostname) {
        if (hostname == null) {
            return false;
        }
        try {
            init();
            return getAliases().contains(hostname.toLowerCase());
        } catch (RuntimeException e) {
            logger.warn("RealmDnsAliases: Unable to read the DNS aliases of the realms", e);
            return false;
        }
    }

question (non-blocking): Is the admin console meant to be served on every realm-alias host?

openam-console/src/main/java/com/sun/identity/console/base/ConsoleServletBase.java:362; openam-core/src/main/java/com/sun/identity/authentication/client/AuthClientUtils.java:1585

validateHost calls getFullyQualifiedHostName, which now returns null for an alias. Take Host: acme.example.com, an alias of /acme, on /base/AMAdminFrame: master sends a 302 to the server host, while this head serves the console on the alias host. The description says "All three places that check the host are affected" and lists only the three filters. As a gap in the description this is minor. It becomes blocking if the console was meant to stay on the server host. If the change is intended, list ConsoleServletBase and AuthClientUtils.getValidFQDNResource in the description.


suggestion (non-blocking): No test pins what an fqdnMap key that is also a realm alias resolves to.

openam-core/src/test/java/com/sun/identity/common/FqdnValidatorTest.java:49-50, :77, :83

The setup maps fqdnMap[openam], but the realmDnsAliases mock answers false for openam. getFullyQualifiedHostName is called only for acme.example.com and attacker.example.org. Master's key-first order and this head's alias-first order both pass all 6 tests. The test below fails on the current head and passes with the fix above. If the head's result is intended, assert isNull() instead.

    @Test
    public void shouldRedirectFqdnMapKeyThatIsAlsoARealmDnsAlias() {
        given(realmDnsAliases.contains("openam")).willReturn(true);

        assertThat(FqdnValidator.getInstance().getFullyQualifiedHostName("openam")).isEqualTo("openam.example.com");
    }

suggestion (non-blocking): RealmDnsAliasesTest stubs out the whole realm read, so no test runs the code that reads aliases from the configuration.

openam-core/src/test/java/org/forgerock/openam/core/realms/RealmDnsAliasesTest.java:73-102

The anonymous subclass overrides both getSubRealms and getRealmAliases. As a result RealmDnsAliases.java:131-145 never runs and the organizationConfigManagerFactory mock is never called. All 21 tests would pass with getSubOrganizationNames("*", false), with ORGANIZATION_STATUS_ATTR read instead of ORGANIZATION_ALIAS_ATTR, or with the "/" prefix dropped. The current code is correct. Only one test is needed on a RealmDnsAliases that is not subclassed, and it fails against each of those mutants:

    @Test
    public void shouldReadDnsAliasesOfNestedRealmsFromTheConfiguration() throws Exception {
        OrganizationConfigManager root = mock(OrganizationConfigManager.class);
        OrganizationConfigManager sub = mock(OrganizationConfigManager.class);
        given(organizationConfigManagerFactory.create(adminToken, "/")).willReturn(root);
        given(organizationConfigManagerFactory.create(adminToken, "/acme/sub")).willReturn(sub);
        given(root.getSubOrganizationNames("*", true)).willReturn(asSet("acme/sub"));
        given(sub.getAttributes(IdConstants.REPO_SERVICE)).willReturn(
                Collections.singletonMap(IdConstants.ORGANIZATION_ALIAS_ATTR, asSet("sub.acme.example.com")));
        RealmDnsAliases aliases = new RealmDnsAliases(() -> adminToken, organizationConfigManagerFactory,
                () -> idRepoService);

        assertThat(aliases.contains("sub.acme.example.com")).isTrue();
    }

issue (non-blocking): In AMSDK coexistence mode, an alias change can leave the old aliases stored under the new version.

openam-core/src/main/java/com/sun/identity/sm/OrganizationConfigManager.java:1032-1045, :797-820

In coexistence mode, setAttributes first saves the SMS realm entry, which schedules the notification, and only then calls amsdk.setAttributes. getAttributes returns the merged AMSDK aliases. Suppose a contains() call lands between the two writes. It captures the new version, reads the old AMSDK value and stores it, so the new alias is rejected and the old one accepted until the next realm change. This contradicts "a stale set is never kept". Not run: it needs a coexistence deployment and a concurrent probe. A sentence in the Javadoc of RealmDnsAliases is enough.

@vharseko
vharseko force-pushed the issue-1196-realm-dns-alias-fqdn branch from 9cafa0e to dca0dee Compare October 27, 2026 09:45
@vharseko

Copy link
Copy Markdown
Member Author

Round 2, rebased onto current master.

issue (blocking): init() marks itself initialised before the listener is registered. Fixed as suggested. init() registers the listener under synchronized, stays uninitialised when addListener returns null or the idRepo provider throws, and bumps configVersion once the listener is registered, so aliases read before that are read again. A concurrent first caller now waits for the registration instead of reading the realms.

One change to the pins. For the provider that throws once, "change aliasesByRealm between two contains() calls without notifying" stays red with the fix as well: the second call registers the listener, and from then on a change is expected to be notified, so a change that is not notified is not seen, by design. That case is pinned instead by shouldRegisterServiceListenerAfterIdRepoServiceCouldNotBeObtained, which checks that the next call registers the listener; on the old head it is never registered. The null → "LISTENER_ID" case is pinned as you proposed (shouldReadRealmsAgainOnceServiceListenerIsRegisteredAfterFailedRegistration), and the concurrent first caller by shouldNotReadRealmsWhileServiceListenerIsBeingRegistered. Mutants killed: no configVersion bump after the registration, initialised set although addListener returned null, the registration without synchronized.

question (blocking): an fqdnMap key that is also a realm alias. Not intended. Fixed with your condition in FqdnValidator.isHostnameValid, and pinned by your test, shouldRedirectFqdnMapKeyThatIsAlsoARealmDnsAlias, which was red on the old head.

issue (non-blocking): a RuntimeException gives a 500. Fixed. contains() catches SMSException and RuntimeException in one place (the SMSException catch moved there from reload()), so init() is covered as well. Pinned by shouldRejectHostWhenAdminTokenCannotBeObtained (an AMSecurityPropertiesException, as AdminTokenAction throws) and shouldRejectHostWhenServiceListenerCannotBeRegistered (the RuntimeException of provideIdRepoService).

question (non-blocking): the admin console on realm-alias hosts. Intended. A realm alias now behaves as the workaround from #1196, fqdnMap[<alias>]=<alias>, which serves the console on the alias host too; an administrator still has to log in. The description now lists ConsoleServletBase and says this. getValidFQDNResource is not a separate check, since it runs only after isValidFQDNRequest returned false, so the description mentions it as part of the LoginServlet path.

suggestion (non-blocking): a test for an fqdnMap key that is also a realm alias. Added, see above.

suggestion (non-blocking): a test that reads the aliases from the configuration. Added, but in a class of its own, RealmDnsAliasesConfigurationTest. mock(OrganizationConfigManager.class) cannot work in RealmDnsAliasesTest: creating the mock runs the static initialiser of OrganizationConfigManager, which initialises SMSEntry, which needs the configuration store (ExceptionInInitializerError caused by AMSecurityPropertiesException: AdminTokenAction: FATAL ERROR: Cannot obtain Application SSO token). The new class runs under PowerMock with @SuppressStaticInitializationFor("com.sun.identity.sm.OrganizationConfigManager"), as AMSendMailTest does, so the other tests stay on the plain class loader. Each of the three mutants you listed (getSubOrganizationNames("*", false), ORGANIZATION_STATUS_ATTR, no "/" prefix) is killed.

issue (non-blocking): AMSDK coexistence mode. A sentence added to the Javadoc of RealmDnsAliases. The description no longer says "a stale set is never kept" and describes this case instead.

mvn -pl openam-core,openam-rest -am clean test: openam-core 2243 tests and openam-rest 285 tests, no failures.

@vharseko
vharseko requested a review from maximthomas October 27, 2026 09:46
@vharseko vharseko added the concurrency Thread safety, races and synchronization label Oct 27, 2026

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The round-1 findings are fixed where the bugs were, and the fixes are measured.

  • FqdnValidator.isHostnameValid (FqdnValidator.java:111-112) keeps an FQDN map key redirected with !fqdnMap.containsKey, pinned by shouldRedirectFqdnMapKeyThatIsAlsoARealmDnsAlias.
  • RealmDnsAliases.init() registers the listener under synchronized and bumps configVersion once it is registered; with the synchronized removed, shouldNotReadRealmsWhileServiceListenerIsBeingRegistered failed 50 of 50 invocations (JDK 26).
  • contains() catches SMSException | RuntimeException in one place (RealmDnsAliases.java:85), so a broken admin token or store rejects the host instead of answering 500.

issue (blocking): After the upgrade, the host names of deleted servers and sites become valid hosts, and nothing tells the operator.

openam-core/src/main/java/com/sun/identity/common/FqdnValidator.java:111-112, openam-core/src/main/java/com/sun/identity/common/configuration/ConfigurationBase.java:186-209, openam-core/src/main/java/com/sun/identity/common/configuration/ServerConfiguration.java:593-608, openam-core/src/main/java/com/sun/identity/common/configuration/SiteConfiguration.java:183-198

Server create, clone and import (ServerConfiguration.java:676, :1006, :1134), site create (SiteConfiguration.java:247) and setup (AMSetupServlet.java:1622-1630) add their host to the top-level realm's sunOrganizationAliases through updateOrganizationAlias(..., true). No caller passes false, and deleteServerInstance and deleteSite only remove the sub-configuration. So an install that once had http://old.example.com:8080/openam still lists old.example.com. At the base, Host: old.example.com was redirected to the default host; at this head, FQDNValidationFilter, LoginServlet, RealmContextFilter and the console accept it. If that name or its DNS record is no longer under the operator's control, the OPENAM-7340 check no longer protects it.

The description says the server host names become valid, but not the names of deleted servers. A note in the PR body would not reach operators either, because the release notes list only PR titles. Is accepting the deleted ones intended? If not, you can also remove the host on delete: call updateOrganizationAlias(ssoToken, <url>, false) after removeSubConfig, but only when no remaining server or site URL has the same host, because two servers on one host share the alias. That does not clean up the entries left by earlier deletes, so the note is needed either way. Not checked: whether a site's secondary URLs or a changed server URL also add aliases.

In openam-documentation/openam-doc-source/src/main/asciidoc/upgrade-guide/chap-upgrade-servers.adoc, next to the other "Starting with this release" blocks:

[IMPORTANT]
====
Starting with this release a host name that is a DNS alias of a realm is accepted as a valid host name, as if it had its own `com.sun.identity.server.fqdnMap[<alias>]=<alias>` entry, unless the FQDN map maps it to another host name. By default the DNS aliases of the top-level realm hold the host name of every server and site that was ever created, including servers and sites that were deleted later, and the RDN value of the root suffix (for example `openam`). Before promoting the upgrade, review the DNS aliases of every realm and remove the host names that are no longer in use or no longer under your control.
====

suggestion (non-blocking): No test checks that the aliases are skipped for an FQDN map key.

openam-core/src/test/java/com/sun/identity/common/FqdnValidatorTest.java:95-100

shouldNotReadRealmDnsAliasesForHostInFqdnMap calls isHostnameValid only for map values (lb.example.com, openam.example.com), and the key test checks only the outcome. If the guard is reordered to isRealmDnsAlias(h) && !fqdnMap.containsKey(h), every outcome stays the same and all 7 tests pass (traced, not run). Under that order, a request for a short-name key host registers the listener and reads the realms.

assertThat(FqdnValidator.getInstance().isHostnameValid("openam")).isFalse();

Pin: add this line to shouldNotReadRealmDnsAliasesForHostInFqdnMap, before verifyZeroInteractions(realmDnsAliases). openam is a key in the fixture's map, so both a reordered guard and a dropped guard call contains("openam"), and verifyZeroInteractions fails.


nitpick (non-blocking): The description says the host is rejected when the listener cannot be registered, but when addListener returns null the alias is accepted.

openam-core/src/main/java/org/forgerock/openam/core/realms/RealmDnsAliases.java:99-101, openam-core/src/test/java/org/forgerock/openam/core/realms/RealmDnsAliasesTest.java:238

ServiceConfigManager.addListener reports a failure only by returning null. init() then returns, contains() reads the realms, and RealmDnsAliasesTest.java:255-258 asserts isTrue() for it. That is correct, since the next successful registration bumps configVersion. Only an idRepo provider or admin token that throws makes the host rejected (:231-241). Two sentences in the description do not match this: "The listener is registered before the realms are read for the first time" and "If the listener cannot be registered ... the host is rejected". Also, shouldRejectHostWhenServiceListenerCannotBeRegistered tests a throwing provider, not a failed registration.

Suggested wording for the Fix bullet: "If the idRepo service or the admin token cannot be obtained, or the realms cannot be read, the host is rejected and the next request tries again. If addListener returns null, the realms are read without a listener and read again once a later request registers it." Also add "unless addListener returns null" to "The listener is registered before the realms are read for the first time". You could also rename the test to shouldRejectHostWhenIdRepoServiceCannotBeObtained.

@vharseko
vharseko force-pushed the issue-1196-realm-dns-alias-fqdn branch from dca0dee to e3e42d2 Compare October 27, 2026 13:21
@vharseko vharseko added documentation Documentation, README, or javadoc security Security fix or hardening (CVE, GHSA, XSS/CSRF/SSRF) labels Oct 27, 2026
@vharseko

Copy link
Copy Markdown
Member Author

Round 3, rebased onto current master.

issue (blocking): the host names of deleted servers and sites become valid hosts. Not intended. Fixed as you suggested, plus one more path. ServerConfiguration.deleteServerInstance and SiteConfiguration.deleteSite remove the host name from the aliases of the top-level realm after removeSubConfig, unless the URL of a remaining server or site, primary or secondary, has the same host name (compared case-insensitively). Two servers on one host, or a server on the host of a site, keep the alias.

On your "Not checked": a secondary site URL never adds an alias, since the only callers of updateOrganizationAlias(..., true) are the four you listed. But SiteConfiguration.setSitePrimaryURL changes the primary URL without touching the aliases, so the old host name stayed behind as well. It is now removed the same way. As before, the new host name is not added; that is outside this PR. One correction that does not change the conclusion: AMSetupServlet.java:1622-1630 (updatePlatformServerList) is never called. Setup adds the host through ServerConfiguration.createServerInstance (AMSetupServlet.java:814).

The upgrade note is in chap-upgrade-servers.adoc. It is your text, extended with the removal on delete. It also warns that a host name the administrator added to the top-level realm by hand, for example a load balancer's, is removed together with the last server or site that has it.

Pinned by RootRealmAliasesTest (9 tests). It runs the real ServerConfiguration and SiteConfiguration code against an in-memory platform configuration and top-level realm, under PowerMock with whenNew for ServiceConfigManager and OrganizationConfigManager. The three removal tests (server, site, replaced primary URL) were red on the previous head. Mutants killed:

  • no check of the remaining URLs (6 tests fail);
  • a case-sensitive comparison;
  • ignoring the site URLs;
  • ignoring the server URLs;
  • removing the alias before removeSubConfig of the site;
  • removing the alias before the primary URL is replaced.

suggestion (non-blocking): pin that the aliases are skipped for an FQDN map key. Added your line to shouldNotReadRealmDnsAliasesForHostInFqdnMap. With the guard reordered to isRealmDnsAlias(h) && !fqdnMap.containsKey(h), the test now fails at verifyZeroInteractions (run, not traced).

nitpick (non-blocking): the description and addListener returning null. The description now uses your wording for both sentences, and the test is renamed to shouldRejectHostWhenIdRepoServiceCannotBeObtained.

mvn -pl openam-core,openam-rest -am clean test: openam-core 2252 tests and openam-rest 285 tests, no failures.

@vharseko
vharseko requested a review from maximthomas October 27, 2026 13:22

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: the cleanup on delete keeps every host that is still in use, and the upgrade note covers what it cannot clean.

  • removeOrganizationAlias checks every remaining server and site URL, secondary site URLs included, case-insensitively before it removes a host (ConfigurationBase.java:228-234).
  • The [IMPORTANT] block in chap-upgrade-servers.adoc tells operators that servers and sites deleted before the upgrade left their host names behind, and to add a hand-added alias such as a load balancer's host name again after deleting the last server or site that has it.

issue (non-blocking): removeOrganizationAlias keeps a deleted server's alias when its letter case differs from the URL deleted last.

openam-core/src/main/java/com/sun/identity/common/configuration/ConfigurationBase.java:231-235, :206-207

The remaining-URL check ignores case (:231), but the removal is delegated to updateOrganizationAlias(..., false), which matches exact case (:206-207), and the add path (:201) stores the case of the first server's URL. With http://A.example.com:8080/openam and http://a.example.com:9090/openam, delete A, then a: the alias A.example.com stays (OpenDJ matches sunKeyValue with caseIgnoreMatch, so only one case is stored), and RealmDnsAliases lower-cases it, so the host of two deleted servers stays a valid host. shouldKeepHostOfDeletedServerThatAnotherServerHasInOtherCase seeds only the lower-case alias and never deletes the last server.

Replace updateOrganizationAlias(ssoToken, url, false); at :235 with:

        OrganizationConfigManager ocm = new OrganizationConfigManager(ssoToken, "/");
        Map<String, Set<String>> allAttrs = ocm.getAttributes(ServiceManager.REALM_SERVICE);
        Set<String> values = allAttrs.get(OrganizationConfigManager.SUNORG_ALIAS);
        if (values != null && values.removeIf(hostName::equalsIgnoreCase)) {
            ocm.setAttributes(ServiceManager.REALM_SERVICE, allAttrs);
        }

The add path (:201) can stay as it is: the case-insensitive removal clears every case variant it may have stored.

Pin: in RootRealmAliasesTest, seed A.example.com, create both servers, delete A and then a, and assert that the alias is gone.


issue (non-blocking): an exception from the alias cleanup turns a committed server or site delete into a failed one.

openam-core/src/main/java/com/sun/identity/common/configuration/ServerConfiguration.java:603-609, SiteConfiguration.java:200-202, :492

removeOrganizationAlias runs after removeSubConfig or accessPoint.setAttributes has committed, and nothing catches what it throws. That can be an SMSException from a store fault in getServers, getAllSiteURLs or the root-realm read or write. It can also be an NPE from getSiteURLs for any site that has no accesspoint subconfig, because createSite writes the site and its accesspoint in two separate writes. When that happens, ServersResourceProvider.delete answers 500 and a retry gets 404. ServerSiteModelImpl.deleteServers reports a failure for a server that is already gone and skips the rest of the selection. While a half-created site exists, this repeats on every server and site delete. At the base these deletes returned true. If the cleanup fails, the alias stays behind, as it did at the base, and the new upgrade note already covers that.

Make the cleanup best-effort in removeOrganizationAlias, which covers all three callers:

    protected static void removeOrganizationAlias(SSOToken ssoToken, String url) {
        String hostName = getHostName(url);
        if (hostName == null) {
            return;
        }
        try {
            Set<String> urls = new HashSet<>(ServerConfiguration.getServers(ssoToken));
            urls.addAll(SiteConfiguration.getAllSiteURLs(ssoToken));
            for (String remainingUrl : urls) {
                if (hostName.equalsIgnoreCase(getHostName(remainingUrl))) {
                    return;
                }
            }
            updateOrganizationAlias(ssoToken, url, false);
        } catch (SMSException | SSOException | RuntimeException e) {
            Debug.getInstance(SetupConstants.DEBUG_NAME).warning("ConfigurationBase.removeOrganizationAlias: could not remove "
                    + hostName + " from the DNS aliases of the top-level realm", e);
        }
    }

This also needs import com.sun.identity.setup.SetupConstants; and import com.sun.identity.shared.debug.Debug;, as in ServerConfigXMLObserver.java:35-36, and the two @throws lines removed from its Javadoc. The callers keep their throws clauses. If you also take the previous fix, its lines replace the updateOrganizationAlias call inside the try.

…alid host names, so that a realm alias no longer needs its own entry in the FQDN map

FqdnValidator now also accepts a host name that is a DNS alias of a realm.
RealmDnsAliases keeps the aliases of all realms in memory and reads them
again after the realm configuration changes, so a request with an unknown
Host header does not search the configuration store. The aliases are only
consulted for a host missing from the FQDN map, and only once OpenAM is
configured.

Fixes OpenIdentityPlatform#1196
…ere redirected even if it is a realm alias, and retry the listener registration of RealmDnsAliases after a failure

- FqdnValidator consults the realm aliases only for a host that is not a key
  of the FQDN map, so a mapping such as fqdnMap[openam]=openam.example.com
  keeps redirecting although openam is an alias of the root realm by default.
- RealmDnsAliases registers its listener under a lock and stays uninitialised
  until the registration succeeds, so a failed registration is retried and the
  aliases read meanwhile are read again; a concurrent first request waits for
  the registration.
- RealmDnsAliases.contains rejects the host when the admin token, the idRepo
  service or the realms cannot be obtained, instead of letting an unchecked
  exception fail the request.
- RealmDnsAliasesConfigurationTest reads the aliases through
  OrganizationConfigManager.
…or site from the aliases of the top-level realm, so that it does not stay a valid host name

- ServerConfiguration.deleteServerInstance and SiteConfiguration.deleteSite
  remove the host name of the deleted server or site from the DNS aliases of
  the top-level realm, and SiteConfiguration.setSitePrimaryURL the host name
  of the replaced URL, unless the URL of a remaining server or site has it.
- The upgrade guide says that the DNS aliases of the realms are now valid
  host names and asks to review the aliases left by earlier deletes.
- FqdnValidatorTest pins that the aliases are not read for a key of the FQDN
  map; a RealmDnsAliasesTest test is renamed after what it checks.
…ite in any letter case, and keep the delete when the aliases cannot be updated

- ConfigurationBase.updateOrganizationAlias removes the host name from the
  DNS aliases of the top-level realm regardless of letter case, so an alias
  stored in the case of another server's URL does not stay behind once the
  last server or site with that host is deleted.
- ConfigurationBase.removeOrganizationAlias only logs a warning when the
  aliases cannot be read or updated, because the server, site or URL is
  already gone by then; the upgrade guide says so.
- SiteConfiguration.deleteSite reads the primary URL after listServers, so a
  site without an access point is rejected with ConfigurationException again
  instead of a NullPointerException.
@vharseko
vharseko force-pushed the issue-1196-realm-dns-alias-fqdn branch from e3e42d2 to f85eca8 Compare October 28, 2026 07:00
@vharseko

Copy link
Copy Markdown
Member Author

Round 4, rebased onto current master.

issue (non-blocking): the alias stays when its letter case differs from the URL deleted last. Fixed, one level lower than you suggested. removeOrganizationAlias is the only caller that passes false to updateOrganizationAlias, so instead of a second copy of the top-level realm read and write, that branch now does values.removeIf(hostName::equalsIgnoreCase). The add path is unchanged. Pinned by shouldRemoveHostOfLastDeletedServerThatTheAliasesHaveInOtherCase, your scenario: A.example.com seeded, then http://A.example.com:8080/openam and http://a.example.com:9090/openam deleted in that order. It was red on the previous head, with A.example.com left behind. The same miss happened with a single site whose primary URL was changed only in letter case before the site was deleted, and the fix covers that too.

issue (non-blocking): an exception from the alias cleanup turns a committed delete into a failed one. Fixed as you suggested. removeOrganizationAlias catches SMSException | SSOException | RuntimeException and logs a warning to the amSetupServlet debug log. Its @throws lines are gone, and the callers keep their throws clauses. The upgrade note now says that when the aliases cannot be updated, the server or site is still deleted or its URL still changed, and the host name stays. Pinned once per caller, each red on the previous head:

  • shouldDeleteServerWhenRootRealmAliasesCannotBeUpdated: setAttributes of the top-level realm throws SMSException;
  • shouldDeleteSiteWhenAnotherSiteHasNoAccessPoint: the NPE from getAllSiteURLs for a half-created site;
  • shouldReplaceSitePrimaryUrlWhenRootRealmAliasesCannotBeRead: getAttributes of the top-level realm throws SMSException.

One more case of the same kind lies outside removeOrganizationAlias. deleteSite read the primary URL before listServers, so deleting the half-created site itself threw an NPE from getSitePrimaryURL. At the base, listServers rejects such a site with ConfigurationException("invalid.site.instance"), which the console and ssoadm delete-site report as an error message. deleteSite now reads the URL after listServers, which restores that. Pinned by shouldRejectDeletingSiteWithoutAccessPointAsInvalidSite. Such a site still cannot be deleted, as at the base, which is outside this PR. The REST delete is not affected either way, because SitesResourceProvider.delete calls listServers first.

mvn -pl openam-core,openam-rest -am clean test: openam-core 2257 tests and openam-rest 285 tests, no failures.

@vharseko
vharseko requested a review from maximthomas October 28, 2026 07:00

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: this round closes both cleanup defects from round 3 without changing how a delete behaves.

  • ConfigurationBase.removeOrganizationAlias catches SMSException | SSOException | RuntimeException after the server or site is gone (ConfigurationBase.java:237), so a committed delete is no longer reported as a failure.
  • values.removeIf(hostName::equalsIgnoreCase) (ConfigurationBase.java:208) removes the host in whatever letter case the aliases hold it.
  • deleteSite reads the primary URL after listServers, so a site without an access point is still rejected with ConfigurationException, pinned by shouldRejectDeletingSiteWithoutAccessPointAsInvalidSite.

issue (non-blocking): deleteSite now fails with an NPE, and keeps the site, when the site's access point has no primary-url.

openam-core/src/main/java/com/sun/identity/common/configuration/SiteConfiguration.java:196, :389-391

getSitePrimaryURL calls ((Set) map.get(ATTR_PRIMARY_SITE_URL)).iterator().next(). In deleteSite it runs before sc.removeSubConfig(siteName), outside the catch in removeOrganizationAlias. ssoadm set-sub-cfg -o delete on the site's accesspoint removes primary-url with no schema check, and after that delete-site, REST and the console all fail on that site. BASE deleted it, because listServers reads only primary-siteid (ConfigurationBase.java:150-153) and belongToSite reads only the server's site. Reaching this state takes a hand-edited entry. setSitePrimaryURL already fails on the same entry at BASE (existingSet.contains), so this comment leaves it alone.

                Set svrs = listServers(ssoToken, siteName);
                Set primaryURLs = (Set) cfg.getSubConfig(SUBCONFIG_ACCESS_URL).getAttributes().get(ATTR_PRIMARY_SITE_URL);
                String siteURL = (primaryURLs == null || primaryURLs.isEmpty())
                        ? null : (String) primaryURLs.iterator().next();
                if ((svrs != null) && !svrs.isEmpty()) {
                    removeServersFromSite(ssoToken, siteName, svrs);
                }
                sc.removeSubConfig(siteName);
                deleted = true;
                if (siteURL != null) {
                    removeOrganizationAlias(ssoToken, siteURL);
                }

Pin: a RootRealmAliasesTest case with a site whose access point has primary-siteid but no primary-url. deleteSite must return true and remove the site config. At this head it throws an NPE.


issue (non-blocking): removing the alias is an unguarded read-modify-write of the root realm's whole sunIdentityRepositoryService map.

openam-core/src/main/java/com/sun/identity/common/configuration/ConfigurationBase.java:199-209, :229-236

removeOrganizationAlias checks the remaining URLs. Then updateOrganizationAlias reads every attribute of the realm service, removes the host and writes the whole map back, with no lock or compare. Suppose a server delete overlaps an admin save of the root realm, for example a new DNS alias. The delete then writes back the map it read before that save, and the save is lost. A server created with the same host between the check and the write loses its alias the same way. Server and site hosts stay valid through NamingService.updateFqdnMappings, and the add path has the same pattern at BASE. No change is asked in this PR: a synchronized method would protect one JVM only.


issue (non-blocking): the upgrade guide promises a warning that a default install does not write.

openam-documentation/openam-doc-source/src/main/asciidoc/upgrade-guide/chap-upgrade-servers.adoc:111, openam-core/src/main/java/com/sun/identity/common/configuration/ConfigurationBase.java:238

The guide says that when the aliases cannot be updated, "OpenAM logs a warning in the amSetupServlet debug log". Debug.warning is written only when warningEnabled(), and the shipped level is com.iplanet.services.debug.level=error. So an operator who follows the guide finds nothing, and the host is still accepted. The sentence also gives a narrower cause than the code does: shouldDeleteSiteWhenAnotherSiteHasNoAccessPoint pins the host staying because another site's URLs cannot be read. (The round-3 review suggested warning itself.) Suggested guide text: "If the host name cannot be removed at that moment, for example because the DNS aliases or the URLs of the other servers and sites cannot be read or updated, the server or site is still deleted or its URL still changed, OpenAM logs an error in the amSetupServlet debug log, and the host name stays."

        } catch (SMSException | SSOException | RuntimeException e) {
            Debug.getInstance(SetupConstants.DEBUG_NAME).error("ConfigurationBase.removeOrganizationAlias: "
                    + "Unable to remove " + hostName + " from the DNS aliases of the top-level realm", e);
        }

Or: keep warning, and have the guide say that the message is written only at debug level warning or message.

…rimary URL, and log a failed alias cleanup as an error

- SiteConfiguration.deleteSite reads the primary URL from the access point
  itself and skips the alias cleanup when the URL is missing, so a site whose
  primary-url was removed by hand is deleted again, as before this change,
  instead of failing with a NullPointerException.
- ConfigurationBase.removeOrganizationAlias logs a failed cleanup as an error,
  because the shipped debug level drops warnings and the host name stays a
  valid host name. The upgrade guide says so, and also names the URLs of the
  other servers and sites that cannot be read as a cause.
@vharseko

Copy link
Copy Markdown
Member Author

Round 5. The branch already sits on current master, so no rebase was needed.

issue (non-blocking): deleteSite fails with an NPE when the access point has no primary-url. Fixed as you suggested. After listServers, deleteSite reads primary-url from the access point itself, accepts a missing or empty value, and then skips the alias cleanup. The public getSitePrimaryURL is unchanged. Pinned by shouldDeleteSiteWhoseAccessPointHasNoPrimaryUrl: a site with primary-siteid and a member server, but no primary-url. deleteSite returns true, the site is gone and the aliases stay as they were. On the previous head it was red with the NPE from getSitePrimaryURL (SiteConfiguration.java:391). For such a site the RootRealmAliasesTest fixture now leaves primary-url out.

issue (non-blocking): the alias removal is an unguarded read-modify-write of the root realm's sunIdentityRepositoryService map. No change in this PR, as you proposed. The add path has the same pattern at the base, so a fix covers both and belongs in an issue of its own.

issue (non-blocking): the upgrade guide promises a warning that a default install does not write. Fixed with the first option. removeOrganizationAlias now logs the failure with error. A host name left in the aliases stays a valid host name, and the shipped com.iplanet.services.debug.level=error (serverdefaults.properties:31) drops a warning. The guide sentence is your text. The PR description says "an error is logged" now too. No test pins the log level.

mvn -pl openam-core,openam-rest -am clean test: openam-core 2258 tests and openam-rest 285 tests, no failures.

@vharseko
vharseko requested a review from maximthomas October 28, 2026 09:17

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: This round removes the last way a site delete could fail on its own alias cleanup, and the upgrade guide matches the code.

  • SiteConfiguration.deleteSite reads the access point's primary-url itself and handles both a missing and an empty value (SiteConfiguration.java:196-200), so a site without a primary URL is deleted and no alias removal is attempted for it.
  • ConfigurationBase.removeOrganizationAlias now logs at error (ConfigurationBase.java:238), so the failure reaches the default amSetupServlet debug log, and chap-upgrade-servers.adoc says "logs an error".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug concurrency Thread safety, races and synchronization documentation Documentation, README, or javadoc java Pull requests that update java code security Security fix or hardening (CVE, GHSA, XSS/CSRF/SSRF) tests Test suite: coverage, fixtures, or test infrastructure

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Realm DNS Alias Is Rejected by FQDN Validation Unless Added to the Server FQDN Map

2 participants