info@altius-group.ch
Froideville, Vaud
IT

Buying a domain from Django

/ 13 min di lettura / aggiornato 04.09.2026

Most API integrations are JSON over HTTPS with a bearer token, and the interesting part is the business logic. Registrar APIs are not that.

The OpenSRS reseller API — Tucows, one of the largest domain registrars — speaks OPS/XCP: XML envelopes posted over HTTPS, signed with a nested MD5 hash. It predates REST, it predates JSON, and it is the interface through which names.legal buys, renews and configures its customers’ domains.

Three things make this integration genuinely different from a normal one, and each of them is a real hazard:

  • The calls spend money. SW_REGISTER debits a reseller balance. There is no undo.
  • The responses carry a DTD, which means naive XML parsing is a vulnerability.
  • Some operations require you to state what you believe the current state is, and refuse if you are wrong.

Two hundred and sixty-seven lines. Here is what they have to get right.

A client that touches no database

"""
Ce client ne touche PAS la base : il encode/signe/envoie/parse. La logique
métier (modèles tenant_id, persistance) se construit au-dessus.
"""

This client does not touch the database: it encodes, signs, sends and parses.

Stated before anything else, and it is the seam the rest depends on. The client knows the protocol and nothing about the product. It does not know what a tenant is, does not save anything, does not decide when a domain should be bought.

The reason it matters more here than usual: you must be able to test this without spending money, and to reason about the wire format without a database. Every method maps one-to-one onto one API action. When OpenSRS returns something unexpected, the question “is this our bug or theirs?” has a small answer.

The signature, implemented exactly as specified

def _signature(self, body: str) -> str:
    key = self.api_key
    inner = hashlib.md5((body + key).encode("utf-8")).hexdigest()
    return hashlib.md5((inner + key).encode("utf-8")).hexdigest()

md5(md5(body + key) + key).

That is HMAC-shaped without being HMAC, and MD5 has been unsuitable for collision resistance since 2004. It is also not your decision. The vendor defines the scheme; a client that implements a better one does not authenticate.

The engineering content is therefore in what you do control:

  • Implement it exactly, including the concatenation order. md5(key + body) is a different function and fails with an authentication error that tells you nothing about which end is wrong.
  • Sign the exact bytes you send. self._signature(body) runs on the same string that is then body.encode("utf-8") for the request. Building the body twice — once to sign, once to send — is how a whitespace difference becomes a four-hour debugging session.
  • Keep the key out of everything. It is in settings, it is never interpolated into a log line, and the error raised on a missing configuration names the variable, not the value:
if not self.username or not self.api_key:
    raise OpenSRSError(
        "OPENSRS_USERNAME et OPENSRS_API_KEY doivent être définis "
        "(ajouter OPENSRS_USERNAME au .env)."
    )

Failing at construction rather than at the first request is the right place: the error appears when the object is built, in whatever context built it, instead of surfacing as a signature failure fifty lines away.

The DOCTYPE, and why defusedxml is not optional

# defusedxml : parse durci contre XXE / billion-laughs (la réponse OpenSRS
# porte un DOCTYPE/DTD — on ne parse jamais ça avec le stdlib nu).
from defusedxml.ElementTree import fromstring as _xml_fromstring

This is the most important line in the file and the easiest to leave out, because xml.etree.ElementTree.fromstring works perfectly in every test you will write.

The protocol requires a DOCTYPE — it is in the header of every request:

_XML_HEADER = (
    "<?xml version='1.0' encoding='UTF-8' standalone='no' ?>"
    "<!DOCTYPE OPS_envelope SYSTEM 'ops.dtd'>"
)

and the responses carry one too. So this is not a case of defensively hardening against an input that might have a DTD. Every single response has one.

Two classes of attack live in that feature:

XXE — XML External Entity. A DTD may declare an entity whose value is a file path or a URL. A parser that resolves external entities will read file:///etc/passwd, or make an HTTP request from inside your network, and place the result in the parsed document. That is arbitrary file disclosure and SSRF, from parsing a response.

Billion laughs. Nested entity definitions that expand exponentially — ten levels of tenfold expansion is a gigabyte from a few hundred bytes. The worker process dies to memory exhaustion.

The obvious objection is that OpenSRS is a large, reputable registrar that will not attack us. It is not the right question. The response arrives over the network, and the threat model includes a compromised endpoint, a man-in-the-middle where TLS validation was misconfigured, a hostile response from a misdirected DNS entry, and a future where someone points OPENSRS_HOST at a staging proxy. defusedxml is a drop-in import that removes the entire category.

Any XML you did not write is parsed with defusedxml. There is no threshold of vendor reputability that changes this, and the import is the same length as the unsafe one.

Encoding: two containers, and escaping everywhere

OPS has exactly two composite types — dt_assoc for mappings, dt_array for sequences — which map cleanly onto Python:

@classmethod
def _encode_value(cls, value) -> str:
    if isinstance(value, dict):
        items = "".join(
            f'<item key="{escape(str(k))}">{cls._encode_value(v)}</item>'
            for k, v in value.items()
        )
        return f"<dt_assoc>{items}</dt_assoc>"
    if isinstance(value, (list, tuple)):
        items = "".join(
            f'<item key="{i}">{cls._encode_value(v)}</item>'
            for i, v in enumerate(value)
        )
        return f"<dt_array>{items}</dt_array>"
    return escape(str(value))

Twelve lines, recursive, and a caller can pass an arbitrarily nested structure — which is exactly what a contact_set is: four contacts, each a dict of a dozen fields.

The part to look at closely is that both insertion points are escaped: escape(str(k)) for the attribute and escape(str(value)) for the text.

Building XML by string concatenation is normally a smell, and here it is a reasonable choice — the format is tiny, the output must match the vendor’s expectations byte for byte, and a DOM library would fight the required DOCTYPE. But it moves escaping from automatic to your responsibility, and the responsibility is discharged at every point where untrusted data enters the string. Not most of them. Every one.

The values are genuinely untrusted, and that is the point people miss: a contact_set contains a customer’s name, address and organisation, typed into a web form. Müller & Söhne contains an ampersand. Someone will eventually type a <. Without escape, that is a malformed envelope at best and injected XML at worst.

str(value) before escaping is small and necessary: the caller passes integers and booleans, and escape wants a string.

Parsing, and reading the format rather than the document

@classmethod
def _parse_node(cls, node):
    """node = élément <dt_assoc> ou <dt_array>."""
    if node.tag == "dt_assoc":
        return {item.get("key"): cls._parse_item(item) for item in node.findall("item")}
    if node.tag == "dt_array":
        pairs = sorted(node.findall("item"), key=lambda it: int(it.get("key") or 0))
        return [cls._parse_item(item) for item in pairs]
    return (node.text or "").strip()


@classmethod
def _parse_item(cls, item):
    child = item.find("dt_assoc")
    if child is None:
        child = item.find("dt_array")
    if child is not None:
        return cls._parse_node(child)
    return (item.text or "").strip()

Mutual recursion between “a container” and “an item”, which is the shape of the grammar.

sorted(..., key=lambda it: int(it.get("key") or 0)) is the detail worth copying. A dt_array’s items carry explicit numeric keys, and the format says the key defines the position — so the parser orders by the key rather than by document order. Trusting document order works right up until the day a proxy, an intermediate serialisation or a vendor change reorders them, and then a nameserver list comes back permuted with nothing to indicate it.

Read the format’s rules, not the shape of the examples you have seen.

or 0 guards a missing or empty key rather than raising. (node.text or "").strip() handles an empty element, which XML represents as None text and which would otherwise be an AttributeError on a perfectly valid response.

Two success signals, both consulted

data = self._parse_response(resp.text)
code = data.get("response_code")
# OpenSRS : 200 = succès ; is_success "1" confirme.
if code not in (None, "200") and data.get("is_success") != "1":
    raise OpenSRSError(
        data.get("response_text", "Erreur OpenSRS"),
        response_code=code,
        payload=data,
    )
return data

Note what is not being trusted: the HTTP status. resp.raise_for_status() runs earlier and catches transport failures, but an OPS error arrives as HTTP 200 with an error inside the envelope. This is normal for pre-REST protocols, and it is the single most common way an integration silently treats failures as successes — the developer checks resp.ok, sees True, and moves on.

The exception carries three things — message, code and the full parsed payload:

class OpenSRSError(Exception):
    def __init__(self, message, response_code=None, payload=None):
        super().__init__(message)
        self.response_code = response_code
        self.payload = payload

The response_code is what lets callers branch on specific conditions — _looks_like_already_exists in the DNS layer is exactly that kind of caller — and the payload is what makes a support conversation possible three weeks later.

One honest note on the condition: code not in (None, "200") means a response with no response_code is treated as success. That is deliberate — some actions do not return one — and it is a permissive branch, so a malformed or truncated response that parses into a data_block without a code passes through as success. Tightening it means enumerating which actions omit the code, which is work the vendor’s documentation makes harder than it should be. Worth knowing it is there.

Marking the calls that spend money

def register(self, domain, *, contacts, reg_username, reg_password,
             period=1, nameservers=None, reg_type="new",
             handle="process", whois_privacy=False):
    """Enregistre un domaine (SW_REGISTER).

    ÉCRITURE — engage le solde reseller. À n'appeler qu'en OT&E (ou avec
    garde-fou explicite en live). ...
    """

ÉCRITURE — engage le solde reseller. In capitals, first line, on every method that costs money.

In a normal API wrapper, get and post are self-describing. Here SW_REGISTER and RENEW debit an account, and LOOKUP and GET_PRICE do not, and the names do not tell you which is which. The docstring is the only place a reader finds out before running it in a shell.

The signatures back it up: everything after domain is keyword-only.

def renew(self, domain, *, period=1, current_expiration_year, handle="process", auto_renew=False)

You cannot call renew(domain, 5) and discover you bought five years. Every argument to a money-spending call must be named at the call site — which also makes the call reviewable in a diff, where a bare 5 would not be.

And the environment split is stated at module level:

"""
Endpoints :
    - TEST (OT&E) : horizon.opensrs.net:55443
    - PROD        : rr-n1-tor.opensrs.net:55443
Pilotés par les settings OPENSRS_* ... En local on reste sur l'environnement
de TEST ; aucune opération n'engage de vrai domaine.
"""

Registrar sandboxes are not a nicety. There is no test mode inside production, no refund, and a mistake in a loop registers domains until the balance runs out. The host is a setting, the default in development is the sandbox, and the fact is written where someone opening the file will see it.

Compare-and-swap, over XML

This is the piece of API design I find most interesting in the whole integration.

def renew(self, domain, *, period=1, current_expiration_year, ...):
    """Renouvelle un domaine déjà enregistré (RENEW).

    ÉCRITURE — engage le solde reseller. `current_expiration_year` (YYYY)
    est exigé par OpenSRS comme garde-fou anti-double-renouvellement : il
    doit correspondre à l'année d'expiration actuelle du domaine, sinon
    l'API refuse (response_code 480).
    """

To renew a domain you must send the year it currently expires. If your value does not match the registrar’s, the call is refused with code 480.

That is compare-and-swap, in a 1990s XML protocol. The caller states the state it believes it is transitioning from, and the server refuses if reality has moved.

It solves the problem that every “perform this action” endpoint has and almost none address: a retry after a timeout. Your request succeeded, the response was lost, your job retries — and without the guard you have renewed twice and paid twice. With it, the second attempt carries a now-stale year and is refused. The retry is safe by construction, not by your remembering to make it safe.

Note the signature detail: current_expiration_year has no default, after *. It cannot be forgotten and it cannot be passed positionally. The API’s safety mechanism is made non-optional in the wrapper too — a wrapper that defaulted it to the current year would have quietly removed the protection while appearing to implement it.

The transferable lesson, for anyone designing an endpoint that costs something:

Require the caller to state the expected current state. It is cheaper than idempotency keys, it needs no server-side storage, and it converts a dangerous retry into a clean refusal.

Silent success, and the field that reveals it

def create_dns_zone(self, domain, *, records=None, template=None) -> dict:
    """Active le service DNS OpenSRS sur un domaine (CREATE_DNS_ZONE).

    La réponse porte `nameservers_ok` (0/1) : créer la zone ne repointe PAS
    les nameservers du domaine. Soit on enregistre le domaine directement
    avec les NS du service (cf. `DNS_NAMESERVERS`), soit on appelle ensuite
    `force_dns_nameservers`. Sans ça la zone existe mais n'est jamais servie.
    """

Creating the zone does not repoint the domain’s nameservers. Without that, the zone exists but is never served.

CREATE_DNS_ZONE returns success. The zone is created. Every record is there. And the domain does not resolve, because its NS records still point somewhere else — so nothing ever asks the zone anything.

There is no error. The API did what it was asked. The wrong assumption was that “create a DNS zone” implies “use this DNS zone”, and only nameservers_ok in the response distinguishes the two.

The corresponding decision appears in the DNS layer, where the nameservers are passed at registration time so the nominal path never needs the repair call:

# Nameservers du service DNS OpenSRS. Un domaine ne sert sa zone que s'il
# pointe vers eux — on les passe donc dès SW_REGISTER (`custom_nameservers=1`),
# ce qui évite un FORCE_DNS_NAMESERVERS de rattrapage sur le chemin nominal.

Two calls collapsed into one, and one fewer step that can fail between “the customer paid” and “the customer’s site works”. force_dns_nameservers stays in the client as a repair tool for domains whose NS have drifted or that were registered before the DNS service was enabled — a documented recovery path rather than part of the happy path.

The general warning: when a third-party call succeeds and the thing still does not work, look for the response field that says which half you got. It is usually there, usually undocumented in the guide you read, and usually named something like nameservers_ok.

What to take away

  1. Keep the protocol client free of the database. One method per action, no persistence, no business logic — so “our bug or theirs?” has a small answer.
  2. Implement the vendor’s signature exactly, and sign the exact bytes you send. Build the body once.
  3. Parse third-party XML with defusedxml. The DTD is a feature of the protocol, and XXE and billion-laughs are features of the DTD.
  4. If you build XML by concatenation, escape at every insertion point — keys included. Customer names contain ampersands.
  5. Never trust the HTTP status alone. Pre-REST protocols return errors inside a 200, and the error object should carry the code and the full payload.
  6. Mark the calls that spend money in capitals, and make their arguments keyword-only.
  7. Require the expected current state on costly operations. It makes retries safe without any bookkeeping.
  8. A successful call is not a working outcome. Find the field that tells you whether the effect took.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.