Add HTTP redirect following (301/302/303/307/308)#3
Conversation
Follow redirects automatically in request, request-stream, and all convenience methods (get, post, put, del, patch). Default limit is 10. For 301/302/303 the method changes to GET and the body is dropped. For 307/308 the original method and body are preserved. New public API: - Client.default-max-redirects (def, = 10) - Client.request-with-max-redirects - Client.request-stream-with-max-redirects Pass max-redirects=0 to disable following.
There was a problem hiding this comment.
Build & Tests
CI passes on both ubuntu-latest and macos-latest. No local CI to run (Carp library). The PR description notes tests cannot run on the ARM dev machine due to a pre-existing TM.tm_zone const-correctness issue in the time dependency, but CI is green.
Prior feedback
No previous reviews or comments on this PR.
Findings
Overall this is a well-structured addition. The redirect loop is clean, method switching for 301/302/303 vs 307/308 is RFC-correct, and the max-redirect limit works properly. A few issues worth addressing:
1. Security: Authorization header not stripped on cross-origin redirects (http-client.carp:344-353)
When following a redirect to a different host, the Authorization header (and potentially other sensitive headers like Cookie) is forwarded to the new origin. This is a well-known vulnerability — if a request to api.example.com with Authorization: Bearer <token> gets redirected to evil.example.com, the token leaks.
Browsers and curl strip Authorization on cross-origin redirects. This library should do the same: compare the host (and scheme+port) of the original URL with the redirect target, and remove Authorization (and arguably Cookie, Proxy-Authorization) when they differ.
This is the most important finding.
2. Relative path URLs not handled (http-client.carp:280-295)
resolve-location handles absolute URLs (contains ://) and absolute paths (starts with /), but the fallback case (line 295) returns @location verbatim. If a server sends Location: new-page (a relative path, no leading slash), this becomes the full URL passed to build-and-send, which will fail with "missing host in URL" since URI.parse won't find a host in a bare path.
Per RFC 7231 section 7.1.2, the Location value is a URI-reference which may be relative. The correct resolution would be to join it against the base URL's path. This is an edge case — most servers use absolute URLs or absolute paths — but worth noting. The current behavior (error) is at least safe, so this is low priority.
3. No visited-set loop detection (http-client.carp:322-354)
The redirect chain is bounded by max-redirects, which is good. But a redirect loop like A -> B -> A -> B -> ... will burn through all 10 redirects before erroring. A visited-URL set would catch this in 2 hops instead of 10. Not a correctness bug (the limit prevents true infinite loops), but a quality-of-life improvement for debugging. Low priority.
4. Empty or whitespace-only Location headers (http-client.carp:337-343)
If the server returns a Location header with an empty string or whitespace, the code will happily follow the redirect to that broken URL. resolve-location with an empty location would produce the base URL's origin with an empty path, or just an empty string. This would likely fail at build-and-send with a parse or connection error, so it's not dangerous, but a specific error message like "empty Location header" would be more helpful for debugging than whatever downstream error URI.parse produces.
5. Body not re-sent for 307/308 via convenience methods (http-client.carp:437-441)
The body-request helper (used by post, put, patch) calls request, which calls request-with-max-redirects, passing the body through. For 307/308, redirect-verb preserves the method and the body is kept — good. However, body-request adds a Content-Length header based on the original body, and remove-content-length is only called when the verb changes (line 349's when-do guard). For 307/308 the verb does not change, so Content-Length is preserved — which is actually correct behavior here, since the body is preserved too. This is fine.
6. Response body of redirect responses is not drained (http-client.carp:345-346)
When a redirect is followed, the connection is closed immediately after reading headers (line 346), without draining the response body. Since the code sends Connection: close, this means the TCP/TLS connection is torn down with unread data. This is fine for correctness (the connection isn't reused), but worth noting. If keep-alive is ever added, this would need to drain the body.
7. No CHANGELOG
The repo has no CHANGELOG file, so nothing to update.
8. Tests
The test coverage is solid: single/multiple 302 redirects, absolute-URL redirects, max-redirect enforcement (both exceeded and disabled with 0), 303 POST-to-GET, and 307 method preservation. The tests hit httpbin.org, which is appropriate for integration tests like these.
Missing test coverage: 301 and 308 status codes are handled in the code but not tested. Not blocking since they share code paths with the tested cases.
Verdict: revise
The Authorization-header-on-cross-origin-redirect issue (#1) should be fixed before merge. It is a real security concern for any user sending authenticated requests through this library. The other findings are minor and could be addressed later.
Strip Authorization, Cookie, and Proxy-Authorization headers when following a redirect to a different origin (scheme+host+port). This prevents credential leakage when a same-origin request gets redirected to an untrusted host. Also adds a specific error message for empty/whitespace Location headers instead of letting them propagate as opaque downstream parse errors.
|
Addressed the security finding and the empty-Location UX issue from @carpentry-reviewer: Finding #1 (Security: Authorization header not stripped on cross-origin redirects) — Fixed. Added Finding #4 (Empty or whitespace-only Location headers) — Fixed. The redirect loop now trims the Location value and returns a specific New helpers added:
Tests added:
|
There was a problem hiding this comment.
Build & Tests
CI passing on both ubuntu-latest and macos-latest. No local Carp compiler available, but CI green confirms type-checking passes and the integration tests succeed against httpbin.org.
Prior feedback
First review flagged:
- Authorization header not stripped on cross-origin redirects (security) — Fixed. New
cross-origin?detection +strip-sensitive-headersimplementation. - Relative path URLs not handled — Acknowledged; fallback returns
@locationverbatim which fails safely atbuild-and-send. Acceptable. - No visited-set loop detection — Acknowledged; max-redirect limit prevents infinite loops. Low priority.
- Empty/whitespace Location headers — Fixed.
Pattern.trim+String.empty?check returns specific error message. - Body not re-sent for 307/308 — Confirmed working correctly (not a bug).
- Response body not drained — Acceptable with
Connection: close.
Findings
Reviewed the full implementation on the checked-out branch (http-client.carp:260-497) and test file.
1. Cross-origin detection — correct and well-designed
url-origin (lines 325-332) normalizes to scheme://lowercase-host:port, using default-port (http=80, https=443) when no port is specified. cross-origin? (line 336) compares these normalized origins with /=. This matches the browser same-origin model (RFC 6454). ✓
Edge case: if URI.parse fails for either URL, url-origin returns "". Two broken URLs would appear same-origin. Not exploitable in practice — if the redirect target can't be parsed, build-and-send will fail on the next iteration anyway.
2. Header stripping — correct
sensitive-header? (lines 310-313) does case-insensitive comparison for Authorization, Cookie, and Proxy-Authorization. strip-sensitive-headers (lines 317-320) uses Map.kv-reduce to filter. This is applied at line 385-387 when cross-origin? is true. ✓
The stripping is correctly placed BEFORE updating cur-url to the new URL — wait, actually it's AFTER: line 384 closes conn, line 385-387 strips headers based on (cross-origin? &cur-url &new-url) where cur-url is still the OLD url, then line 388 sets cur-url to new-url. This is correct — comparing old vs new origin to decide whether to strip. ✓
3. Header state across redirect chains — correct
Once headers are stripped, they stay stripped for all subsequent redirects in the chain. A sequence like A (same-origin) → B (cross-origin, strip) → C (same-origin as B) correctly retains the stripped state because cur-headers is mutated in place and never restored. ✓
4. Content-Length removal on method change — correct
Lines 390-393: when-do (/= &new-verb &cur-verb) removes Content-Length and clears the body when verb changes (301/302/303 → GET). For 307/308, verb doesn't change, body and Content-Length are preserved. ✓
5. Empty Location handling — correct
Lines 375-381: Pattern.trim &location then String.empty? check. Returns "redirect with empty Location header" error. ✓
6. Tests — good coverage
Integration tests against httpbin.org cover: single/multiple 302 redirects, absolute-URL redirects, max-redirect enforcement (exceeded + disabled), 303 POST→GET, 307 method preservation, same-origin auth preservation, and cross-origin auth stripping.
The cross-origin test (httpbin.org → www.httpbin.org) is a realistic scenario — different subdomain = different origin. ✓
7. Connection cleanup — correct
Connection.close conn is called in every redirect iteration before the next build-and-send. On the final (non-redirect) response, conn is moved into ResponseStream. No leaked connections. ✓
8. README updated
Documents the new redirect behavior, -with-max-redirects variants, and removes the "redirects not followed" limitation note. ✓
Verdict: merge
The security fix is well-implemented, CI is green, tests validate both the redirect mechanism and credential safety. All prior feedback has been addressed. Ready to merge.
Summary
Clientmethods (request,request-stream,get,post,put,del,patch), with a default limit of 10 redirects.Client.request-with-max-redirects,Client.request-stream-with-max-redirects, andClient.default-max-redirects(= 10). Passmax-redirects= 0 to disable following./new-path).Notes
timedependency (TM.tm_zoneconst-correctness on ARM). Carp type-checking passes cleanly.carp-fmtandanglerpass on all changed files.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.