-
Notifications
You must be signed in to change notification settings - Fork 445
[tabcmd] fix: preserve POST body across 3xx redirects (#1127, #1828) #1848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
11a7534
b0e3e62
8ec8ed9
226c6e5
8535145
89602cc
f9bf81a
a5666fe
1ce99e0
75d456f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import os | ||
| from contextlib import closing | ||
| from typing_extensions import Concatenate, ParamSpec | ||
| from urllib.parse import urljoin, urlparse | ||
| from tableauserverclient import datetime_helpers as datetime | ||
|
|
||
| import abc | ||
|
|
@@ -30,6 +31,7 @@ | |
| InternalServerError, | ||
| NonXMLResponseError, | ||
| NotSignedInError, | ||
| RedirectError, | ||
| ) | ||
| from tableauserverclient.server.exceptions import EndpointUnavailableError | ||
|
|
||
|
|
@@ -45,6 +47,13 @@ | |
|
|
||
| Success_codes = [200, 201, 202, 204] | ||
|
|
||
| # 301/302/303/307/308 all indicate the caller should re-request at a new URL. | ||
| # `requests`' default handler converts POST -> GET on 301/302/303, which drops | ||
| # the POST body and breaks sign-in / addusers / publish / any write endpoint | ||
| # whose target sits behind a redirect. We disable that and walk the chain | ||
| # manually, keeping the original method and body across every hop. | ||
| Redirect_codes = [301, 302, 303, 307, 308] | ||
|
|
||
| XML_CONTENT_TYPE = "text/xml" | ||
| JSON_CONTENT_TYPE = "application/json" | ||
|
|
||
|
|
@@ -120,6 +129,11 @@ def _make_request( | |
| parameters = Endpoint.set_parameters( | ||
| self.parent_srv.http_options, auth_token, content, content_type, parameters | ||
| ) | ||
| # Manual redirect handling: see Redirect_codes comment. `requests` | ||
| # follows 301/302/303 by converting POST to GET (RFC-conforming but | ||
| # loses the body). We disable it here and re-issue the same method | ||
| # ourselves in _follow_redirect_if_any. | ||
| parameters["allow_redirects"] = False | ||
|
|
||
| logger.debug(f"request method {method.__name__}, url: {url}") | ||
| if content: | ||
|
|
@@ -144,6 +158,7 @@ def _make_request( | |
| raise RuntimeError | ||
| if isinstance(server_response, Exception): | ||
| raise server_response | ||
| server_response, url = self._follow_redirect_if_any(method, url, parameters, server_response) | ||
| self._check_status(server_response, url) | ||
|
|
||
| loggable_response = self.log_response_safely(server_response) | ||
|
|
@@ -157,6 +172,76 @@ def _make_request( | |
|
|
||
| return server_response | ||
|
|
||
| def _follow_redirect_if_any( | ||
| self, | ||
| method: Callable[..., "Response"], | ||
| url: str, | ||
| parameters: dict[str, Any], | ||
| server_response: "Response", | ||
| ) -> tuple["Response", str]: | ||
| # Walk a 301/302/303/307/308 chain up to session.max_redirects hops, | ||
| # preserving method and body. Rejects HTTPS -> HTTP scheme downgrades | ||
| # (silent security regression). Raises RedirectError on a missing | ||
| # Location header instead of the KeyError requests emits deep in its | ||
| # internals, and on exceeding the session hop limit. | ||
| try: | ||
| max_hops = int(self.parent_srv.session.max_redirects) | ||
| except (AttributeError, TypeError): | ||
| max_hops = 30 # requests' library default | ||
| current_url = url | ||
| response = server_response | ||
| # Not a redirect? Return immediately regardless of max_hops (including 0). | ||
| if response.status_code not in Redirect_codes: | ||
| return response, current_url | ||
| method_name = getattr(method, "__name__", "REQUEST").upper() | ||
| for _ in range(max_hops): | ||
| location = response.headers.get("Location") | ||
| if not location: | ||
| raise RedirectError( | ||
| f"{method_name} {current_url} returned HTTP {response.status_code} " | ||
| f"without a Location header; can't follow the redirect." | ||
| ) | ||
| # Support relative Locations per RFC 7231. | ||
| next_url = urljoin(current_url, location) | ||
| current_scheme = urlparse(current_url).scheme | ||
| next_scheme = urlparse(next_url).scheme | ||
| if current_scheme == "https" and next_scheme == "http": | ||
| raise RedirectError( | ||
| f"Refusing to follow redirect from {current_url} to {next_url}: " | ||
| f"HTTPS -> HTTP scheme downgrade would send request data over plaintext." | ||
| ) | ||
| # http -> https upgrade on the same host: promote the stored server | ||
| # address so subsequent requests skip this redirect round-trip. | ||
| # Only rewrite on same-host, same-path-root redirects to avoid | ||
| # accidentally pointing the client at an unrelated server. | ||
| 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: | ||
| old_address = self.parent_srv._server_address | ||
| if old_address.startswith("http://") and old_address[7:].startswith(current_parsed.netloc): | ||
| new_address = "https://" + old_address[7:] | ||
| self.parent_srv._server_address = new_address | ||
| logger.info(f"Server redirected to HTTPS; updated server address to {new_address}") | ||
|
Comment on lines
+237
to
+246
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 1ce99e0. Replaced the |
||
| logger.debug(f"Following {response.status_code} redirect: {current_url} -> {next_url}") | ||
| current_url = next_url | ||
| next_response = self._blocking_request(method, current_url, parameters) | ||
| if next_response is None: | ||
| raise RuntimeError(f"No response after redirect to {current_url}") | ||
| if isinstance(next_response, Exception): | ||
| # _blocking_request already re-raises via except -> raise, so this | ||
| # branch is defensive; keep it to satisfy the Response|Exception|None | ||
| # return type. | ||
| raise next_response | ||
| response = next_response | ||
| if response.status_code not in Redirect_codes: | ||
| return response, current_url | ||
| # Still a redirect after max_hops hops -> loop / misconfiguration. | ||
| raise RedirectError( | ||
| f"Exceeded {max_hops} redirect hops starting from {url}; last Location was {current_url}. " | ||
| f"Increase session.max_redirects if this is legitimate." | ||
| ) | ||
|
Comment on lines
+278
to
+281
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 1ce99e0 -- error message now reads "last URL attempted was {current_url}". |
||
|
|
||
| def _check_status(self, server_response: "Response", url: str | None = None): | ||
| logger.debug(f"Response status: {server_response}") | ||
| if not hasattr(server_response, "status_code"): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Filed separately as #1866 -- same bug was flagged by a fresh-eyes pass this morning. Not addressing on this PR because PR #1863 proposes removing the whole
Namespace.detectsubsystem entirely (dead code since TSC min supported server is 10.0, 2016). If #1863 lands, #1866 is moot; if not, we widen the guard there.