Skip to content

Poll HTTP-01 challenge with POST-as-GET instead of re-sending the trigger - #49

Open
monperrus wants to merge 1 commit into
analogic:masterfrom
monperrus:poll-challenge-with-post-as-get
Open

Poll HTTP-01 challenge with POST-as-GET instead of re-sending the trigger#49
monperrus wants to merge 1 commit into
analogic:masterfrom
monperrus:poll-challenge-with-post-as-get

Conversation

@monperrus

Copy link
Copy Markdown

Bug

The wait loop in signDomains() re-POSTs the challenge trigger payload on every iteration:

while ($loopCount < $maxAllowedLoops) {
    $result = $this->signedRequest(
        $challenge['url'],
        array("keyAuthorization" => $payload)   // sent on every poll
    );
    ...

Boulder accepts that request only while the authorization is pending. As soon as validation has completed it answers:

400 {"type":"urn:ietf:params:acme:error:malformed",
     "detail":"Unable to update challenge :: authorization must be pending",
     "status":400}

Client::curl() throws on that, so the exception is raised at the exact moment the challenge succeeds and the whole order is abandoned. Any domain that becomes valid during the loop's first sleep(1) — i.e. anything not already valid on the very first request — can therefore never be issued.

It also hides the reason a validation genuinely failed: because the 400 replaces the polled challenge object, the error field Boulder puts there is never logged. In my case that was tls: unrecognized name from a catch-all HTTP→HTTPS redirect; with this patch the client printed the real cause on the next run.

Seen in the wild via poste.io (which vendors 0.3.1): a two-domain certificate failed to renew on every daily attempt for ten weeks and expired. Domain A had a cached valid authz and passed; domain B validated during the first sleep and hit the 400 every time. The last log line before the error is Verification pending, sleeping 1s, which makes it look like a Let's Encrypt problem rather than a client one.

Fix

Send the trigger once, then poll with POST-as-GET, as RFC 8555 §7.5.1 prescribes and as the order-status loop further down this same method already does ($this->signedRequest($location, "")). signedRequest() already encodes an empty/null payload as "", so nothing else changes.

Verified against the production Let's Encrypt API: the renewal that had been failing for ten weeks completed on the first try after this change.

…gger

The wait loop in signDomains() re-POSTs the {"keyAuthorization": ...}
trigger payload on every iteration. Boulder accepts that only while the
authorization is "pending": once validation completes it answers

  400 malformed - Unable to update challenge :: authorization must be pending

so the exception is thrown at the moment the challenge actually succeeds,
and the order is abandoned. Any domain that becomes valid during the
loop's first sleep(1) can therefore never be issued.

Send the trigger once and poll with POST-as-GET afterwards, as RFC 8555
7.5.1 prescribes and as the order-status loop below already does.
signedRequest() encodes an empty/null payload as "", so no other change
is needed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant