]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
Underscores may appear in hostnames if RFC112-CONFORMANCE metadata is set to 0. 16151/head
authorMiod Vallat <miod.vallat@powerdns.com>
Wed, 6 Aug 2025 10:01:14 +0000 (12:01 +0200)
committerMiod Vallat <miod.vallat@powerdns.com>
Mon, 29 Sep 2025 12:00:39 +0000 (14:00 +0200)
Signed-off-by: Miod Vallat <miod.vallat@powerdns.com>
docs/domainmetadata.rst
pdns/check-zone.cc
pdns/check-zone.hh
pdns/dnsname.cc
pdns/dnsname.hh
pdns/dnsrecords.cc
pdns/dnsrecords.hh
pdns/pdnsutil.cc
pdns/ws-auth.cc
regression-tests.api/test_Zones.py

index 090c2fbccf3813351e669fbe09737c101fd470e8..9aa5ea5b6a6b76272b4cb2df68796f5b40d6a504 100644 (file)
@@ -214,6 +214,14 @@ Global defaults for these values can be set via :ref:`setting-default-publish-cd
 
 .. _metadata-signaling-zone:
 
+RFC1123-CONFORMANCE
+-------------------
+.. versionadded:: 5.1.0
+
+If set to 0, hostnames within the zone are allowed to deviate from :rfc:`1123`
+by allowing underscore (``_``) characters to appear anywhere a letter or a
+digit is allowed.
+
 SIGNALING-ZONE
 --------------
 .. versionadded:: 5.0.0
index 431da983e86e32cd1749691c85aaa8bc840d37e2..9044cf546792feef69c64a1672865b97727c477f 100644 (file)
@@ -53,7 +53,7 @@ bool validateViewName(std::string_view name, std::string& error)
   return true;
 }
 
-void checkRRSet(const vector<DNSResourceRecord>& oldrrs, vector<DNSResourceRecord>& allrrs, const ZoneName& zone, vector<pair<DNSResourceRecord, string>>& errors)
+void checkRRSet(const vector<DNSResourceRecord>& oldrrs, vector<DNSResourceRecord>& allrrs, const ZoneName& zone, bool allowUnderscores, vector<pair<DNSResourceRecord, string>>& errors)
 {
   // QTypes that MUST NOT have multiple records of the same type in a given RRset.
   static const std::set<uint16_t> onlyOneEntryTypes = {QType::CNAME, QType::DNAME, QType::SOA};
@@ -109,7 +109,7 @@ void checkRRSet(const vector<DNSResourceRecord>& oldrrs, vector<DNSResourceRecor
 
     // Check if the DNSNames that should be hostnames, are hostnames
     try {
-      checkHostnameCorrectness(rec);
+      checkHostnameCorrectness(rec, allowUnderscores);
     }
     catch (const std::exception& e) {
       errors.emplace_back(std::make_pair(rec, e.what()));
index 6d43f09b7928e215da59557b961b6c2cb61c3c19..a172f2d838642cbd25a23256762c0e4b68401124 100644 (file)
@@ -42,6 +42,6 @@ bool validateViewName(std::string_view name, std::string& error);
 //   *) no exact duplicates
 //   *) no duplicates for QTypes that can only be present once per RRset
 //   *) hostnames are hostnames
-void checkRRSet(const vector<DNSResourceRecord>& oldrrs, vector<DNSResourceRecord>& allrrs, const ZoneName& zone, vector<pair<DNSResourceRecord, string>>& errors);
+void checkRRSet(const vector<DNSResourceRecord>& oldrrs, vector<DNSResourceRecord>& allrrs, const ZoneName& zone, bool allowUnderscores, vector<pair<DNSResourceRecord, string>>& errors);
 
 } // namespace Check
index 97c0c30c3c178a8a62b234910750d3d5e14fc9d3..b66f75c86211e4ea4208b5f41899fe0a394b0735 100644 (file)
@@ -638,9 +638,17 @@ bool DNSName::isWildcard() const
 /*
  * Returns true if the DNSName is a valid RFC 1123 hostname, this function uses
  * a regex on the string, so it is probably best not used when speed is essential.
+ *
+ * If allowUnderscore is set, underscore characters (`_') are allowed anywhere
+ * a letter or a digit would have been. In particular, leading underscores are
+ * allowed.
  */
-bool DNSName::isHostname() const
+bool DNSName::isHostname(bool allowUnderscore) const
 {
+  if (allowUnderscore) {
+    static Regex hostNameRegexWithUnderscore = Regex("^(([A-Za-z0-9_]([A-Za-z0-9-_]*[A-Za-z0-9_])?)\\.)+$");
+    return hostNameRegexWithUnderscore.match(this->toString());
+  }
   static Regex hostNameRegex = Regex("^(([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)\\.)+$");
   return hostNameRegex.match(this->toString());
 }
index 9756b5faef5cd5e18101f0c02089964a4e1cd62e..e5fde7877855b71add79eefbf2e3826d29fdf252 100644 (file)
@@ -149,7 +149,7 @@ public:
   DNSName getCommonLabels(const DNSName& other) const; //!< Return the list of common labels from the top, for example 'c.d' for 'a.b.c.d' and 'x.y.c.d'
   DNSName labelReverse() const;
   bool isWildcard() const;
-  bool isHostname() const;
+  bool isHostname(bool allowUnderscore = false) const;
   unsigned int countLabels() const;
   size_t wirelength() const; //!< Number of total bytes in the name
   bool empty() const { return d_storage.empty(); }
index cb8172e894a6c293e9fa4853880fa423274e9501..f55804686b28431101439b7e86f2f51b04efb682 100644 (file)
@@ -1043,7 +1043,7 @@ ComboAddress getAddr(const DNSRecord& dr, uint16_t defport)
 /**
  * Check if the DNSNames that should be hostnames, are hostnames
  */
-void checkHostnameCorrectness(const DNSResourceRecord& rr)
+void checkHostnameCorrectness(const DNSResourceRecord& rr, bool allowUnderscore) // NOLINT(readability-identifier-length)
 {
   if (rr.qtype.getCode() == QType::NS || rr.qtype.getCode() == QType::MX || rr.qtype.getCode() == QType::SRV) {
     DNSName toCheck;
@@ -1064,7 +1064,7 @@ void checkHostnameCorrectness(const DNSResourceRecord& rr)
     }
     else if ((rr.qtype.getCode() == QType::MX || rr.qtype.getCode() == QType::SRV) && toCheck == g_rootdnsname) {
       // allow null MX/SRV
-    } else if(!toCheck.isHostname()) {
+    } else if(!toCheck.isHostname(allowUnderscore)) {
       throw std::runtime_error(boost::str(boost::format("non-hostname content %s") % toCheck.toString()));
     }
   }
index 2677cd6b1dbdbd0109bea79564e9a8a078c9073f..f1bd7a961015db7c3560d6f31946168cd6bac551 100644 (file)
@@ -1336,4 +1336,4 @@ class MOADNSParser;
 bool getEDNSOpts(const MOADNSParser& mdp, EDNSOpts* eo);
 void reportAllTypes();
 ComboAddress getAddr(const DNSRecord& dr, uint16_t defport=0);
-void checkHostnameCorrectness(const DNSResourceRecord& rr);
+void checkHostnameCorrectness(const DNSResourceRecord& rr, bool allowUnderscore = false);
index 4721fe441e0623a1375e858b5b5ca036b0285998..76b89ef2700858dfe7e1eec66891564846d98cc7 100644 (file)
@@ -870,6 +870,14 @@ static std::string terminalSafe(const std::string& input)
   return output;
 }
 
+static bool areUnderscoresAllowed(const ZoneName& zonename, DomainInfo& info)
+{
+  string underscores{};
+  info.backend->getDomainMetadataOne(zonename, "RFC1123-CONFORMANCE", underscores);
+  // Metadata absent implies strict conformance
+  return underscores == "0";
+}
+
 static int checkZone(DNSSECKeeper &dk, UeberBackend &B, const ZoneName& zone, const vector<DNSResourceRecord>* suppliedrecords=nullptr) // NOLINT(readability-function-cognitive-complexity,readability-identifier-length)
 {
   int numerrors=0;
@@ -1029,6 +1037,8 @@ static int checkZone(DNSSECKeeper &dk, UeberBackend &B, const ZoneName& zone, co
   else
     records=*suppliedrecords;
 
+  bool allowUnderscores = areUnderscoresAllowed(zone, di);
+
   for(auto &rr : records) { // we modify this
     if(rr.qtype.getCode() == QType::TLSA)
       tlsas.insert(rr.qname);
@@ -1240,7 +1250,7 @@ static int checkZone(DNSSECKeeper &dk, UeberBackend &B, const ZoneName& zone, co
 
     // Check if the DNSNames that should be hostnames, are hostnames
     try {
-      checkHostnameCorrectness(rr);
+      checkHostnameCorrectness(rr, allowUnderscores);
     } catch (const std::exception& e) {
       cout << "[Warning] " << rr.qtype.toString() << " record in zone '" << zone << ": " << e.what() << endl;
       numwarnings++;
@@ -2683,6 +2693,8 @@ static int addOrReplaceRecord(bool isAdd, const vector<string>& cmds)
     }
   }
 
+  bool allowUnderscores = areUnderscoresAllowed(zone, di);
+
   di.backend->startTransaction(zone, UnknownDomainID);
 
   DNSResourceRecord oldrr;
@@ -2704,7 +2716,7 @@ static int addOrReplaceRecord(bool isAdd, const vector<string>& cmds)
   }
 
   std::vector<std::pair<DNSResourceRecord, string>> errors;
-  Check::checkRRSet(oldrrs, newrrs, zone, errors);
+  Check::checkRRSet(oldrrs, newrrs, zone, allowUnderscores, errors);
   oldrrs.clear(); // no longer needed
   if (!errors.empty()) {
     for (const auto& error : errors) {
index 07736a134fd436a4c06d2122e91dc7150dba04c3..73332f2b5cb79e183f4fd0ec6a94aa78dff1ea91 100644 (file)
@@ -1034,6 +1034,7 @@ static bool isValidMetadataKind(const string& kind, bool readonly)
     {"PRESIGNED", true},
     {"PUBLISH-CDNSKEY", false},
     {"PUBLISH-CDS", false},
+    {"RFC1123-CONFORMANCE", false},
     {"SIGNALING-ZONE", false},
     {"SLAVE-RENOTIFY", false},
     {"SOA-EDIT", true},
@@ -1674,13 +1675,21 @@ static void gatherRecordsFromZone(const std::string& zonestring, vector<DNSResou
   }
 }
 
+static bool areUnderscoresAllowed(const ZoneName& zonename, DNSBackend& backend)
+{
+  string underscores{};
+  backend.getDomainMetadataOne(zonename, "RFC1123-CONFORMANCE", underscores);
+  // Metadata absent implies strict conformance
+  return underscores == "0";
+}
+
 // Wrapper around checkRRSet; returns true if all checks successful, false if
 // not, in which case the response body and status have been filled up.
-static bool checkNewRecords(HttpResponse* resp, vector<DNSResourceRecord>& records, const ZoneName& zone)
+static bool checkNewRecords(HttpResponse* resp, vector<DNSResourceRecord>& records, const ZoneName& zone, bool allowUnderscores)
 {
   std::vector<std::pair<DNSResourceRecord, string>> errors;
 
-  Check::checkRRSet({}, records, zone, errors);
+  Check::checkRRSet({}, records, zone, allowUnderscores, errors);
   if (errors.empty()) {
     return true;
   }
@@ -2054,7 +2063,7 @@ static void apiServerZonesPOST(HttpRequest* req, HttpResponse* resp)
     }
   }
 
-  if (!checkNewRecords(resp, new_records, zonename)) {
+  if (!checkNewRecords(resp, new_records, zonename, false)) { // no RFC1123-CONFORMANCE metadata on new zones
     return;
   }
 
@@ -2230,7 +2239,8 @@ static void apiServerZoneDetailPUT(HttpRequest* req, HttpResponse* resp)
       throw ApiException("Modifying RRsets in Consumer zones is unsupported");
     }
 
-    if (!checkNewRecords(resp, new_records, zoneData.zoneName)) {
+    bool allowUnderscores = areUnderscoresAllowed(zoneData.zoneName, *zoneData.domainInfo.backend);
+    if (!checkNewRecords(resp, new_records, zoneData.zoneName, allowUnderscores)) {
       return;
     }
 
@@ -2460,6 +2470,7 @@ static void patchZone(UeberBackend& backend, const ZoneName& zonename, DomainInf
     domainInfo.backend->getDomainMetadataOne(zonename, "SOA-EDIT-API", soa_edit_api_kind);
     domainInfo.backend->getDomainMetadataOne(zonename, "SOA-EDIT", soa_edit_kind);
     bool soa_edit_done = false;
+    bool allowUnderscores = areUnderscoresAllowed(zonename, *domainInfo.backend);
 
     vector<DNSResourceRecord> new_records;
     vector<Comment> new_comments;
@@ -2518,7 +2529,7 @@ static void patchZone(UeberBackend& backend, const ZoneName& zonename, DomainInf
                 soa_edit_done = increaseSOARecord(resourceRecord, soa_edit_api_kind, soa_edit_kind, zonename);
               }
             }
-            if (!checkNewRecords(resp, new_records, zonename)) {
+            if (!checkNewRecords(resp, new_records, zonename, allowUnderscores)) {
               return;
             }
           }
index bf28bb14756831cd9fd49ce2a2f92c8901c3401d..bfcc978cbb5dc9dbe4aaf4bea16f35935db54a57 100644 (file)
@@ -2686,6 +2686,37 @@ $NAME$  1D  IN  SOA ns1.example.org. hostmaster.example.org. (
         self.assertEqual(r.status_code, 422)
         self.assertIn('Data field in DNS should end on a quote', r.json()['error'])
 
+    def test_underscore_names(self):
+        name = unique_zone_name()
+        self.create_zone(name=name, kind='Native')
+
+        payload_metadata = {"type": "Metadata", "kind": "RFC1123-CONFORMANCE", "metadata": ["0"]}
+        r = self.session.post(self.url("/api/v1/servers/localhost/zones/" + name + "/metadata"),
+                              data=json.dumps(payload_metadata))
+        rdata = r.json()
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(rdata["metadata"], payload_metadata["metadata"])
+
+        rrset = {
+            'changetype': 'replace',
+            'name': "_underscores_r_us_."+name,
+            'type': "A",
+            'ttl': 3600,
+            'records': [{
+                "content": "42.42.42.42",
+                "disabled": False,
+            }],
+        }
+        payload = {'rrsets': [rrset]}
+        r = self.session.patch(
+            self.url("/api/v1/servers/localhost/zones/" + name),
+            data=json.dumps(payload),
+            headers={'content-type': 'application/json'})
+        self.assert_success(r)
+        data = self.get_zone(name)
+        # check our record has appeared
+        self.assertEqual(get_rrset(data, rrset['name'], 'A')['records'], rrset['records'])
+
 @unittest.skipIf(not is_auth(), "Not applicable")
 class AuthRootZone(ZonesApiTestCase, AuthZonesHelperMixin):