Skip to content

security: rewind file bodies before 401 retry to prevent empty uploads - #409

Merged
cyberjunky merged 2 commits into
masterfrom
security/upload-retry-rewind
Aug 10, 2026
Merged

security: rewind file bodies before 401 retry to prevent empty uploads#409
cyberjunky merged 2 commits into
masterfrom
security/upload-retry-rewind

Conversation

@cyberjunky

@cyberjunky cyberjunky commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes an externally reported data-integrity flaw (CVSS 4.0 8.2 / High) in the 401 retry path of _run_request.

After a 401, _run_request refreshed the session and retried with the same **kwargs. For uploads (upload_activity, import_activity), kwargs["files"] holds an open file handle that requests reads to EOF while encoding the multipart body on attempt #1. The retry re-read from EOF and sent an empty file part — and the client reported success.

Impact

  • Any 401 on the first upload attempt — routine token expiry at upload time, or a hostile/MITM response — deterministically caused a truncated/empty activity upload that was reported as successful. Silent loss of the user's own activity data.
  • Secondary defect: the retry used bare get_api_headers(), dropping the caller's custom headers (NK, origin, User-Agent: GCM-iOS…) that import_activity depends on.

Fix

  • Snapshot stream positions of all file-like bodies (files dict/list forms, tuple variants, file-like data) before attempt added Garmin Connect heart rate endpoint #1; rewind them to the caller's original positions (not blindly to 0) before the retry.
  • If any body is unseekable/unrewindable, skip the retry so the 401 raises GarminConnectConnectionError — fail loudly instead of silently uploading an empty body.
  • Re-merge the caller's custom headers over fresh API headers on the retry.

New helpers: _iter_file_objects, _capture_file_positions, _restore_file_positions.

Tests

tests/test_garmin_unit.py::Test401RetryFileRewind (4 new, all pass):

  • test_retry_rewinds_file_body — both attempts read the full payload
  • test_retry_restores_initial_position_not_zero — respects caller's initial offset
  • test_unseekable_body_skips_retry_and_raises — no blind retry; raises
  • test_retry_keeps_custom_headers — NK/User-Agent survive the retry

No regressions in the unit suite.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of file uploads when requests are retried after authentication renewal.
    • Preserved the original upload position, including nonzero starting positions.
    • Safely skipped retries for upload streams that cannot be rewound, preventing failed or corrupted submissions.
    • Preserved custom request headers during authentication retries.
  • Tests

    • Added coverage for upload retry behavior across seekable and unseekable streams, including uploads starting from nonzero positions.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The client captures upload stream positions before requests, restores them after token refresh, and retries only when restoration succeeds. Unit tests cover seekable and unseekable bodies, nonzero positions, consumed streams, and custom headers.

Changes

Request Body Retry Handling

Layer / File(s) Summary
Stream position management
garminconnect/client.py
Internal helpers discover file-like objects in data and files, record rewindable positions, and restore positions safely.
Authenticated retry flow
garminconnect/client.py, tests/test_garmin_unit.py
_run_request retries a 401 only after restoring request bodies. Tests cover seekable uploads, mapping-based files, generators, unseekable streams, restoration failures, and custom headers.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant _run_request
  participant UploadStreams
  participant TokenRefresh
  Caller->>_run_request: Send request with upload streams
  _run_request->>UploadStreams: Capture stream positions
  _run_request->>TokenRefresh: Refresh token after 401
  _run_request->>UploadStreams: Restore stream positions
  UploadStreams-->>_run_request: Return restoration result
  _run_request->>Caller: Retry request or return original 401
Loading

Suggested reviewers: tamcore, mannmann2, rifusaki

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: rewinding file bodies before retrying requests after a 401 response.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/upload-retry-rewind

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cyberjunky
cyberjunky force-pushed the security/upload-retry-rewind branch from 1ae7bff to 1f793bc Compare August 10, 2026 10:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 189-204: Update _iter_file_objects to recognize files values from
collections.abc.Mapping, not only dict, and classify streamed data iterables
such as generators even when they lack read(). Ensure _capture_file_positions
returns None for any non-rewindable streamed body so _run_request skips the 401
retry rather than resending consumed content; preserve rewindable file handling.
Add regression coverage for mapping-like files and generator data.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5fe70fae-3906-44ca-beea-33f7a10a59a4

📥 Commits

Reviewing files that changed from the base of the PR and between 76452a5 and 1ae7bff.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Comment thread garminconnect/client.py
@cyberjunky
cyberjunky force-pushed the security/upload-retry-rewind branch 2 times, most recently from 07f327d to 4cf7b57 Compare August 10, 2026 12:22
@cyberjunky
cyberjunky force-pushed the security/upload-retry-rewind branch from 4cf7b57 to e5ce1e6 Compare August 10, 2026 12:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 187-188: Update _iter_file_objects to annotate its generator
return type as Iterator[Any] instead of Any, and add the required Iterator
import using the file’s existing typing-import style.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2e07e2f9-bc90-4658-a47e-c766c89ec35d

📥 Commits

Reviewing files that changed from the base of the PR and between 1ae7bff and e5ce1e6.

📒 Files selected for processing (1)
  • garminconnect/client.py

Comment thread garminconnect/client.py Outdated
@cyberjunky
cyberjunky force-pushed the security/upload-retry-rewind branch from e5ce1e6 to 33208e3 Compare August 10, 2026 13:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 228-235: Update _restore_file_positions to treat a missing seek
method like other restoration failures by catching AttributeError alongside
OSError and ValueError. Ensure it returns False so retry logic does not resend a
consumed body when restoration cannot be performed.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3ced7298-f09a-4d97-aba2-8b8d9e7d3fe3

📥 Commits

Reviewing files that changed from the base of the PR and between e5ce1e6 and 33208e3.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Comment thread garminconnect/client.py
@cyberjunky
cyberjunky force-pushed the security/upload-retry-rewind branch from 33208e3 to 22b6f13 Compare August 10, 2026 13:18
_run_request retried a 401 by re-sending the same **kwargs. For uploads,
kwargs['files'] holds an open file handle that requests reads to EOF
while encoding the multipart body on the first attempt; the retry
re-read from EOF and uploaded an empty file part while reporting
success. Any 401 during an upload (routine token expiry or a hostile/
MITM response) caused deterministic silent data loss (report 3995).

Snapshot the stream position of every file-like body before the first
attempt and rewind before retrying. If a body is unseekable, skip the
retry and let the 401 raise GarminConnectConnectionError instead of
silently sending an empty part. The retry now also re-merges the
caller's custom headers (NK/origin/User-Agent), which were previously
dropped in favor of bare get_api_headers().
@cyberjunky
cyberjunky force-pushed the security/upload-retry-rewind branch from 22b6f13 to 1dc6d20 Compare August 10, 2026 13:28
Addresses CodeRabbit review on PR #409:
- _iter_file_objects: recognize any Mapping for files (requests accepts
  them via to_key_val_list), not only dict; annotate as Iterator[Any].
- _capture_file_positions: return None for iterator data bodies (e.g.
  generators) that attempt #1 consumes and cannot be rewound.
- _restore_file_positions: catch AttributeError (missing seek) like other
  restoration failures so the retry is skipped instead of crashing.

Adds regression tests for Mapping files, generator data, and seek-less
duck-typed streams.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 226-230: The streamed-body check in _capture_file_positions
currently misses one-shot Iterable objects that implement __iter__ without
__next__. Update it to reject non-file Iterable/.iter bodies before retrying,
while excluding materialized str, bytes, list, tuple, and Mapping values;
preserve existing Iterator handling and add a regression test alongside
test_generator_data_skips_retry_and_raises.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cdc42d74-3ed8-4e31-8be3-2802482b33b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1dc6d20 and 6b2f576.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Comment thread garminconnect/client.py
Comment on lines +226 to +230
data = kwargs.get("data")
if data is not None and not hasattr(data, "read") and isinstance(data, Iterator):
# Streamed iterable body (e.g. a generator): attempt #1 consumes it
# and it cannot be rewound, so a retry must not be attempted.
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from collections.abc import Iterable, Iterator

class OneShotBody:
    def __init__(self):
        self._body = iter((b"chunk1", b"chunk2"))

    def __iter__(self):
        return self._body

body = OneShotBody()
assert isinstance(body, Iterable)
assert not isinstance(body, Iterator)
assert list(body) == [b"chunk1", b"chunk2"]
assert list(body) == []
print("One-shot Iterable is missed by an Iterator-only check")
PY

python3 - <<'PY'
import inspect
import requests
from requests.models import PreparedRequest

print("requests:", requests.__version__)
print(inspect.getsource(PreparedRequest.prepare_body))
PY

Repository: cyberjunky/python-garminconnect

Length of output: 344


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files =="
git ls-files | sed -n '1,120p'

echo "== locate client.py and test files =="
fd -a 'client.py|test_garmin_unit.py' . | sed -n '1,80p'

echo "== inspect client.py relevant section =="
client=$(fd 'client.py' . | head -1)
if [ -n "$client" ]; then
  wc -l "$client"
  sed -n '180,260p' "$client" | nl -ba -v180
fi

echo "== search retry/capture helpers and tests =="
rg -n "_capture_file_positions|_restore_file_positions|_run_request|streamed|generator_data_skips|requests" -S .gitignore README.md tests garminconnect 2>/dev/null | sed -n '1,220p'

Repository: cyberjunky/python-garminconnect

Length of output: 1614


🌐 Web query:

Requests PreparedRequest prepare_body source streamed body callable iterator read

💡 Result:

In the Python requests library, the PreparedRequest.prepare_body method handles the preparation of request bodies, including detecting whether the provided data is a streamed body [1][2]. When data is passed to prepare_body, the library identifies it as a stream if it is iterable (typically checked via collections.abc.Iterable or the presence of a iter attribute) and is not a standard type like a string, bytes, list, tuple, or Mapping [1][3][2]. Key mechanisms for streamed bodies include: 1. Stream Detection: Modern versions of requests use a check similar to isinstance(data, Iterable) or hasattr(data, "iter") to identify streams [3][2]. Using both ensures compatibility with objects that rely on getattr delegation for their interface, such as file-like wrappers [4][3]. 2. Rewind Capability: If the streamed object has a tell method, requests records the current position in the _body_position attribute [2][5]. This allows the library to attempt to rewind the stream if the request needs to be resent, such as following a 307 or 308 redirect [4][2]. 3. Content Length: If a length can be determined for the stream (using super_len), it is set in the Content-Length header; otherwise, the library uses Transfer-Encoding: chunked [1][2]. A recent regression in version 2.34.0 (addressed in PR #7433) highlighted that relying solely on isinstance(data, Iterable) can break file-like objects that dynamically proxy their interface, leading to issues where the body cannot be properly rewound during redirects [4][3]. The fix reinstated the explicit check for the iter attribute to ensure robust support for these patterns [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

client="$(fd 'client.py' . | head -1)"
test_file="$(fd 'test_garmin_unit.py' . | head -1)"

echo "== client imports and retry helpers =="
sed -n '1,40p' "$client"
sed -n '190,245p' "$client"

echo "== test generator/stream body context =="
sed -n '1,80p' "$test_file"
rg -n "def test_generator_data_skips_retry_and_raises|_capture_file_positions|_run_request|Iterab|Iterator|OneShot|Generator|stream" "$test_file" "$client"

echo "== Python standard library probe for current Iterator check =="
python3 - <<'PY'
from collections.abc import Iterable, Iterator, Mapping

class OneShotBody:
    def __init__(self):
        self._body = iter((b"chunk1", b"chunk2"))
    def __iter__(self):
        return self._body

# Mirrors the current request body classification before retry decisions
class Current:
    pass
Current.__bases__ = ()

def current_is_iterable_file(data):
    return data is not None and hasattr(data, "read") and Current.__bases__ == ()

def current_is_stream(data):
    return data is not None and not hasattr(data, "read") and isinstance(data, Iterator)

body = OneShotBody()
print("object", type(body).__name__)
print("is_iterable_file()", current_is_iterable_file(body))
print("is_stream()", current_is_stream(body))
print("first list()", list(body))
print("second list()", list(body))

# Modern equivalent with materialized container exclusion
def fixed_is_stream(data):
    return (data is not None
            and not hasattr(data, "read")
            and (isinstance(data, Iterable) or hasattr(data, "iter"))
            and not isinstance(data, (str, bytes, list, tuple, Mapping)))

print("fixed_is_stream()", fixed_is_stream(body))
print("fixed first list()", list(body))
print("fixed second list()", list(body))
PY

Repository: cyberjunky/python-garminconnect

Length of output: 9409


🌐 Web query:

site:github.com/psf/requests PreparedRequest.prepare_body is Iterable or hasattr "iter"

💡 Result:

In the requests library, the PreparedRequest.prepare_body method determines if the provided body data should be treated as a stream (iterable) by checking if the data is an instance of collections.abc.Iterable or has the iter attribute [1][2]. For many years, the library exclusively used hasattr(data, "iter") to detect streams [3][2]. However, a change was introduced to use isinstance(data, Iterable) for better adherence to modern Python practices [3][2]. This change caused a regression for objects that use getattr-based delegation (such as file-like wrappers like tqdm.wrapattr), because isinstance only checks the class MRO and ignores attributes resolved via getattr [3][2]. To address this, as of May 2026, the implementation in the main branch of the requests library was updated to use both checks in conjunction to ensure compatibility: if (isinstance(data, Iterable) or hasattr(data, "iter")) and not isinstance(data, (str, bytes, list, tuple, Mapping)): # logic for handling streamed bodies [1][2] This ensures that the library correctly identifies both standard iterables and objects that proxy their iterator interface through dynamic attribute delegation [2][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

client="$(fd 'client.py' . | head -1)"
test_file="$(fd 'test_garmin_unit.py' . | head -1)"

echo "== full _capture/_restore helper =="
sed -n '189,229p' "$client"

echo "== _run_request relevant section =="
sed -n '1560,1660p' "$client"

echo "== generator test context =="
sed -n '1728,1770p' "$test_file"

echo "== deterministic probe mirroring current helper predicate =="
python3 - <<'PY'
from collections.abc import Iterator, Mapping, Iterable

class OneShotData:
    def __iter__(self):
        return iter([b"x", b"y"])

def current_capture_returns_none(data):
    return data is not None and not hasattr(data, "read") and isinstance(data, Iterator)

print("current_capture_returns_none(generator_function_called)", current_capture_returns_none((lambda: (x for x in []))()))
print("current_capture_returns_none(one_shot_iterable)", current_capture_returns_none(OneShotData()))
print("current_capture_returns_none(generator_function_called)", current_capture_returns_none((lambda: (x for x in []))()))
print("current_capture_returns_none(one_shot_iterable)", current_capture_returns_none(OneShotData()))

class ModernPredicate:
    pass
ModernPredicate.__bases__ = ()
def current_like(data):
    return data is not None and not hasattr(data, "read") and isinstance(data, Iterator)

def fixed_like(data):
    return (
        data is not None
        and not hasattr(data, "read")
        and (isinstance(data, Iterable) or hasattr(data, "iter"))
        and not isinstance(data, (str, bytes, list, tuple, Mapping))
    )

for body in [(lambda: (x for x in []))(), OneShotData(), iter((b"one", b"two"))]:
    print(type(body).__name__, "current=", current_like(body), "fixed=", fixed_like(body))

print(_iter_file_objects.__self__)
PY

Repository: cyberjunky/python-garminconnect

Length of output: 7514


Reject one-shot Iterable bodies before retrying.

_capture_file_positions only treats Iterator bodies as non-retryable, but Requests also streams objects that define __iter__ without __next__. Such bodies can be consumed by the first request, allowing the 401 path to retry with an empty iterable. Match the stream check with Iterable/.iter, excluding materialized str, bytes, list, tuple, and Mapping bodies, and add a regression test near test_generator_data_skips_retry_and_raises.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@garminconnect/client.py` around lines 226 - 230, The streamed-body check in
_capture_file_positions currently misses one-shot Iterable objects that
implement __iter__ without __next__. Update it to reject non-file Iterable/.iter
bodies before retrying, while excluding materialized str, bytes, list, tuple,
and Mapping values; preserve existing Iterator handling and add a regression
test alongside test_generator_data_skips_retry_and_raises.

@cyberjunky
cyberjunky merged commit 7ce4d2a into master Aug 10, 2026
4 checks passed
@cyberjunky
cyberjunky deleted the security/upload-retry-rewind branch August 10, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant