-
Notifications
You must be signed in to change notification settings - Fork 11
OAuth transport hardening: headers-first, totally-bounded fetches (Ruby + Python) #376
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
Open
jeremy
wants to merge
17
commits into
oauth-device-flow
Choose a base branch
from
oauth-transport-hardening
base: oauth-device-flow
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
fec4541
oauth: headers-first bounded transport as the default (Ruby)
jeremy 20124e8
oauth: share the bounded worker transport with discovery (Python)
jeremy cc644ac
transport: map protocol-parse failures and drop the dead Faraday buil…
jeremy 9cffa63
transport: keep the worker-join grace small (Python)
jeremy 4462323
transport: status dominates the discovery body; verify TLS rejection …
jeremy 85a8635
transport: status dominates the discovery body (Python)
jeremy a9ddf8a
transport: timeout-map ETIMEDOUT, default the form content type, stat…
jeremy cb33e6b
transport: assert no leaked worker in the stalled-500 test (Python)
jeremy 6feb394
transport: fail closed on hostless endpoint URLs (Ruby)
jeremy db1583f
transport: require the stdlib the primitive uses (Ruby)
jeremy e6727ef
transport: fail fast on helper misuse (Ruby + Python)
jeremy 243007e
transport: bound and map the write phase too (Ruby)
jeremy c642f8d
transport: tolerate platform-specific peer-close errno in test server…
jeremy 138caf3
transport: enforce the timeout contract in request_bounded; document …
jeremy c282e1e
test: fold Ruby's lazy Timeout worker thread into the watchdog-leak b…
jeremy 5da2fa5
transport: adapt the poll-entry timeout normalization to the renamed …
jeremy baf16b2
transport: state the watchdog's session-start scope; re-check the dea…
jeremy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| """Bounded HTTP transport shared by the OAuth discovery and device-flow fetches. | ||
|
|
||
| Sync httpx has NO total-request timeout: its timeout is per-read and RESETS on | ||
| every received chunk, so a peer dripping header or body bytes just under that | ||
| interval can hold a request open indefinitely (verified), and closing a sync | ||
| client from a watchdog thread does not interrupt a blocked read either. The | ||
| core here runs an async client under ``asyncio.wait_for`` on a dedicated | ||
| worker thread, so the deadline CANCELS the request (and closes the socket) — | ||
| the caller is bounded regardless of chunk cadence AND the work is actually | ||
| terminated, no leaked connection. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import math | ||
| import threading | ||
| from collections.abc import Callable | ||
|
|
||
| import httpx | ||
|
|
||
| from basecamp.oauth.errors import OAuthError | ||
|
|
||
| #: Extra time (seconds) to let a timed-out request's async cancellation/cleanup | ||
| #: unwind before the caller abandons the (daemon) worker and returns a timeout. | ||
| #: Kept SMALL so the caller's worst-case block stays ~timeout: cleanup after a | ||
| #: wait_for cancellation is just closing a socket, and joining with no grace at | ||
| #: all would race a request that completes right at the deadline. The daemon | ||
| #: worker never blocks interpreter exit if even this stalls. | ||
| _WORKER_JOIN_GRACE = 1.0 | ||
|
|
||
| #: Upper bound (seconds) on a bounded request timeout. A per-request timeout | ||
| #: beyond this is nonsensical, and a huge finite value would overflow the | ||
| #: wall-clock wait primitive (asyncio.wait_for / thread join); callers clamp | ||
| #: to their operation default above it (see ``_normalize_timeout``). | ||
| _MAX_REQUEST_TIMEOUT = 3600.0 | ||
|
|
||
|
|
||
| def request_bounded( | ||
| method: str, | ||
| url: str, | ||
| *, | ||
| headers: dict[str, str], | ||
| params: dict[str, str] | None = None, | ||
| timeout: float, | ||
| max_body_bytes: int, | ||
| read_body: Callable[[int], bool] = lambda _status: True, | ||
| context: str = "OAuth", | ||
| ) -> tuple[int, bytes]: | ||
| """SSRF-hardened request: suppress redirects, bound the WHOLE round-trip by | ||
| ``timeout``, and read the body under a genuine streaming cap that aborts once | ||
| ``max_body_bytes`` is exceeded (never a post-hoc check on an already-buffered | ||
| body). ``params``, when given, is sent as a form body (POST). | ||
|
|
||
| ``timeout`` must arrive ALREADY normalized — finite, positive, and no greater | ||
| than :data:`_MAX_REQUEST_TIMEOUT` (callers run it through | ||
| ``discovery._normalize_timeout``, which this module cannot import without a | ||
| cycle). An unnormalized value would disable the ``wait_for`` deadline | ||
| (``inf`` never fires) or overflow the wait primitive. | ||
|
|
||
| ``read_body(status)`` decides — from the response status, known once headers | ||
| arrive — whether the body is drained. A caller returns ``False`` for statuses | ||
| whose body it does not use, so a slow/never-ending body cannot time out | ||
| mid-read and be misclassified as a retryable transport failure instead of | ||
| the api_error the status already is. | ||
|
|
||
| Transport failures propagate as :class:`httpx.HTTPError` (incl. | ||
| :class:`httpx.TimeoutException`) so callers classify them; an oversized body | ||
| raises :class:`OAuthError` (``api_error``). ``context`` labels both messages. | ||
| """ | ||
|
|
||
| if params is not None and method != "POST": | ||
| # A form body on a non-POST would emit e.g. a GET-with-body — commonly | ||
| # rejected server-side and hard to debug; fail fast on the misuse. | ||
| raise ValueError("request_bounded: params (a form body) is only valid with POST") | ||
|
|
||
| # Defensive enforcement of the caller-normalization contract: an inf/nan/ | ||
| # non-positive/oversized timeout would disable or overflow the wait_for | ||
| # deadline and the thread join — the very bound this module exists to | ||
| # provide. Callers run through _normalize_timeout; a violation here is a | ||
| # programming error, so fail fast. Range checks run BEFORE math.isfinite: | ||
| # isfinite converts an int to float, so an astronomically large int | ||
| # (10**400) would raise OverflowError out of the guard instead of this | ||
| # ValueError; int/float comparisons never convert, and NaN compares False | ||
| # on both, falling through to the isfinite reject. | ||
| if ( | ||
| isinstance(timeout, bool) | ||
| or not isinstance(timeout, (int, float)) | ||
| or timeout <= 0 | ||
| or timeout > _MAX_REQUEST_TIMEOUT | ||
| or not math.isfinite(timeout) | ||
| ): | ||
| raise ValueError( | ||
| "request_bounded: timeout must be a finite positive number of seconds " | ||
| f"no greater than {_MAX_REQUEST_TIMEOUT}" | ||
| ) | ||
|
|
||
| async def _do() -> tuple[int, bytes]: | ||
| async with ( | ||
| httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client, | ||
| client.stream(method, url, data=params, headers=headers) as response, | ||
| ): | ||
| if not read_body(response.status_code): | ||
| return response.status_code, b"" | ||
| chunks: list[bytes] = [] | ||
| total = 0 | ||
| async for chunk in response.aiter_bytes(): | ||
| total += len(chunk) | ||
| if total > max_body_bytes: | ||
| # An oversized body is api_error, not a timeout — abort the | ||
| # stream so it is never fully buffered. | ||
| raise OAuthError("api_error", f"{context} response exceeds size cap") | ||
| chunks.append(chunk) | ||
| return response.status_code, b"".join(chunks) | ||
|
|
||
| # httpx's timeout is per-read (it resets on every received chunk) and httpx has | ||
| # NO total-request timeout, so a peer slow-dripping header or body bytes just | ||
| # under that interval could otherwise hold the request open indefinitely | ||
| # (verified); closing a sync client from a watchdog does not interrupt a blocked | ||
| # read either. asyncio.wait_for CANCELS the request (and closes the socket) at | ||
| # the deadline — the caller is bounded AND the socket work is actually | ||
| # terminated. Known residual: a getaddrinfo blocked in the OS resolver runs on | ||
| # an executor thread that cancellation cannot interrupt, so a slow-DNS attempt | ||
| # leaves a daemon worker alive until the RESOLVER's own timeout (seconds to | ||
| # ~30s, OS-bounded) — the caller still returns at the deadline, the stragglers | ||
| # are bounded in lifetime by the resolver and in count by the attempt rate, | ||
| # and they never block interpreter exit. Sync Python offers no stronger | ||
| # DNS bound short of process isolation. | ||
| # | ||
| # Run it in a DEDICATED thread with its own event loop rather than calling | ||
| # asyncio.run() here: this sync helper may be invoked from code that already has | ||
| # a running loop (Jupyter/FastAPI/async CLI), where asyncio.run() raises | ||
| # RuntimeError before any request is made. wait_for bounds the thread's work at | ||
| # ~timeout, so the bounded join below normally returns almost immediately; the | ||
| # is_alive backstop after it covers only a pathological async-cleanup hang. | ||
| result: list[tuple[int, bytes]] = [] | ||
| error: list[Exception] = [] | ||
|
|
||
| def _runner() -> None: | ||
| try: | ||
| result.append(asyncio.run(asyncio.wait_for(_do(), timeout))) | ||
| except Exception as exc: # captured and re-raised on the caller thread | ||
| error.append(exc) | ||
|
|
||
| worker = threading.Thread(target=_runner, daemon=True) | ||
| worker.start() | ||
| # asyncio.wait_for cancels the request at `timeout`, so the worker normally | ||
| # finishes well within it. Join with a small grace for the cancellation/cleanup | ||
| # to unwind; if even that stalls (a pathological async cleanup hang), return a | ||
| # timeout rather than block the caller — the daemon worker never blocks | ||
| # interpreter exit. This is bounded AND non-leaking in every non-pathological case. | ||
| worker.join(timeout + _WORKER_JOIN_GRACE) | ||
|
jeremy marked this conversation as resolved.
|
||
| if worker.is_alive(): | ||
| raise httpx.ReadTimeout(f"{context} request exceeded the timeout deadline") | ||
|
jeremy marked this conversation as resolved.
|
||
| if error: | ||
| exc = error[0] | ||
| # On Python >= 3.11 (this package's floor) asyncio.TimeoutError IS the | ||
| # builtin TimeoutError, so this catches wait_for's deadline expiry. | ||
| if isinstance(exc, TimeoutError): | ||
| raise httpx.ReadTimeout(f"{context} request exceeded the timeout deadline") from exc | ||
| raise exc | ||
| return result[0] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.