]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
Perform normalization of record data received in the REST API. 15994/head
authorMiod Vallat <miod.vallat@powerdns.com>
Wed, 20 Aug 2025 08:48:44 +0000 (10:48 +0200)
committerMiod Vallat <miod.vallat@powerdns.com>
Thu, 4 Sep 2025 13:45:44 +0000 (15:45 +0200)
Data will now have its surrounding whitespace trimmed and will be
processed by a parseRFC1035CharString() loop, in order to perform
proper escape sequence expansion.

Fixes: #15990
Signed-off-by: Miod Vallat <miod.vallat@powerdns.com>
docs/upgrading.rst
pdns/ws-auth.cc
regression-tests.api/test_Zones.py

index 88005d9bf3212beb9765502fac1c281709bb6f33..fe4a0fa3d652bc566fd0c4047faaf34e8ad496fb 100644 (file)
@@ -17,6 +17,14 @@ zone display
 Display of records in various :doc:`pdnsutil <manpages/pdnsutil.1>` commands
 will now always contain explicit trailing dots, for consistency.
 
+Record handling in the API
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Record data sent through the API will now be normalized to be closer to their
+actual zone representation.
+As a result of this change, reading back these records may show a different
+representation than expected.
+
 4.9.0 to 5.0.0
 --------------
 
index 35e3df6646a691bf307b4bb1e36eacf5a07c1173..9585e0c17d0e31f6470b7c87e8edbf913321abd1 100644 (file)
@@ -588,6 +588,56 @@ static void validateGatheredRRType(const DNSResourceRecord& resourceRecord)
   }
 }
 
+// Clean and unescape a record content string, in order to minimize the
+// risk of mismatch between it and its canonical form returned by
+// makeApiRecordContent().
+// To do so, we remove leading and trailing whitespace, and perform
+// RFC1035 processing on the data until all the chunks have been processed.
+static std::string normalizeJsonString(const std::string& jsonContent)
+{
+  std::ostringstream ret;
+
+  std::string copy{jsonContent};
+  // Trim surrounding whitespace
+  boost::trim_right(copy);
+  boost::trim_left(copy);
+
+  std::string_view input{copy};
+  auto len = input.size();
+  size_t pos = 0;
+  while (pos < len) {
+    std::string chunk;
+    // Preserve quotes in the result if the chunk is quoted.
+    bool quote = input[pos] == '"';
+    auto chunksize = parseRFC1035CharString(input.substr(pos), chunk);
+    if (quote) {
+      ret << '"';
+    }
+    // We would love to simply feed chunk to ret here, but unfortunately
+    // we need to RFC1035 escape non-printable characters again.
+    for (char chr : chunk) {
+      if (chr >= 0x20 && chr < 0x7f) {
+        ret << chr;
+      }
+      else {
+        ret << '\\' << std::setfill('0') << std::setw(3) << static_cast<unsigned int>(chr) << std::setw(0);
+      }
+    }
+    if (quote) {
+      ret << '"';
+    }
+    pos += chunksize;
+    // Keep only one space for space-separated chunks.
+    if (pos < len && std::isspace(static_cast<unsigned char>(input[pos])) != 0) {
+      while (pos < len && std::isspace(static_cast<unsigned char>(input[pos])) != 0) {
+        ++pos;
+      }
+      ret << ' ';
+    }
+  }
+  return ret.str();
+}
+
 static void gatherRecords(const Json& container, const DNSName& qname, const QType& qtype, const uint32_t ttl, vector<DNSResourceRecord>& new_records)
 {
   DNSResourceRecord resourceRecord;
@@ -599,7 +649,7 @@ static void gatherRecords(const Json& container, const DNSName& qname, const QTy
   validateGatheredRRType(resourceRecord);
   const auto& items = container["records"].array_items();
   for (const auto& record : items) {
-    string content = stringFromJson(record, "content");
+    string content = normalizeJsonString(stringFromJson(record, "content"));
     if (record.object_items().count("priority") > 0) {
       throw std::runtime_error("`priority` element is not allowed in record");
     }
index 421c628eb53751e6633d98bdd1fc2a1b833c8d08..6b5c3f2d47a3d07a3e7a7b5640730abb646a20ea 100644 (file)
@@ -1377,7 +1377,7 @@ $NAME$  1D  IN  SOA ns1.example.org. hostmaster.example.org. (
             data=json.dumps(payload),
             headers={'content-type': 'application/json'})
         self.assertEqual(r.status_code, 422)
-        self.assertIn('contains an invalid escape', r.json()['error'])
+        self.assertIn('Data field in DNS should start with quote (") at position 9', r.json()['error'])
 
     def test_zone_rr_update_with_escapes(self):
         name, payload, zone = self.create_zone()
@@ -1669,7 +1669,7 @@ $NAME$  1D  IN  SOA ns1.example.org. hostmaster.example.org. (
         rrset = {
             'changetype': 'replace',
             'name': name,
-            'type': 'FAFAFA',
+            'type': 'FAFAFAFA', # obviously a FIPv6 address
             'ttl': 3600,
             'records': [
                 {
@@ -1994,7 +1994,6 @@ $NAME$  1D  IN  SOA ns1.example.org. hostmaster.example.org. (
 #        self.assertIn('You cannot have record(s) under CNAME/DNAME', r.json()['error'])
 
     def test_create_zone_with_leading_space(self):
-        # Actual regression.
         name, payload, zone = self.create_zone()
         rrset = {
             'changetype': 'replace',
@@ -2011,8 +2010,7 @@ $NAME$  1D  IN  SOA ns1.example.org. hostmaster.example.org. (
         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.assertEqual(r.status_code, 422)
-        self.assertIn('Not in expected format', r.json()['error'])
+        self.assert_success(r)
 
     @unittest.skipIf(is_auth_lmdb(), "No out-of-zone storage in LMDB")
     def test_zone_rr_delete_out_of_zone(self):