From: Miod Vallat Date: Wed, 20 Aug 2025 08:48:44 +0000 (+0200) Subject: Perform normalization of record data received in the REST API. X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=refs%2Fpull%2F15994%2Fhead;p=thirdparty%2Fpdns.git Perform normalization of record data received in the REST API. 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 --- diff --git a/docs/upgrading.rst b/docs/upgrading.rst index 88005d9bf..fe4a0fa3d 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -17,6 +17,14 @@ zone display Display of records in various :doc:`pdnsutil ` 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 -------------- diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index 35e3df664..9585e0c17 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -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(chr) << std::setw(0); + } + } + if (quote) { + ret << '"'; + } + pos += chunksize; + // Keep only one space for space-separated chunks. + if (pos < len && std::isspace(static_cast(input[pos])) != 0) { + while (pos < len && std::isspace(static_cast(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& 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"); } diff --git a/regression-tests.api/test_Zones.py b/regression-tests.api/test_Zones.py index 421c628eb..6b5c3f2d4 100644 --- a/regression-tests.api/test_Zones.py +++ b/regression-tests.api/test_Zones.py @@ -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):