-
Notifications
You must be signed in to change notification settings - Fork 4
fix: client-side security hardening against API weaknesses #145
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
base: main
Are you sure you want to change the base?
Changes from all commits
fb352e3
6cbe74a
d792c91
013e3d5
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 |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # Memory Index | ||
| - [OtfRequestError optional fields](exceptions_optional_fields.md) — response/request typed non-Optional but sometimes passed as None with type:ignore; crashes documented `e.request.method` usage. | ||
|
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Describe the This index says |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| --- | ||
| name: exceptions-optional-fields | ||
| description: OtfRequestError.response/.request are typed non-Optional; historical None call-site no longer exists | ||
| metadata: | ||
| type: project | ||
| --- | ||
|
|
||
| <!-- 2026-09-05 (updated) --> | ||
| `src/otf_api/exceptions.py`'s `OtfRequestError` declares `response: httpx.Response` and | ||
|
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add a top-level heading after the front matter. markdownlint-cli2 reports MD041 because the first prose line is not a level-one heading. Add 🧰 Tools🪛 markdownlint-cli2 (0.23.2)[warning] 9-9: First line in a file should be a top-level heading (MD041, first-line-heading, first-line-h1) Source: Linters/SAST tools |
||
| `request: httpx.Request` as non-Optional class attributes. An earlier note recorded a | ||
| `member_api.py` IDOR-check call site passing `response=None, request=None` with | ||
| `# type: ignore[arg-type]` — **verified stale as of 2026-09-05**: that call site was | ||
| refactored (commit `d792c91`, "address clean code review findings") to raise a plain | ||
| `ValueError` instead of `OtfRequestError`, so no current call site passes None for | ||
| these fields. Grepped all `OtfRequestError(`/`RetryableOtfRequestError(` call sites in | ||
| `src/` and `tests/` on that date — all pass real `httpx.Request`/`httpx.Response` | ||
| objects. | ||
|
|
||
| **Why it still matters:** `OtfRequestError.__init__` now (as of the same date) mutates | ||
| `self.response.request` and `self.original_exception.request` in place to redact | ||
| sensitive headers from any retained references. If a future call site ever reintroduces | ||
| `response=None` or `request=None`, this would crash with `AttributeError` before the | ||
| message even gets set. `_sanitize_request()` has no None-guard either. | ||
|
|
||
| **How to apply:** When reviewing new `raise exc.OtfRequestError(...)` call sites, check | ||
| whether `response`/`request` are ever `None`. If so, flag it — either the field types | ||
| need to become `Optional`, or the call site needs a real request/response object. Don't | ||
| trust this note's own history at face value either — re-verify against current | ||
| `src/otf_api/api/client.py` before citing it, since it has already gone stale once. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # Memory Index | ||
|
|
||
| - [Identifier validation convention](identifier-validation-convention.md) — path-segment identifiers must be validated at the *Client layer before URL interpolation; check for gaps when reviewing future security diffs. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| --- | ||
| name: identifier-validation-convention | ||
| description: otf-api's convention for validating URL-path-segment identifiers (booking_uuid, class_uuid, studio_uuid, performance_summary_id) before interpolating into request paths | ||
| metadata: | ||
| type: project | ||
| --- | ||
|
|
||
| <!-- 2026-09-05 --> | ||
|
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add a top-level heading after the YAML front matter.
Source: Linters/SAST tools |
||
| As of commit fb352e3 ("client-side security hardening against API weaknesses"), the established | ||
| pattern is: any identifier interpolated into an HTTP path segment must pass through | ||
| `otf_api.api.utils.validate_identifier()` (rejects path separators, `..`, null bytes, whitespace, | ||
| percent-encoding) before being used to build a URL. This is applied either directly in the | ||
| `*Client` class (e.g. `StudioClient.get_studio_detail`, `WorkoutClient.get_performance_summary`) | ||
| or via the shared `utils.get_booking_uuid`/`get_booking_id`/`get_class_uuid`/`get_class_id` helpers | ||
| that `*Api` classes call before delegating to the client. | ||
|
|
||
| **Known gap as of fb352e3 (flagged, not yet fixed):** `BookingClient.get_booking` interpolates | ||
| `booking_uuid` into a path with no validation, and `BookingApi.get_booking` (a public, | ||
| directly-callable method) does not call `utils.get_booking_uuid` before forwarding. Check this | ||
| specific method when reviewing future diffs — if still unpatched, it's a real gap, not a false | ||
| positive. | ||
|
|
||
| **Resolved (as of 2026-09-05, branch `security/client-hardening`):** `TrendApi.get_workout_stats` | ||
| now does both — enforces `isinstance(trend_type, TrendType)` (raising `TypeError`, matching the | ||
| `get_booking_id`/`get_class_uuid`-style "Expected X or str" convention in `api/utils.py`) *and* | ||
| still runs `trend_type.value` through `utils.validate_identifier` before it reaches the client. | ||
| Reconciled, not a parallel technique. | ||
|
|
||
| **Resolved:** `OtfRequestError(response=None, request=None)` call site is gone — grep for | ||
| `response=None` / `request=None` across `src/otf_api` turns up nothing. `response`/`request` | ||
| stay typed non-Optional. | ||
|
|
||
| **New in the same hardening pass:** `OtfRequestError.__init__` now mutates the *caller-supplied* | ||
| `response`/`original_exception` objects in place (`self.response.request = sanitized_request`, | ||
| `self.original_exception.request = sanitized_request`) so that downstream code holding the same | ||
| reference (e.g. `anonymize/hooks.py`, which reads `response.request` for logging) doesn't see the | ||
| unsanitized request. This is a deliberate, load-bearing exception to the "never mutate existing | ||
| objects" rule — the leak isn't closed otherwise, since `self.request` alone doesn't cover | ||
| `response.request`/`original_exception.request`. Flag it as intentional if seen again, not a fresh | ||
| violation. | ||
|
|
||
| **Tooling gap:** `ruff.toml` excludes `tests/` entirely (`exclude = [..., "tests"]`), so | ||
| `ruff check .` / the `ruff-check` pre-commit hook never lints test files — unused imports, | ||
| missing annotations, etc. in `tests/` pass CI silently. Running `ruff check <path>` with an | ||
| explicit test file path bypasses the exclude and does catch these; use that when reviewing test | ||
| diffs in this repo, since the standard hook won't. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -178,3 +178,4 @@ scratch*.py | |
| docs/reference/ | ||
| .vscode | ||
| .playwright-mcp/ | ||
| .claude/worktrees/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,11 @@ | |
| CACHE = get_cache() | ||
| LOGGER = getLogger(__name__) | ||
|
|
||
| # Maximum response body size (bytes) the library will attempt to parse as JSON. | ||
| # The response is already buffered by this point — this prevents wasting CPU on | ||
| # parsing an unexpectedly large payload, not the memory cost of the read itself. | ||
| MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10 MB | ||
|
|
||
|
|
||
| class OtfClient: | ||
| """Client for interacting with the OTF API - generally to be used by the Otf class. | ||
|
|
@@ -233,6 +238,22 @@ def _handle_response(self, method: str, response: httpx.Response, request: httpx | |
| LOGGER.debug("No content returned from %s %s", method, response.url) | ||
| return None | ||
|
|
||
| content_length = len(response.content) | ||
|
NodeJSmith marked this conversation as resolved.
|
||
| if content_length > MAX_RESPONSE_SIZE: | ||
|
NodeJSmith marked this conversation as resolved.
Comment on lines
+241
to
+242
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.
The fresh narrowing of Useful? React with 👍 / 👎. |
||
| LOGGER.error( | ||
| "Response from %s %s exceeds size limit (%d bytes, max %d)", | ||
| method, | ||
| response.url, | ||
| content_length, | ||
| MAX_RESPONSE_SIZE, | ||
| ) | ||
| raise exc.OtfRequestError( | ||
| f"Response too large ({content_length} bytes, max {MAX_RESPONSE_SIZE})", | ||
| original_exception=None, | ||
| response=response, | ||
| request=request, | ||
| ) | ||
|
|
||
| try: | ||
| json_data = response.json() | ||
| except JSONDecodeError as e: | ||
|
|
||
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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a blank line after the heading.
markdownlint-cli2 reports MD022 because the list starts immediately after the level-one heading.
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 1-1: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
Source: Linters/SAST tools