Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 49 additions & 22 deletions tableauserverclient/server/endpoint/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,35 +230,62 @@ def _follow_redirect_if_any(
)
# http -> https upgrade on the same host: promote the stored server
# address so subsequent requests skip this redirect round-trip.
# Only rewrite when the stored address's netloc exactly matches
# the redirected netloc to avoid pointing the client at an
# unrelated server (prefix matching could match e.g. "test"
# against a stored address of "test.other.example").
# Two comparisons here:
# * current vs next: same-host check across schemes. Compare
# hostnames case-insensitively (RFC 3986); scheme-default
# port differs across schemes so port is not part of this
# check. Explicit ports are rare in real Tableau deployments
# and a cross-scheme cross-port hop is unusual enough that
# falling through to "no promotion" is the safe default.
# * old_address vs current: same-scheme (both http) check.
# Normalize explicit-vs-implicit port so http://host and
# http://host:80 compare equal.
if current_scheme == "http" and next_scheme == "https":
current_parsed = urlparse(current_url)
next_parsed = urlparse(next_url)
if current_parsed.netloc == next_parsed.netloc:
current_host = (current_parsed.hostname or "").lower()
next_host = (next_parsed.hostname or "").lower()
if current_host and current_host == next_host:
Comment thread
jacalata marked this conversation as resolved.
old_address = self.parent_srv._server_address
old_parsed = urlparse(old_address)
if old_parsed.scheme == "http" and old_parsed.netloc == current_parsed.netloc:
new_address = "https://" + old_address[len("http://") :]
default_http_port = 80

def _hostport(parsed):
return ((parsed.hostname or "").lower(), parsed.port or default_http_port)

if old_parsed.scheme == "http" and _hostport(old_parsed) == _hostport(current_parsed):
# Build new_address from the redirect target's netloc so the
# target's port survives. Stripping "http://" off old_address
# (its predecessor) silently dropped the target port and
# broke enterprise on-prem deployments that run HTTPS on a
# non-default port (e.g. 8443). Normalize an explicit 443
# away since it is the HTTPS default.
next_port = next_parsed.port
port_suffix = f":{next_port}" if next_port and next_port != 443 else ""
new_address = f"https://{next_host}{port_suffix}"
self.parent_srv._server_address = new_address
logger.info(f"Server redirected to HTTPS; updated server address to {new_address}")
# Auth-material policy: the request `parameters` (including the
# X-Tableau-Auth header and any session cookies) are forwarded
# to the redirect target unchanged. This is intentional and
# required. TSC is a client library for a specific server the
# caller has already agreed to trust, and customers routinely
# deploy Tableau Server behind reverse proxies, load balancers,
# and SSO front-ends that redirect between hosts within their
# own infrastructure (e.g. tableau.corp.example -> east.tableau.
# corp.example, or an SSO IdP -> the auth-callback endpoint on
# a different subdomain). Stripping X-Tableau-Auth on cross-
# host redirects would break sign-in against every such
# deployment. The HTTPS -> HTTP downgrade guard above (line 208)
# is the boundary that keeps this from becoming a security
# regression: once the caller connects over HTTPS, the token
# never leaves TLS.
# Auth-material policy: the request `parameters` (headers, body,
# cookies) are forwarded to the redirect target unchanged. Two
# cases carry credentials:
# 1. Already-signed-in calls carry the issued token in the
# X-Tableau-Auth header and session cookies.
# 2. sign_in itself has no token yet; the raw credentials
# (username+password or PAT secret) travel in the POST
# body of the signin request.
# Both are forwarded on redirect. This is intentional. TSC is a
# client library for a specific server the caller has already
# agreed to trust, and customers routinely deploy Tableau Server
# behind reverse proxies, load balancers, and SSO front-ends that
# redirect between hosts within their own infrastructure
# (e.g. tableau.corp.example -> east.tableau.corp.example, or
# an SSO IdP -> the auth-callback endpoint on a different
# subdomain). Stripping credentials on cross-host redirects
# would break sign-in against every such deployment.
# The HTTPS -> HTTP downgrade guard above (line 208) is the
# boundary that keeps this from becoming a security regression:
# once the caller connects over HTTPS, credentials never leave
# TLS to a downgraded target.
Comment thread
jacalata marked this conversation as resolved.
logger.debug(f"Following {response.status_code} redirect: {current_url} -> {next_url}")
history.append(response)
current_url = next_url
Expand Down
60 changes: 60 additions & 0 deletions test/test_redirect_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ def capture(self, response, url=None):

@pytest.mark.parametrize("code", [301, 302, 303, 307, 308])
def test_all_supported_redirect_codes_preserve_post_body(server: TSC.Server, code: int) -> None:
# Uniform method preservation across all five supported codes -- 303 is
# a deliberate deviation from RFC 7231 section 6.4.4 (which says 303
# SHOULD convert POST to GET). Tableau Server has historically returned
# 303s that expect the original method/body, so we preserve rather than
# convert. If a future refactor "helpfully" converts 303 to GET, this
# test fails on the 303 param.
xml = _sign_in_xml()
seen_bodies: list[bytes | None] = []

Expand Down Expand Up @@ -263,6 +269,60 @@ def test_http_to_https_upgrade_promotes_stored_server_address(server: TSC.Server
assert server._server_address == "https://test"


def test_http_to_https_upgrade_preserves_explicit_target_port(server: TSC.Server) -> None:
# Enterprise on-prem installs commonly run HTTPS on a non-default port
# (e.g. 8443). If the redirect target carries an explicit port, the
# promoted server address must keep it -- otherwise every subsequent
# request goes to :443 and fails.
assert server._server_address == "http://test"
xml = _sign_in_xml()
with requests_mock.mock() as m:
m.post(
server.auth.baseurl + "/signin",
status_code=301,
headers={"Location": "https://test:8443/api/3.6/auth/signin"},
)
m.post("https://test:8443/api/3.6/auth/signin", text=xml)
server.auth.sign_in(TSC.TableauAuth("u", "p"))
assert server._server_address == "https://test:8443"


def test_http_to_https_upgrade_normalizes_default_ports() -> None:
# http://host:80 -> https://host:443 with both ports at their scheme
# defaults should collapse to "https://host" (no port suffix), matching
# how a user would type it.
s = TSC.Server("http://test:80", False)
assert s._server_address == "http://test:80"
xml = _sign_in_xml()
with requests_mock.mock() as m:
m.post(
s.auth.baseurl + "/signin",
status_code=301,
headers={"Location": "https://test:443/api/3.6/auth/signin"},
)
m.post("https://test:443/api/3.6/auth/signin", text=xml)
s.auth.sign_in(TSC.TableauAuth("u", "p"))
assert s._server_address == "https://test"


def test_http_to_https_upgrade_does_not_promote_to_different_host_with_port(server: TSC.Server) -> None:
# Cross-host redirect: even to an https:8443 endpoint, do NOT rewrite
# the stored server address. Same non-promotion contract as the
# port-less different-host case; guards against a scenario where the
# port-preserving fix accidentally widens the same-host check.
assert server._server_address == "http://test"
xml = _sign_in_xml()
with requests_mock.mock() as m:
m.post(
server.auth.baseurl + "/signin",
status_code=301,
headers={"Location": "https://other-host:8443/api/3.6/auth/signin"},
)
m.post("https://other-host:8443/api/3.6/auth/signin", text=xml)
server.auth.sign_in(TSC.TableauAuth("u", "p"))
assert server._server_address == "http://test"


def test_http_to_https_upgrade_does_not_promote_on_different_host(server: TSC.Server) -> None:
# If the redirect target is on a different host, do NOT rewrite the stored
# server address -- the redirect might be to a completely unrelated server
Expand Down
Loading