You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[tabcmd] fix: preserve POST body across 3xx redirects (#1127, #1828) (#1848)
* fix: preserve POST body across 3xx redirects (#1127, #1828)
`requests` follows 301/302/303 by converting POST to GET and dropping the
request body. Any TSC write hitting a server behind a redirect (users.add,
workbooks.publish, addusers, etc.) returned 405 Method Not Allowed because
the server saw a GET where it expected a POST.
Disable requests' auto-redirect and walk the chain manually in
Endpoint._make_request, keeping the original method and body across every
hop. Hop count bounded by session.max_redirects (default 30, same as
requests).
Also close two nearby gaps:
- Refuse HTTPS -> HTTP scheme downgrades. Silently following them would
send auth material over plaintext; no legitimate server behaviour
requires this. Raises RedirectError with the original and target URLs.
- Raise RedirectError (with URL, method, status code) when a 3xx response
has no Location header, replacing the bare KeyError('location') that
requests emits deep in its internals.
Sign-in retains its own single-hop 301 handler in auth_endpoint.py for
backwards compatibility; the new path is additive.
Test coverage: 8 new tests in test_redirect_handling.py covering POST body
preservation, multi-hop chains, relative Location headers, scheme
downgrade refusal, missing Location, and hop-cap enforcement. Existing
866-test suite unchanged.
Fixes#1127. Fixes#1828.
* Address #1848 review: fix max_redirects=0, unify signin with base redirect handling, restructure tests
Fixes from Claude review pass:
1. `_follow_redirect_if_any`: move the "not a redirect?" early-return
outside the loop, so a 200 response returns immediately even when
session.max_redirects=0 (previously fell straight to
"Exceeded 0 redirect hops" error). Also switch to
getattr(method, "__name__", "REQUEST") to survive functools.partial
or other callable wrappers.
2. `auth_endpoint.sign_in`: replace the inline session.post + 301 handler
with `_make_request`, so signin now inherits multi-hop chain support,
the HTTPS -> HTTP scheme guard, the missing-Location diagnostic, and
the hop limit. This resolves the divergent behavior between signin
and every other endpoint (signin previously refused to follow 302 and
had no security guards).
3. `test_redirect_handling.py`: rewrite all tests to drive real endpoint
calls (`server.auth.sign_in`, `server.workbooks.get`) through
`requests_mock`, exercising `_make_request` end-to-end rather than
calling `_follow_redirect_if_any` in isolation. Add parametrized
coverage for all 5 followed redirect codes (301/302/303/307/308) and
the 4 non-followed ones (300/304/305/306). Add tests for header
preservation (X-Tableau-Auth reaches the redirect target), HTTP->HTTPS
upgrade allowed, cross-host redirect followed, second-hop HTTPS->HTTP
downgrade caught, and max_redirects=1 error path. Document why
max_redirects=0 isn't tested (`requests` refuses to complete any 3xx
response when max_redirects=0, regardless of `allow_redirects`, so
the response never reaches our code).
Full test suite: 888 passed, 1 skipped.
* Promote server address to https on http->https redirect
When the server redirects http://host to https://host on the same host,
update `server._server_address` so subsequent requests skip the redirect
round-trip. Recovers an older idea from the abandoned
`jac/handle-https-better` branch, now that the manual-redirect handler
from #1848 provides the right hook point.
Only rewrites the stored address when:
- current scheme is http, next scheme is https (upgrade, not downgrade
which is already refused above)
- current and next netloc match (same host, just scheme change) -- avoids
the failure mode where a redirect to a completely unrelated https
server silently repoints every future call at it.
Two tests: one verifies the address is promoted on a same-host http->https
redirect, the other verifies it is NOT promoted on a cross-host redirect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: justify auth-material forwarding across cross-host redirects
Add a code comment in `_handle_redirects` explaining why the X-Tableau-
Auth header and session cookies are intentionally preserved on cross-
host redirects, rather than stripped as a generic library would.
The concern was raised in a security-focused fresh-eyes review: same-
scheme cross-host redirects normally leak bearer tokens to whoever
controls the redirect target, and RFC 7235 recommends stripping auth
on cross-origin hops for that reason. But TSC is a client for a
specific server the caller has already trusted, and Tableau Server is
routinely deployed behind reverse proxies, load balancers, and SSO
front-ends that redirect between hosts within the same infrastructure
(tableau.corp.example -> east.tableau.corp.example, SSO IdP -> auth
callback endpoint on a different subdomain, etc.). Stripping auth
material there would break sign-in against every such deployment.
The HTTPS -> HTTP downgrade guard (line 208) is the load-bearing
security boundary: once the caller connects over HTTPS, the token
cannot leave TLS regardless of which host receives the redirect.
Comment only. No code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: justify uniform method preservation on 303 responses
Add a code comment noting the deliberate deviation from RFC 7231 §6.4.4,
which says 303 SHOULD change the method to GET on retry. TSC preserves
the method and body on 303 the same as on the other redirect codes.
Rationale: Tableau Server doesn't emit 303 for POST endpoints in normal
operation, and PR #1848's goal is to preserve method+body across the
common proxy/HA cases. If a deployment ever starts emitting 303 for
writes, revisit. Marking it as a conscious deviation so a future reader
doesn't submit a "fix" that reintroduces the bug we just fixed.
Comment only. No code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* respect explicit allow_redirects override from http_options
Change `parameters["allow_redirects"] = False` to
`parameters.setdefault("allow_redirects", False)`. Default behavior is
unchanged: with no override, TSC walks the redirect chain itself and
preserves method+body across every hop.
If a caller has a specific reason to override -- a security policy that
requires failing loudly on any redirect rather than silently following
one, or a test harness that wants requests' default behavior -- they
can pass allow_redirects=True or =False on the Server's http_options
and have it respected. The manual redirect walker in
_follow_redirect_if_any short-circuits on non-3xx responses, so
requests handling the redirect first and returning a 200 is safe.
The 24 existing redirect tests all pass unchanged; none of them
override allow_redirects, and both the enforced-redirect and refused-
redirect paths still exercise the correct code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* populate response.history when walking the redirect chain manually
`requests`' native follower populates `Response.history` with the
intermediate 3xx responses in receipt order; the final non-3xx response
is what's returned, not in history. PR #1848 short-circuited the native
follower by setting `allow_redirects=False` and walking the chain in
`_follow_redirect_if_any`, which meant `.history` came back as an empty
list even after a multi-hop chain. Fine for internal callers (nothing
in TSC reads .history), but a silent behavior change for external
consumers who forensically inspect responses.
Collect each intermediate 3xx response in a local list and assign it
to `response.history` on the final non-3xx response before returning.
Matches the shape callers get from requests' native follower.
Two tests:
- test_response_history_populated_across_multi_hop_chain confirms the
intermediate 301 and 302 land in .history in order after a 3-hop
chain terminating in 200.
- test_response_history_empty_when_no_redirect confirms the no-redirect
short-circuit still returns .history=[] as requests would have.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address Copilot review findings on #1848
- endpoint.py: replace `old_address[7:].startswith(current_parsed.netloc)`
with exact `old_parsed.netloc == current_parsed.netloc` comparison for
the http->https address promotion. The startswith form was correct in
the reviewed attacker scenarios but wrong for the common corporate case:
an unqualified hostname like `TSC.Server("http://tableau")` where DNS
search paths / split-horizon resolve `tableau` to different actual
hosts. A redirect from `http://tableau/` to `https://tableau.other/`
would previously promote `_server_address` to `https://tableau` even
though the redirected netloc was `tableau.other`. Exact-netloc match
kills that.
- endpoint.py: change RedirectError message from "last Location was
{current_url}" to "last URL attempted was {current_url}". current_url
is the resolved URL of the last attempted hop, not the raw Location
header value; the old phrasing was misleading during redirect-loop
diagnostics.
- test_redirect_handling.py: tighten
test_non_followed_3xx_codes_pass_through to raise `ServerResponseError`
specifically instead of `(ServerResponseError, Exception)`. The mocked
XML error body deterministically produces ServerResponseError via
_check_status; the broader assertion could mask an unrelated failure.
- The fourth Copilot finding (namespace detection lost on the new signin
path) is tracked separately as #1866; addressing there so it does not
block this PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0 commit comments