va: Handle IPv4 fallback for HTTP(S) redirects - #8905
Conversation
Retry the request and validation target selected by redirect handling so IPv6 dial failures can fall back to IPv4 at each hop. Resume from the request whose dial failed to avoid replaying earlier redirects. Add regression coverage for the reported HTTP-to-HTTPS two-fallback sequence.
b051bd5 to
af710a3
Compare
aarongable
left a comment
There was a problem hiding this comment.
This change looks really good; thank you for taking the time to pick apart this hairy mess of loops and redirects and fallbacks. I have several comments, but none are critical -- the test comments are stylistic, and most of the production code comments are about improving this further to address other long-standing (but minor) issues, not about the correctness of this PR itself.
| // reached via processRedirect, so loop to allow a further fallback for | ||
| // that target rather than giving up after a single retry. Each target's | ||
| // own nextIP() bounds how many times it can be retried. | ||
| for err != nil && fallbackErr(err) { |
There was a problem hiding this comment.
fallbackErr only checks that the underlying error occurred during dial, not later in the request. If the context deadline expires during the dial, then that'll be treated as a fallback error, we'll advance to the next IP, attempt to dial, and immediately fail. This will make it look like it's actually the next IP that timed out, not this one. Not sure of the best way to fix this (and it's basically a pre-existing bug), but worth considering.
Separately, I'd prefer not to have this be a loop. While yes, newHTTPValidationTarget itself will prevent us from falling back multiple times (by virtue of only populating two IPs in the first place), we know that we only want to fallback once. Having this be a loop gives the false impression that we might fall back many times. Maybe we should simplify httpValidationTarget to just have a defaultIP and a fallbackIP, and get rid of all of the tried/cur/next machinery.
There was a problem hiding this comment.
If the context deadline expires during the dial, then that'll be treated as a fallback error, we'll advance to the next IP, attempt to dial, and immediately fail. This will make it look like it's actually the next IP that timed out, not this one. Not sure of the best way to fix this (and it's basically a pre-existing bug), but worth considering.
Seems like we could fix this by having fallbackErr return false when errors.Is(err, context.DeadlineExceeded) (or context.Canceled).
| return nil, records, newIPError(records[len(records)-1].AddressUsed, err) | ||
| // Resume from the request whose dial failed. Replaying initialReq would | ||
| // repeat earlier redirects and trip loop detection. | ||
| retryReq, retryReqErr := http.NewRequestWithContext(ctx, "GET", currentReqURL, nil) |
There was a problem hiding this comment.
Using NewRequestWithContext here is a good improvement. Let's take it even further:
- Upgrade your new setHTTP01RequestHeaders helper into newHTTP01Request
- This new helper will take a context and a URL as inputs
- It will call NewRequestWithContext and set the request headers, then return the request object
- Use this new helper both here and at lines 485-507 (i.e. move the context creation and dealine setting up a bit, and then pass that context into the new helper to get initialReq).
| // Accept the closed redirect port so redirect policy permits the hop. | ||
| va.httpsPort = badPort | ||
|
|
||
| ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*500) |
There was a problem hiding this comment.
Always use t.Context() instead of context.Background(). (Same comment in TestFetchHTTPRedirectDoubleFallback.)
| defer cancel() | ||
|
|
||
| _, records, err := va.processHTTPValidation(ctx, identifier.NewIP(netip.MustParseAddr("127.0.0.1")), "/redir-to-https") | ||
| test.AssertError(t, err, "redirect target with no reachable address should fail") |
There was a problem hiding this comment.
Because these are brand new test functions, please follow the go style guide convention of not using test assertion helpers. We're moving away from our test.AssertFoo helpers, only continuing to use them when editing existing test functions, not adding new ones.
| // processRedirect, which updates currentTarget and currentReqURL; if | ||
| // the resulting dial fails, the loop condition re-evaluates | ||
| // fallbackErr(err) against that new target. | ||
| httpResponse, err = client.Do(retryReq) |
There was a problem hiding this comment.
When go does an HTTP 3XX redirect, the follow-up request automatically has the Referer: header set. This fallback retry won't, since we're building it manually. It would be nice to set the Referer header if this isn't the first hop. But this is definitely a pre-existing issue, so don't worry about it too much.
jsha
left a comment
There was a problem hiding this comment.
Thanks for working on this! The change looks correct to me, but it makes our dependence on the CheckRedirect callback even trickier to follow. In particular, having the outer call pick up where a CheckRedirect previously left off seems to me like it has a high potential for bugs.
I believe @aarongable is working on an additional refactor that will remove reliance on CheckRedirect and have Boulder implement its own redirect following. I think that will result in clearer code, where the "first try" and the "fallback try" are adjacent to each other. I think it makes sense to only land this fix alongside that refactor.
BTW, I was curious how we wound up with the current setup: given that we only want to fallback on dial errors, it's strange to implement that fallback at the HTTP level. Wouldn't it be easier to implement a custom dialer that falls back, and let the HTTP layer be oblivious?
Turns out we used to do that, and abandoned it after encountering bugs: #3939, #3889. Here's what the old custom dialer looked like:
Lines 240 to 340 in d9d2f4e
The current version does the dial, builds an HTTP request to a URL containing an IP address, and overrides the Host header to the host we actually want.
| // reached via processRedirect, so loop to allow a further fallback for | ||
| // that target rather than giving up after a single retry. Each target's | ||
| // own nextIP() bounds how many times it can be retried. | ||
| for err != nil && fallbackErr(err) { |
There was a problem hiding this comment.
If the context deadline expires during the dial, then that'll be treated as a fallback error, we'll advance to the next IP, attempt to dial, and immediately fail. This will make it look like it's actually the next IP that timed out, not this one. Not sure of the best way to fix this (and it's basically a pre-existing bug), but worth considering.
Seems like we could fix this by having fallbackErr return false when errors.Is(err, context.DeadlineExceeded) (or context.Canceled).
Fixes #8029
When an HTTP-01 validation follows a redirect to a dual-stack host and the IPv6 dial fails, the VA retried the original validation target instead of the redirect target, so IPv6-to-IPv4 fallback never applied past the first hop. A host reachable only over IPv4 behind a redirect failed validation despite publishing both address types.
The fallback path now tracks the target and URL most recently selected by redirect handling. On a qualifying dial failure it advances that target's IP queue and retries with a fresh bodyless GET against the failed hop, resuming from that request rather than replaying earlier redirects (which would re-run prior hops and trip loop detection). The single retry becomes a loop so each hop can fall back independently; it terminates when a target exhausts its addresses (
nextIP) or redirects hitmaxRedirects.http01Fallbacksnow counts one increment per fallback hop.setHTTP01RequestHeaderscentralizes the Boulder-controlledUser-AgentandAcceptheaders so the initial request and every fallback retry share one setup.Tests:
TestFetchHTTPcovering fallback at the redirect target.