Send X-Request-ID with the case Dynadot requires

Every Dynadot request this provider has ever made was rejected with "the
X-Signature provided is not valid". The signature was correct. The header name
was not.

urllib capitalises header names when they go through add_header, which the
Request constructor does for everything passed to it, so X-Request-ID left as
X-request-id. Header names are case-insensitive under RFC 9110 and Dynadot's
gateway is not: it matches X-Request-ID exactly, did not find the rewritten
form, and computed the signature with an empty request id while we computed ours
with the UUID. A correct signature over a different string, reported as a bad
signature.

That message sends you to the algorithm and the credentials, which is where I
spent the evening. The header we were plainly sending is the last thing anyone
would suspect.

Assigning the mapping after construction bypasses add_header and preserves the
name. Confirmed against the live API: exact X-Request-ID is accepted, and
X-Request-Id one letter different is rejected.

Two tests, both of which fail against the previous code: the wire name keeps its
case, and the id in the signature is the id in the header. The second is the
invariant that actually broke, and it can break again without the first.
This commit is contained in:
jw
2026-08-24 10:41:28 -04:00
parent a4b8a8729b
commit 9a533a4d5a
2 changed files with 59 additions and 1 deletions
+12 -1
View File
@@ -288,9 +288,20 @@ class DynadotDNSProvider:
request = urllib.request.Request(
self.api_base_url + path,
data=request_body.encode("utf-8") if body is not None else None,
headers=headers,
method=method,
)
# Assigned rather than passed to the constructor, which routes every
# header through add_header() and capitalises the name: X-Request-ID
# becomes X-request-id on the wire.
#
# Header names are case-insensitive per RFC 9110, and Dynadot's gateway
# is not. It matches X-Request-ID exactly, does not find the rewritten
# form, and therefore computes the signature with an empty request id
# while we computed ours with the UUID. The result is a valid signature
# over a different string, reported as "the X-Signature provided is not
# valid", which points at the algorithm and the credentials rather than
# at a header we did send.
request.headers = headers
try:
with self._opener(request, timeout=30) as response:
raw = response.read().decode("utf-8")
+47
View File
@@ -179,6 +179,53 @@ class DynadotDNSTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "HTTP 503"):
provider.get_hosts("example.test")
def test_request_id_header_keeps_its_exact_case_on_the_wire(self):
"""Dynadot matches X-Request-ID exactly, against RFC 9110.
urllib capitalises header names when they go through add_header, so
X-Request-ID left as X-request-id. Dynadot did not find it, signed with
an empty request id while we signed with the UUID, and rejected the
result as an invalid signature. Every request this provider made failed,
and the message pointed at the algorithm and the credentials.
"""
seen = {}
def opener(request, timeout=None):
seen.update(dict(request.header_items()))
return FakeResponse({"code": 200, "data": {"glue_info": {"glue_type": "DNS", "ttl": 1800, "dns_main_list": [], "dns_sub_list": []}}})
provider = DynadotDNSProvider(credentials=self.credentials, opener=opener)
provider.get_hosts("example.test")
self.assertIn("X-Request-ID", seen)
self.assertNotIn("X-request-id", seen)
# The signature must be over the id we actually sent.
self.assertIn("X-Signature", seen)
def test_the_signed_request_id_is_the_one_transmitted(self):
# Signing one value and sending another is the failure above, restated.
seen = {}
def opener(request, timeout=None):
seen.update(dict(request.header_items()))
return FakeResponse({"code": 200, "data": {"glue_info": {"glue_type": "DNS", "ttl": 1800, "dns_main_list": [], "dns_sub_list": []}}})
provider = DynadotDNSProvider(
credentials=self.credentials,
opener=opener,
request_id_factory=lambda: "fixed-request-id",
)
provider.get_hosts("example.test")
expected = dynadot_signature(
"test-key",
"test-secret",
"/restful/v2/domains/example.test/records",
"fixed-request-id",
)
self.assertEqual("fixed-request-id", seen["X-Request-ID"])
self.assertEqual(expected, seen["X-Signature"])
def test_mixed_ttl_and_broad_source_allowlist_fail_closed(self):
provider = DynadotDNSProvider(credentials=self.credentials)
with self.assertRaisesRegex(ValueError, "one observed"):