mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Send X-Request-ID with the case Dynadot requires (#3)
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. Co-authored-by: jw <jw@jwmacbookm5.local>
This commit is contained in:
+12
-1
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user