Skip to content

fix(nodes): harden Gmail credential handling — token_uri, broker refresh, schemeless broker URL (#1560)#1569

Open
meetp06 wants to merge 5 commits into
rocketride-org:developfrom
meetp06:fix/gh-1560-gmail-token-uri-validation
Open

fix(nodes): harden Gmail credential handling — token_uri, broker refresh, schemeless broker URL (#1560)#1569
meetp06 wants to merge 5 commits into
rocketride-org:developfrom
meetp06:fix/gh-1560-gmail-token-uri-validation

Conversation

@meetp06

@meetp06 meetp06 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Addresses #1560 — the credential-hardening items (see Scope).

1. Security: validate token_uri (refresh_token exfiltration)

tool_gmail trusted token_uri straight from the untrusted saved token. That URL is where google-auth POSTs the refresh_token; a tampered token could redirect it to an attacker host and exfiltrate the long-lived credential. resolve_token_uri() (mirrors the existing resolve_refresh_url) now accepts only https URLs on Google's official token endpoints (oauth2.googleapis.com, accounts.google.com), falls back to the canonical endpoint when absent, and raises otherwise.

2. Reliability: broker token refresh

Fixed several bugs in the broker refresh path, extracted into a testable _refresh_access_token_via_broker() helper:

  • HTTPError misreportedHTTPError subclasses URLError, so a broker reached-but-erroring was reported as "unreachable". Now caught separately with an accurate message.
  • 200 without access_token silently kept the stale token → now raises.
  • 200 without expiry_date left a stale past expiry → refresh POST on every API call (quota churn). Now falls back to a default access-token lifetime.
  • Socket timeout during resp.read() raises OSError, not URLError → now treated as unreachable.
  • Malformed/non-JSON bodies → reported as invalid response.

3. Config: schemeless RR_OAUTH_BROKER_URL

A schemeless value (broker.example.com) parsed with hostname=None and was silently dropped from the refresh allowlist. Now a scheme is prepended before parsing so the self-hosted broker host registers.

Tests

  • resolve_token_uri: Google endpoints accepted, absent → canonical, rejects scheme downgrade / attacker host / suffix-confusion lookalike / non-string.
  • broker refresh (mocked urlopen): success (broker + default expiry), missing access_token, HTTP error, connection error, read timeout, invalid JSON.
  • schemeless broker URL registers its host.

Verification (run locally)

python -m pytest nodes/test/tool_gmail/test_gmail.py  →  99 passed
ruff check   →  All checks passed
ruff format  →  already formatted

Scope

Covers the credential-hardening items of #1560 (token_uri, broker refresh, schemeless broker URL). The remaining items — 403 rate-limit retry, protobuf floor bump, and hoisting the shared client into nodes/core/ — are left as separate follow-up.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Strengthened Gmail OAuth handling by validating and normalizing OAuth broker URLs (including scheme-less values) and enforcing allowlisted Google token endpoints.
    • Token refresh now cleanly falls back to the standard Google token endpoint when none is provided, and rejects unsafe or untrusted token URLs.
    • Improved broker-based refresh reliability with clearer failure handling and safer expiry handling using broker-provided expiry when available.
  • Tests
    • Added new coverage for broker URL normalization, token URL fallback/acceptance, rejection scenarios, and broker refresh success plus multiple error paths.

…rocketride-org#1560)

tool_gmail trusted `token_uri` straight from the saved token payload
(`gmail_client.py`, `info.get('token_uri', ...)`). That value is where the
google-auth library POSTs the refresh_token to mint a new access token, so a
tampered token JSON could redirect the POST — carrying the refresh_token — to
an attacker-controlled host and exfiltrate the long-lived credential.

Add `resolve_token_uri()`, mirroring the existing `resolve_refresh_url()`
broker-host validation: accept a token-supplied endpoint only when it is https
and its host is one of Google's official token endpoints
(oauth2.googleapis.com, accounts.google.com), fall back to the canonical
endpoint when the field is absent, and raise ValueError otherwise so a tampered
token fails loud instead of silently posting credentials elsewhere. Apply it at
the point where token_uri is read.

Tests: cover the accepted Google endpoints, the absent-field fallback, and
rejection of scheme downgrade, attacker host, suffix-confusion lookalikes, and
non-string input. Full tool_gmail suite passes (91 tests).

Scope: this is the token_uri validation item from rocketride-org#1560. The broker-refresh
reliability fixes, 403 rate-limit retry, protobuf floor bump, and the hoist of
the shared client into nodes/core are separate items left as follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added the module:nodes Python pipeline nodes label Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor
🤖 Internal: Discord sync marker

Auto-managed by the Discord notification workflow. Stores the linked Discord message ID and forum thread ID. Do not edit or delete.

@coderabbitai

coderabbitai Bot commented Jul 13, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 177032e6-5a42-4ce0-96f3-4b89a7d981fd

📥 Commits

Reviewing files that changed from the base of the PR and between 1247dc8 and dc2d83b.

📒 Files selected for processing (2)
  • nodes/src/nodes/tool_gmail/gmail_client.py
  • nodes/test/tool_gmail/test_gmail.py

📝 Walkthrough

Walkthrough

Gmail OAuth token endpoints now require trusted HTTPS Google hosts, while broker refresh handling centralizes token exchange, expiry calculation, response validation, and RefreshError mapping with expanded tests.

Changes

Gmail OAuth credential handling

Layer / File(s) Summary
Token URI validation and credential integration
nodes/src/nodes/tool_gmail/gmail_client.py, nodes/test/tool_gmail/test_gmail.py
Normalizes schemeless broker URLs, validates Google token endpoints, applies canonical fallback behavior, and tests accepted and rejected inputs.
Broker refresh exchange and failure mapping
nodes/src/nodes/tool_gmail/gmail_client.py, nodes/test/tool_gmail/test_gmail.py, nodes/test/mocks/google/auth/*
Centralizes broker refresh requests, expiry parsing, response validation, and RefreshError conversion; broker credentials delegate to the helper and tests cover success and failure paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BrokerCredentials
  participant _refresh_access_token_via_broker
  participant OAuthBroker
  BrokerCredentials->>_refresh_access_token_via_broker: pass refresh token
  _refresh_access_token_via_broker->>OAuthBroker: POST refresh request
  OAuthBroker-->>_refresh_access_token_via_broker: JSON access_token and expiry_date
  _refresh_access_token_via_broker-->>BrokerCredentials: return token and expiry
Loading

Possibly related issues

  • Issue 1560 — Implements the requested token URI validation, schemeless broker URL support, and broker refresh error and expiry handling.

Suggested reviewers: jmaionchi, rod-christensen, stepmikhaylov

🚥 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 summarizes the main Gmail credential hardening work, including token_uri validation, broker refresh, and schemeless broker URL handling.
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 unit tests (beta)
  • Create PR with unit tests

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.

@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 `@nodes/src/nodes/tool_gmail/gmail_client.py`:
- Around line 114-117: Update the token_uri validation logic in the Gmail token
URI helper so only None or the documented empty-string value falls back to
_GOOGLE_TOKEN_URI; validate the type before applying any falsy fallback,
ensuring falsy non-string values such as 0, False, lists, and dictionaries raise
ValueError.
🪄 Autofix (Beta)

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

Run ID: 96a4bb51-d4d9-4671-ab26-ffd181d67b40

📥 Commits

Reviewing files that changed from the base of the PR and between cbe5d12 and 53bf081.

📒 Files selected for processing (2)
  • nodes/src/nodes/tool_gmail/gmail_client.py
  • nodes/test/tool_gmail/test_gmail.py

Comment thread nodes/src/nodes/tool_gmail/gmail_client.py Outdated
The broker refresh path in gmail_client.py had several reliability bugs called
out in rocketride-org#1560:

- HTTPError is a subclass of URLError, so a broker that was reached but
  returned an error status was misreported as "broker unreachable".
- A 200 response without access_token silently kept the stale token.
- A 200 without expiry_date left a stale past expiry, so google-auth issued a
  refresh POST on every subsequent API call (broker quota churn).
- A socket timeout during resp.read() raises OSError, not URLError, so it
  escaped the handler as a raw error.

Extract the network/parse logic into a module-level
`_refresh_access_token_via_broker()` helper (also makes it unit-testable) that:
catches HTTPError separately with an accurate message, treats URLError/OSError
(incl. read timeouts) as unreachable, reports malformed bodies as invalid,
raises when access_token is missing, and falls back to a default access-token
lifetime when the broker omits expiry_date.

Tests mock urlopen to cover success (broker + default expiry), missing
access_token, HTTP error, connection error, read timeout, and invalid JSON.
Full tool_gmail suite passes (98 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@meetp06 meetp06 changed the title fix(nodes): validate Gmail token_uri against Google's OAuth endpoints (#1560) fix(nodes): harden Gmail credential refresh — validate token_uri + fix broker refresh (#1560) Jul 13, 2026

@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 `@nodes/src/nodes/tool_gmail/gmail_client.py`:
- Around line 191-215: Validate that the parsed response in the Gmail token
refresh flow is a dictionary before calling data.get('access_token'). Treat any
other valid JSON shape, including null, numbers, and lists, as an invalid broker
response and raise the existing _gae.RefreshError with the same invalid-response
message used for parsing failures.
🪄 Autofix (Beta)

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

Run ID: 86829721-d54b-4071-bb6f-a07cc1819593

📥 Commits

Reviewing files that changed from the base of the PR and between 53bf081 and 1df8e7b.

📒 Files selected for processing (2)
  • nodes/src/nodes/tool_gmail/gmail_client.py
  • nodes/test/tool_gmail/test_gmail.py

Comment thread nodes/src/nodes/tool_gmail/gmail_client.py
…ocketride-org#1560)

A schemeless RR_OAUTH_BROKER_URL (e.g. "broker.example.com") parsed with
urlparse().hostname == None, so a self-hosted broker host was silently dropped
from the refresh allowlist and its own refresh URLs were rejected. Prepend a
scheme before parsing when one is missing so the host is registered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@meetp06 meetp06 changed the title fix(nodes): harden Gmail credential refresh — validate token_uri + fix broker refresh (#1560) fix(nodes): harden Gmail credential handling — token_uri, broker refresh, schemeless broker URL (#1560) Jul 14, 2026

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
nodes/src/nodes/tool_gmail/gmail_client.py (1)

214-224: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject malformed access_token and expiry_date values as RefreshError.

The current truthiness check accepts non-string access tokens, while non-numeric expiry_date values can raise raw TypeError/ValueError during timestamp conversion. Validate both fields and map malformed payloads to the existing invalid-response error; add regression tests for these shapes.

🐛 Proposed validation
+    import math
+
     access_token = data.get('access_token')
-    if not access_token:
+    if not isinstance(access_token, str) or not access_token:
         raise _gae.RefreshError(
             'Gmail token refresh returned no access token from the broker. Please reconnect your Google account.'
         )
 
     ms = data.get('expiry_date')
-    if ms:
+    if ms is not None:
+        if isinstance(ms, bool) or not isinstance(ms, (int, float)) or not math.isfinite(ms):
+            raise _gae.RefreshError(
+                'Gmail token refresh returned an invalid response from the broker. Please reconnect your Google account.'
+            )
         expiry = _dt.datetime.utcfromtimestamp(ms / 1000)
🤖 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 `@nodes/src/nodes/tool_gmail/gmail_client.py` around lines 214 - 224, Update
the broker response handling around access_token and expiry_date to validate
that access_token is a non-empty string and expiry_date, when present, is
numeric and convertible to a timestamp. Raise the existing _gae.RefreshError
with the invalid-response message for malformed values instead of allowing
TypeError or ValueError to escape, while preserving the default lifetime when
expiry_date is absent. Add regression tests covering non-string/empty access
tokens and invalid expiry_date shapes.
🤖 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.

Outside diff comments:
In `@nodes/src/nodes/tool_gmail/gmail_client.py`:
- Around line 214-224: Update the broker response handling around access_token
and expiry_date to validate that access_token is a non-empty string and
expiry_date, when present, is numeric and convertible to a timestamp. Raise the
existing _gae.RefreshError with the invalid-response message for malformed
values instead of allowing TypeError or ValueError to escape, while preserving
the default lifetime when expiry_date is absent. Add regression tests covering
non-string/empty access tokens and invalid expiry_date shapes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b381b0b7-e06f-4fb5-9433-7ffb5c7a88b3

📥 Commits

Reviewing files that changed from the base of the PR and between 1df8e7b and 19932ba.

📒 Files selected for processing (2)
  • nodes/src/nodes/tool_gmail/gmail_client.py
  • nodes/test/tool_gmail/test_gmail.py

meetp06 and others added 2 commits July 13, 2026 19:44
…he SDK

The broker-refresh tests imported google.auth.exceptions at module top level,
which fails at collection in the node test environment where the Google SDK is
not installed (ModuleNotFoundError: No module named 'google.auth') — breaking
the whole test_gmail module in CI.

Add a mock google.auth.exceptions (mirroring the existing google.oauth2 mocks)
and load it per-test via monkeypatch.syspath_prepend, matching the pattern the
build_service tests already use. The RefreshError type is obtained through a
fixture instead of a top-level import, so collection no longer depends on the
real SDK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address CodeRabbit review on rocketride-org#1569:
- resolve_token_uri: only None/'' fall back to the canonical endpoint; other
  falsy non-strings (0, False, [], {}) now raise instead of silently returning
  the default.
- broker refresh: reject a non-dict JSON body (list/number/null) that would
  otherwise crash data.get() with AttributeError; require access_token to be a
  non-empty string; and reject a non-numeric expiry_date instead of letting a
  raw TypeError escape. All map to the existing invalid-response RefreshError.

Added tests for each shape. Full tool_gmail suite passes (106 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@meetp06

meetp06 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @coderabbitai — addressed all three in the latest commit:

  • resolve_token_uri now only falls back for None/''; other falsy non-strings raise.
  • broker refresh rejects a non-dict JSON body (was an unhandled AttributeError), requires access_token to be a non-empty string, and rejects a non-numeric expiry_date — all mapped to the existing invalid-response RefreshError.
  • Added tests for each shape; full tool_gmail suite is at 106 passing.

Also fixed the CI break: the broker tests imported google.auth.exceptions at module top level, which fails collection in the node test env (no Google SDK). Added a google.auth.exceptions mock and load it per-test via syspath_prepend, matching the existing google.oauth2 mock pattern.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@meetp06 Thanks for the detailed follow-up — all three points sound well covered:

  • Narrowing resolve_token_uri's fallback to only None/'' (raising for other falsy non-strings) closes the ambiguity I flagged.
  • Rejecting non-dict JSON bodies, requiring access_token to be a non-empty string, and validating expiry_date is numeric — all funneled into the existing invalid-response RefreshError — is exactly the kind of defensive handling needed around an external broker response.
  • Good catch on the CI collection break; mocking google.auth.exceptions and loading it per-test via syspath_prepend (mirroring the existing google.oauth2 mock pattern) is the right fix to keep collection working without the Google SDK installed.

106 passing across the suite is a solid signal. Nice work tightening this up.

(=^・ω・^=) ゙


✏️ Learnings added
Learnt from: meetp06
URL: https://github.com/rocketride-org/rocketride-server/pull/1569

Timestamp: 2026-07-14T02:57:07.137Z
Learning: In rocketride-server's nodes/test/tool_gmail test suite, tests that need `google.auth.exceptions` (e.g., for broker refresh error handling in gmail_client.py) must not import it at module top level, since the node test environment lacks the Google SDK and this breaks test collection. Instead, add a mock at nodes/test/mocks/google/auth/{__init__.py,exceptions.py} providing GoogleAuthError and RefreshError, and load it per-test via `syspath_prepend`, matching the existing nodes/test/mocks/google/oauth2 mock pattern.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module:nodes Python pipeline nodes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant