This is an automated email from the ASF dual-hosted git repository.

weizhouapache pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new 4e4a26b49ef dns: prevent creation of cross-tenant conflicting DNS 
zones in the same server (#13946)
4e4a26b49ef is described below

commit 4e4a26b49ef19e892a4b695b6291b21f2117c70b
Author: Fabricio Duarte <[email protected]>
AuthorDate: Mon Aug 24 10:29:25 2026 -0300

    dns: prevent creation of cross-tenant conflicting DNS zones in the same 
server (#13946)
---
 .../cloudstack/dns/DnsProviderManagerImpl.java     | 42 +++++++++++-
 .../org/apache/cloudstack/dns/dao/DnsZoneDao.java  |  2 +
 .../apache/cloudstack/dns/dao/DnsZoneDaoImpl.java  | 24 +++++--
 .../cloudstack/dns/DnsProviderManagerImplTest.java | 76 ++++++++++++++++++++++
 ui/src/views/network/dns/AddDnsServer.vue          |  7 +-
 ui/src/views/network/dns/UpdateDnsServer.vue       |  7 +-
 6 files changed, 145 insertions(+), 13 deletions(-)

diff --git 
a/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java 
b/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java
index b451da1baf7..e1ace5d7a3c 100644
--- a/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/dns/DnsProviderManagerImpl.java
@@ -184,6 +184,11 @@ public class DnsProviderManagerImpl extends ManagerBase 
implements DnsProviderMa
             publicDomainSuffix = 
DnsProviderUtil.normalizeDomainForDb(publicDomainSuffix);
         }
 
+        if (isDnsPublic && StringUtils.isBlank(publicDomainSuffix)) {
+            throw new InvalidParameterValueException("A public DNS server 
requires a public domain suffix so that " +
+                    "DNS zones created by other accounts are contained under 
it.");
+        }
+
         DnsProviderType type = cmd.getProvider();
         DnsServerVO server = new DnsServerVO(cmd.getName(), cmd.getUrl(), 
cmd.getPort(), type,
                 cmd.getDnsUserName(), cmd.getDnsApiKey(), isDnsPublic, 
publicDomainSuffix, cmd.getNameServers(),
@@ -273,12 +278,20 @@ public class DnsProviderManagerImpl extends ManagerBase 
implements DnsProviderMa
         if (accountMgr.isRootAdmin(caller.getId()) || 
accountMgr.isDomainAdmin(caller.getId())) {
             if (cmd.isPublic() != null) {
                 boolean isPublic = BooleanUtils.isTrue(cmd.isPublic());
-                dnsServer.setPublicServer(isPublic);
 
                 String publicDomainSuffix = null;
-                if (isPublic && 
StringUtils.isNotBlank(cmd.getPublicDomainSuffix())) {
-                    publicDomainSuffix = 
DnsProviderUtil.normalizeDomainForDb(cmd.getPublicDomainSuffix());
+                if (isPublic) {
+                    if (StringUtils.isNotBlank(cmd.getPublicDomainSuffix())) {
+                        publicDomainSuffix = 
DnsProviderUtil.normalizeDomainForDb(cmd.getPublicDomainSuffix());
+                    } else {
+                        publicDomainSuffix = dnsServer.getPublicDomainSuffix();
+                    }
+                    if (StringUtils.isBlank(publicDomainSuffix)) {
+                        throw new InvalidParameterValueException("A public DNS 
server requires a public domain " +
+                                "suffix so that DNS zones created by other 
accounts are contained under it.");
+                    }
                 }
+                dnsServer.setPublicServer(isPublic);
                 dnsServer.setPublicDomainSuffix(publicDomainSuffix);
             }
         }
@@ -590,6 +603,7 @@ public class DnsProviderManagerImpl extends ManagerBase 
implements DnsProviderMa
                 throw new PermissionDeniedException("You do not have 
permission to use this DNS server.");
             }
             dnsZoneName = 
DnsProviderUtil.appendPublicSuffixToZone(dnsZoneName, 
server.getPublicDomainSuffix());
+            checkDnsZoneNameConflictsAcrossAccounts(dnsZoneName, 
server.getId(), caller.getId());
         }
         DnsZone.ZoneType type = cmd.getType();
         DnsZoneVO existing = dnsZoneDao.findByNameServerAndType(dnsZoneName, 
server.getId(), type);
@@ -600,6 +614,28 @@ public class DnsProviderManagerImpl extends ManagerBase 
implements DnsProviderMa
         return dnsZoneDao.persist(dnsZoneVO);
     }
 
+    /**
+     * Rejects a DNS zone name that is equal to, a DNS child of, or a DNS 
parent of an existing zone owned by a
+     * different account on the same DNS server. Without this, a co-tenant 
could register e.g.
+     * {@code www.victimzone.<suffix>} on a shared public server and shadow 
the victim's records in the
+     * authoritative name server, since the more specific zone wins resolution.
+     */
+    private void checkDnsZoneNameConflictsAcrossAccounts(String dnsZoneName, 
long dnsServerId, long callerAccountId) {
+        String requestedName = dnsZoneName.toLowerCase();
+        List<DnsZoneVO> existingZones = 
dnsZoneDao.listByDnsServerId(dnsServerId);
+        for (DnsZoneVO zone : existingZones) {
+            if (zone.getAccountId() == callerAccountId) {
+                continue;
+            }
+            String existingName = zone.getName().toLowerCase();
+            if (requestedName.equals(existingName) || 
requestedName.endsWith("." + existingName)
+                    || existingName.endsWith("." + requestedName)) {
+                throw new PermissionDeniedException(String.format("DNS zone 
name %s conflicts with an existing DNS " +
+                        "zone owned by another account on this DNS server.", 
dnsZoneName));
+            }
+        }
+    }
+
     @Override
     public DnsZone provisionDnsZone(long dnsZoneId, boolean isExistingZone) {
         DnsZoneVO dnsZone = dnsZoneDao.findById(dnsZoneId);
diff --git a/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDao.java 
b/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDao.java
index 43bf60818d5..adcf3542189 100644
--- a/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDao.java
+++ b/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDao.java
@@ -34,4 +34,6 @@ public interface DnsZoneDao extends GenericDao<DnsZoneVO, 
Long> {
                                                String keyword, Filter filter);
 
     List<Long> findDnsZoneIdsByServerId(long dnsServerId);
+
+    List<DnsZoneVO> listByDnsServerId(long dnsServerId);
 }
diff --git 
a/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDaoImpl.java 
b/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDaoImpl.java
index 2487f2fee20..c8fdf8b8452 100644
--- a/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDaoImpl.java
+++ b/server/src/main/java/org/apache/cloudstack/dns/dao/DnsZoneDaoImpl.java
@@ -35,17 +35,22 @@ import com.cloud.utils.db.SearchCriteria;
 
 @Component
 public class DnsZoneDaoImpl extends GenericDaoBase<DnsZoneVO, Long> implements 
DnsZoneDao {
-    SearchBuilder<DnsZoneVO> DnsServerSearch;
+    SearchBuilder<DnsZoneVO> DnsServerZoneIdsSearch;
+    SearchBuilder<DnsZoneVO> DnsServerZonesSearch;
     SearchBuilder<DnsZoneVO> AccountSearch;
     SearchBuilder<DnsZoneVO> NameServerTypeSearch;
 
     public DnsZoneDaoImpl() {
         super();
 
-        DnsServerSearch  = createSearchBuilder();
-        DnsServerSearch.selectFields(DnsServerSearch.entity().getId());
-        DnsServerSearch.and(ApiConstants.DNS_SERVER_ID, 
DnsServerSearch.entity().getDnsServerId(), SearchCriteria.Op.EQ);
-        DnsServerSearch.done();
+        DnsServerZoneIdsSearch = createSearchBuilder();
+        
DnsServerZoneIdsSearch.selectFields(DnsServerZoneIdsSearch.entity().getId());
+        DnsServerZoneIdsSearch.and(ApiConstants.DNS_SERVER_ID, 
DnsServerZoneIdsSearch.entity().getDnsServerId(), SearchCriteria.Op.EQ);
+        DnsServerZoneIdsSearch.done();
+
+        DnsServerZonesSearch = createSearchBuilder();
+        DnsServerZonesSearch.and(ApiConstants.DNS_SERVER_ID, 
DnsServerZonesSearch.entity().getDnsServerId(), SearchCriteria.Op.EQ);
+        DnsServerZonesSearch.done();
 
         AccountSearch = createSearchBuilder();
         AccountSearch.and(ApiConstants.ACCOUNT_ID, 
AccountSearch.entity().getAccountId(), SearchCriteria.Op.EQ);
@@ -116,8 +121,15 @@ public class DnsZoneDaoImpl extends 
GenericDaoBase<DnsZoneVO, Long> implements D
         return searchAndCount(sc, filter);
     }
 
+    @Override
+    public List<DnsZoneVO> listByDnsServerId(long dnsServerId) {
+        SearchCriteria<DnsZoneVO> sc = DnsServerZonesSearch.create();
+        sc.setParameters(ApiConstants.DNS_SERVER_ID, dnsServerId);
+        return listBy(sc);
+    }
+
     public List<Long> findDnsZoneIdsByServerId(long dnsServerId) {
-        SearchCriteria<DnsZoneVO> sc = DnsServerSearch.create();
+        SearchCriteria<DnsZoneVO> sc = DnsServerZoneIdsSearch.create();
         sc.setParameters(ApiConstants.DNS_SERVER_ID, dnsServerId);
         List<DnsZoneVO> dnsZones = listBy(sc);
         if (CollectionUtils.isEmpty(dnsZones)) {
diff --git 
a/server/src/test/java/org/apache/cloudstack/dns/DnsProviderManagerImplTest.java
 
b/server/src/test/java/org/apache/cloudstack/dns/DnsProviderManagerImplTest.java
index 309f5e5d9cf..8ce31d4e7c7 100644
--- 
a/server/src/test/java/org/apache/cloudstack/dns/DnsProviderManagerImplTest.java
+++ 
b/server/src/test/java/org/apache/cloudstack/dns/DnsProviderManagerImplTest.java
@@ -262,6 +262,60 @@ public class DnsProviderManagerImplTest {
         manager.allocateDnsZone(cmd);
     }
 
+    @Test(expected = PermissionDeniedException.class)
+    public void testAllocateDnsZoneNonOwnerShadowingOtherAccountZoneRejected() 
{
+        CreateDnsZoneCmd cmd = mock(CreateDnsZoneCmd.class);
+        when(cmd.getName()).thenReturn("www.tenant1.cloud.example");
+        when(cmd.getDnsServerId()).thenReturn(SERVER_ID);
+        when(dnsServerDao.findById(SERVER_ID)).thenReturn(serverVO);
+        Mockito.doReturn(SERVER_ID).when(serverVO).getId();
+        Mockito.doReturn(ACCOUNT_ID + 99).when(serverVO).getAccountId(); // 
different owner
+        Mockito.doReturn(true).when(serverVO).getPublicServer();
+        
Mockito.doReturn("cloud.example").when(serverVO).getPublicDomainSuffix();
+        DnsZoneVO victimZone = new DnsZoneVO("tenant1.cloud.example", 
DnsZone.ZoneType.Public, SERVER_ID,
+                ACCOUNT_ID + 50, DOMAIN_ID, "victim zone");
+        
when(dnsZoneDao.listByDnsServerId(SERVER_ID)).thenReturn(Collections.singletonList(victimZone));
+
+        manager.allocateDnsZone(cmd);
+    }
+
+    @Test(expected = PermissionDeniedException.class)
+    public void testAllocateDnsZoneNonOwnerParentOfOtherAccountZoneRejected() {
+        CreateDnsZoneCmd cmd = mock(CreateDnsZoneCmd.class);
+        when(cmd.getName()).thenReturn("tenant1.cloud.example");
+        when(cmd.getDnsServerId()).thenReturn(SERVER_ID);
+        when(dnsServerDao.findById(SERVER_ID)).thenReturn(serverVO);
+        Mockito.doReturn(SERVER_ID).when(serverVO).getId();
+        Mockito.doReturn(ACCOUNT_ID + 99).when(serverVO).getAccountId(); // 
different owner
+        Mockito.doReturn(true).when(serverVO).getPublicServer();
+        
Mockito.doReturn("cloud.example").when(serverVO).getPublicDomainSuffix();
+        DnsZoneVO victimZone = new DnsZoneVO("www.tenant1.cloud.example", 
DnsZone.ZoneType.Public, SERVER_ID,
+                ACCOUNT_ID + 50, DOMAIN_ID, "victim zone");
+        
when(dnsZoneDao.listByDnsServerId(SERVER_ID)).thenReturn(Collections.singletonList(victimZone));
+
+        manager.allocateDnsZone(cmd);
+    }
+
+    @Test
+    public void testAllocateDnsZoneNonOwnerPublicServerSuccess() {
+        CreateDnsZoneCmd cmd = mock(CreateDnsZoneCmd.class);
+        when(cmd.getName()).thenReturn("tenant2.cloud.example");
+        when(cmd.getDnsServerId()).thenReturn(SERVER_ID);
+        when(cmd.getType()).thenReturn(DnsZone.ZoneType.Public);
+        when(dnsServerDao.findById(SERVER_ID)).thenReturn(serverVO);
+        Mockito.doReturn(SERVER_ID).when(serverVO).getId();
+        Mockito.doReturn(ACCOUNT_ID + 99).when(serverVO).getAccountId(); // 
different owner
+        Mockito.doReturn(true).when(serverVO).getPublicServer();
+        
Mockito.doReturn("cloud.example").when(serverVO).getPublicDomainSuffix();
+        
when(dnsZoneDao.listByDnsServerId(SERVER_ID)).thenReturn(Collections.emptyList());
+        when(dnsZoneDao.findByNameServerAndType(anyString(), anyLong(), 
any())).thenReturn(null);
+        when(dnsZoneDao.persist(any(DnsZoneVO.class))).thenReturn(zoneVO);
+
+        DnsZone result = manager.allocateDnsZone(cmd);
+        assertNotNull(result);
+        verify(dnsZoneDao).persist(Mockito.argThat(z -> 
"tenant2.cloud.example".equals(((DnsZoneVO) z).getName())));
+    }
+
     @Test(expected = CloudRuntimeException.class)
     public void testProvisionDnsZoneNotFound() {
         when(dnsZoneDao.findById(ZONE_ID)).thenReturn(null);
@@ -806,6 +860,28 @@ public class DnsProviderManagerImplTest {
                 s -> !((DnsServerVO) s).getPublicServer() && ((DnsServerVO) 
s).getPublicDomainSuffix() == null));
     }
 
+    @Test(expected = InvalidParameterValueException.class)
+    public void testAddDnsServerPublicWithoutSuffixRejected() {
+        org.apache.cloudstack.api.command.user.dns.AddDnsServerCmd cmd = mock(
+                
org.apache.cloudstack.api.command.user.dns.AddDnsServerCmd.class);
+        when(accountMgr.isRootAdmin(callerMock.getId())).thenReturn(true);
+        when(cmd.getUrl()).thenReturn("http://newpdns:8081";);
+        when(cmd.isPublic()).thenReturn(true);
+        when(dnsServerDao.findByUrlAndAccount(anyString(), 
anyLong())).thenReturn(null);
+        manager.addDnsServer(cmd);
+    }
+
+    @Test(expected = InvalidParameterValueException.class)
+    public void testUpdateDnsServerPublicWithoutSuffixRejected() {
+        org.apache.cloudstack.api.command.user.dns.UpdateDnsServerCmd cmd = 
mock(
+                
org.apache.cloudstack.api.command.user.dns.UpdateDnsServerCmd.class);
+        when(cmd.getId()).thenReturn(SERVER_ID);
+        when(cmd.isPublic()).thenReturn(true);
+        when(accountMgr.isRootAdmin(callerMock.getId())).thenReturn(true);
+        when(dnsServerDao.findById(SERVER_ID)).thenReturn(serverVO);
+        manager.updateDnsServer(cmd);
+    }
+
     @Test(expected = CloudRuntimeException.class)
     public void testAddDnsServerValidationFailure() throws Exception {
         org.apache.cloudstack.api.command.user.dns.AddDnsServerCmd cmd = mock(
diff --git a/ui/src/views/network/dns/AddDnsServer.vue 
b/ui/src/views/network/dns/AddDnsServer.vue
index 3950c85248d..054b0e37863 100644
--- a/ui/src/views/network/dns/AddDnsServer.vue
+++ b/ui/src/views/network/dns/AddDnsServer.vue
@@ -205,7 +205,10 @@ export default {
       ]
     }
     if (this.isAdminOrDomainAdmin()) {
-      this.rules.publicdomainsuffix = [{ validator: 
this.validatePublicDomainSuffix }]
+      this.rules.publicdomainsuffix = [{
+        required: true,
+        validator: this.validatePublicDomainSuffix
+      }]
     }
     this.fetchProviders()
   },
@@ -331,7 +334,7 @@ export default {
     validatePublicDomainSuffix (rule, value) {
       const normalized = value?.toLowerCase().trim()
       if (!normalized) {
-        return Promise.resolve()
+        return Promise.reject(new 
Error(this.$t('message.error.required.input')))
       }
       if (!FQDN_REGEX.test(normalized)) {
         return Promise.reject(new Error('Invalid domain suffix'))
diff --git a/ui/src/views/network/dns/UpdateDnsServer.vue 
b/ui/src/views/network/dns/UpdateDnsServer.vue
index b8bd4f352e4..728151a2153 100644
--- a/ui/src/views/network/dns/UpdateDnsServer.vue
+++ b/ui/src/views/network/dns/UpdateDnsServer.vue
@@ -164,7 +164,10 @@ export default {
       ]
     }
     if (this.isAdminOrDomainAdmin()) {
-      this.rules.publicdomainsuffix = [{ validator: 
this.validatePublicDomainSuffix }]
+      this.rules.publicdomainsuffix = [{
+        required: true,
+        validator: this.validatePublicDomainSuffix
+      }]
     }
     this.form.name = this.resource.name
     this.form.url = this.resource.url
@@ -272,7 +275,7 @@ export default {
     validatePublicDomainSuffix (rule, value) {
       const normalized = value?.toLowerCase().trim()
       if (!normalized) {
-        return Promise.resolve()
+        return Promise.reject(new 
Error(this.$t('message.error.required.input')))
       }
       if (!FQDN_REGEX.test(normalized)) {
         return Promise.reject(new Error('Invalid domain suffix'))

Reply via email to