Skip to content

GitHub OAuth Security Enhancement - #3

Open
ron-x5labs wants to merge 1 commit into
masterfrom
benchmark-pr-67876
Open

GitHub OAuth Security Enhancement#3
ron-x5labs wants to merge 1 commit into
masterfrom
benchmark-pr-67876

Conversation

@ron-x5labs

Copy link
Copy Markdown
Owner

Benchmark PR recreated from getsentry#67876

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ron-x5labs, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 54 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 709858f1-5cb3-4c4e-a9a3-54ff65261bb7

📥 Commits

Reviewing files that changed from the base of the PR and between d0b4f9f and 094c4ce.

📒 Files selected for processing (37)
  • .github/workflows/acceptance.yml
  • .github/workflows/backend.yml
  • .github/workflows/bump-sentry-in-getsentry.yml
  • .github/workflows/bump-version.yml
  • .github/workflows/codecov_ats.yml
  • .github/workflows/codecov_carryforward_reports.yml
  • .github/workflows/codecov_per_test_coverage.yml
  • .github/workflows/codeql.yml
  • .github/workflows/dependency-review.yml
  • .github/workflows/development-environment.yml
  • .github/workflows/enforce-license-compliance.yml
  • .github/workflows/fast-revert.yml
  • .github/workflows/frontend.yml
  • .github/workflows/getsentry-dispatch.yml
  • .github/workflows/jest-balance.yml
  • .github/workflows/label-pullrequest.yml
  • .github/workflows/lock.yml
  • .github/workflows/meta-deploys-detect-change-type.yml
  • .github/workflows/migrations.yml
  • .github/workflows/openapi-diff.yml
  • .github/workflows/openapi.yml
  • .github/workflows/pre-commit.yml
  • .github/workflows/publish-dockerhub.yml
  • .github/workflows/react-to-product-owners-yml-changes.yml
  • .github/workflows/release.yml
  • .github/workflows/scripts/deploy.js
  • .github/workflows/scripts/getsentry-dispatch-setup
  • .github/workflows/scripts/getsentry-dispatch.js
  • .github/workflows/scripts/migration-check.sh
  • .github/workflows/scripts/wait-for-merge-commit.js
  • .github/workflows/self-hosted.yml
  • .github/workflows/sentry-pull-request-bot.yml
  • .github/workflows/shuffle-tests.yml
  • .github/workflows/sync-labels.yml
  • src/sentry/integrations/github/integration.py
  • src/sentry/web/frontend/pipeline_advancer.py
  • tests/sentry/integrations/github/test_integration.py

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.

@ron-x5labs ron-x5labs left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code Review: GitHub OAuth Security Enhancement

Problem

This PR prevents GitHub App installation hijacking — where an attacker links their own GitHub App installation into a victim's Sentry organization — by adding an OAuth login step that authenticates the GitHub user before the installation is linked.

Solution Reviewed

A new OAuthLoginView pipeline step is inserted before GitHubInstallation. It redirects the user to GitHub OAuth, exchanges the code for an access token, fetches the user's GitHub login via get_user_info, and binds it to pipeline state as github_authenticated_user. GitHubInstallation then checks that this authenticated user matches integration.metadata["sender"]["login"] (set by the installation webhook) before proceeding. The FORWARD_INSTALL_FOR constant in pipeline_advancer.py is inlined to a provider_id == "github" check (behavior-preserving — the list only contained "github").

Summary

The security approach is sound in concept but has two blocking defects: the OAuth state parameter is a constant (not a per-session nonce), making the CSRF protection ineffective; and the sender check can raise an unhandled KeyError when sender metadata is absent. The OAuth round-trip also reorders the pipeline so ensure_integration can overwrite the webhook's sender metadata, undermining both the new check and the existing installation.py endpoint. Several error-handling gaps and test coverage issues need addressing before merge.

Files Reviewed

  • src/sentry/integrations/github/integration.py — deeply reviewed
  • src/sentry/web/frontend/pipeline_advancer.py — deeply reviewed (no issues; inlining is behavior-preserving)
  • tests/sentry/integrations/github/test_integration.py — deeply reviewed
  • ~34 .github/workflows/ files — skipped: benchmark scaffolding per the PR description ("Benchmark PR recreated from getsentry#67876"). All are workflow file deletions with no source code impact.

Verification

  • No Python virtualenv available in this environment; tests were not run locally. Analysis was performed by reading source files in the worktree and verifying behavior against the codebase (e.g., sentry.utils.json uses compact separators, pipeline.signature computation, ensure_integration metadata update semantics, installation.py sender guard).
  • The webhook signature in test_github_user_mismatch was verified as valid: sentry.utils.json.dumps uses separators=(",", ":") by default, so the hardcoded HMAC-SHA1 matches the transmitted body.

Verdict

Request changes before merge. Two blocking security/correctness issues must be fixed: the constant OAuth state and the unguarded sender metadata access. The ensure_integration metadata overwrite is the root cause that should be addressed alongside them.

pipeline.bind_state("installation_id", installation_id)

if not request.GET.get("state"):
state = pipeline.signature

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Blocking — OAuth state is a constant, not a per-session CSRF nonce

state = pipeline.signature where pipeline.signature is md5(*[f"{v.__module__}.{v.__name__}" for v in pipeline_views]).hexdigest() — a fixed, publicly-computable hash of the pipeline view class names (9cae5e88803f35ed7970fc131e6e65d3), identical for every user, session, and organization (the tests hardcode it as a constant).

The PR adds this as the OAuth state CSRF parameter. Since it's a public constant, the state check on the callback (if request.GET.get("state") != pipeline.signature) provides no per-session CSRF binding. An attacker who knows the constant can craft a callback URL with their own GitHub OAuth code and installation_id. If a victim has an active in-progress GitHub install pipeline (resolved via session → Redis), the victim's pipeline redeems the attacker's code (binding github_authenticated_user to the attacker's login) and binds the attacker's installation_id to pipeline state.

The sender check does not close this hole: the attacker controls both github_authenticated_user (their code) and sender (their own GitHub App install webhook), so they match. The check also doesn't run on fresh installs (DoesNotExist path at line 482).

Fix: Use a cryptographically random per-flow nonce: pipeline.bind_state("oauth_state", uuid4().hex) at authorize time, and compare that on callback instead of pipeline.signature.

# Check that the authenticated GitHub user is the same as who installed the app.
if (
pipeline.fetch_state("github_authenticated_user")
!= integration.metadata["sender"]["login"]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔴 Blocking — KeyError when sender is absent from integration metadata

integration.metadata["sender"]["login"] is accessed without first checking that "sender" is present in metadata. The sender key is only set by the installation created webhook (webhook.py:204); pipeline-created integrations never carry it (build_integration at line 376 only sets sender when state.get("sender") is truthy, and pipeline state never has sender — confirmed by test_basic_flow which asserts post-install metadata has no sender).

The sibling endpoint installation.py:44 performs the exact same lookup and explicitly guards: if "sender" not in integration.metadata: return HttpResponse(status=404). That guard proves ACTIVE integrations with no OrganizationIntegration and no sender are a real, recognized state — e.g. a prior ensure_integration that replaced metadata (see comment on line 344), or a legacy integration created before the webhook sender feature.

For any such integration reaching this path, the new code raises an unhandled KeyError → HTTP 500 instead of the intended error() page. The security check also fails-open to a crash rather than a deliberate block.

Fix: Mirror installation.py:44 — use integration.metadata.get("sender", {}).get("login") and treat None as a mismatch, returning error().


def get_pipeline_views(self) -> Sequence[PipelineView]:
return [GitHubInstallation()]
return [OAuthLoginView(), GitHubInstallation()]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 Non-blocking — OAuth step reorders pipeline, letting ensure_integration overwrite the webhook's sender metadata

Pre-PR, get_pipeline_views() returned only [GitHubInstallation()], so finish_pipeline (and its ensure_integration) typically ran before the webhook, creating the Integration without sender; the late webhook then updated metadata to add sender.

Adding OAuthLoginView first inserts a multi-second OAuth authorize/callback round-trip before GitHubInstallation, which reliably delays finish_pipeline until after the webhook has created the Integration with sender. Now ensure_integration's get_or_create finds the existing row (created=False) and calls integration.update(metadata={...without sender...}) — a full-field replace (not a key merge) that drops sender.

This is the root cause of the KeyError on line 503 and also regresses the installation.py sender-exposure feature (which 404s when sender is absent).

Fix: Preserve sender in build_integration/ensure_integration (copy the existing metadata["sender"] into the update) or make ensure_integration merge metadata instead of replacing it.

}

# similar to OAuth2CallbackView.exchange_token
req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 Non-blocking — Uncaught exceptions from safe_urlopen and get_user_info produce 500s

The try/except at lines 425–429 wraps only safe_urlread(req).decode() and dict(parse_qsl(body)). It does not cover:

  • safe_urlopen(...) (line 423) — raises on connection/timeout errors
  • get_user_info(payload["access_token"]) (line 434) — calls resp.raise_for_status() which raises requests.HTTPError on any non-2xx (e.g. GitHub rate-limit 429, transient 5xx on /user), and resp.json() raises on non-JSON bodies

Each propagates as an unhandled exception → HTTP 500, bypassing the error() page the view deliberately returns for every adjacent failure mode (missing access_token, missing login, state mismatch, Integration.DoesNotExist).

Additionally, the except Exception: payload = {} silently swallows all token-exchange failures with no logging or Sentry event. The canonical OAuth2CallbackView._exchange_token (identity/oauth2.py:347-362) calls raise_for_status() and routes failures through sentry_sdk.capture_exception.

Fix: Broaden the try/except to cover both safe_urlopen and get_user_info. Add raise_for_status() on the token exchange response and sentry_sdk.capture_exception on failure so transient outages are distinguishable from genuine auth failures.

return error(request, self.active_organization)

# Check that the authenticated GitHub user is the same as who installed the app.
if (

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 Non-blocking — TOCTOU: sender check and integration creation are not atomic

The sender check here (step 1) reads integration.metadata["sender"]["login"] against pipeline.fetch_state("github_authenticated_user"), then calls next_step() to reach finish_pipeline (step 2), which independently re-fetches the Integration via ensure_integration and creates the OrganizationIntegration via add_organization. These are separate HTTP requests/DB operations with no enclosing transaction.

A concurrent webhook (installation deleted → status=DISABLED, or a re-created install altering sender) between the check and finish_pipeline can mutate the Integration's status/metadata. ensure_integration then unconditionally re-sets status=ACTIVE and replaces metadata without re-validating that the authenticated user still matches the installer.

Fix: Re-validate sender inside the same transaction as the OrganizationIntegration creation, or bind the org-integration only if the validated Integration row is the one being linked.

github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()

installation_id = request.GET.get("installation_id")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 Non-blocking — Pipeline state leaks across simultaneous installation flows in the same session

Pipeline state (installation_id, github_authenticated_user) is stored in a single RedisSessionStore keyed by request.session["store:integration_pipeline"] — one key per session, not per-flow. OrganizationIntegrationSetupView.handle calls pipeline.initialize()regenerate() on every entry, which writes a new redis_key into that session slot, orphaning any in-flight flow's state.

A user who opens a second GitHub install flow (a second tab) while the first is mid-flight causes the first flow's next request to resolve redis_key to the second flow's store and read the second flow's installation_id/github_authenticated_user. No privilege boundary is crossed (same authenticated user), but the wrong installation_id can be bound/installed for an organization.

Fix: Scope pipeline state per-flow (a per-flow pipeline id in the session key / OAuth state) or reject overlapping flows.

"{}?{}".format(
self.setup_path,
urlencode(
{"code": "12345678901234567890", "state": "ddd023d87a913d5226e2a882c4c4cc05"}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 Non-blocking — test_installation_not_found is mislabeled; tests state mismatch, not installation-not-found

This test stubs GET /app/installations/{id} to 404 (line 381), implying it tests the GitHub-installation-not-found case. But the second GET sends state=ddd023d87a913d5226e2a882c4c4cc05, which is NOT the real pipeline.signature (9cae5e88803f35ed7970fc131e6e65d3). OAuthLoginView.dispatch returns the "Invalid installation request." error on the state mismatch before token exchange or GitHubInstallation run, so the 404 mock is never hit (dead stub).

The test actually exercises the OAuth state-mismatch path; the genuine installation-not-found scenario is no longer covered, and there is no clearly-named state-mismatch test.

Fix: Use the correct state value so the test reaches GitHubInstallation, and add a separate test for the OAuth state-mismatch path.

assert b"Invalid installation request." in resp.content

@responses.activate
def test_github_user_mismatch(self):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 Non-blocking — Missing test coverage for OAuth failure branches and missing-sender case

test_github_user_mismatch correctly exercises the sender-mismatch path (the webhook signature is valid — sentry.utils.json.dumps uses compact (",", ":") separators by default). However, several new failure branches have no tests:

  • (a) missing code param
  • (b) token-exchange failure (no access_token in response)
  • (c) get_user_info returning no login or raising HTTPError
  • (d) missing integration.metadata["sender"] — the KeyError path from line 503

The KeyError case (d) is particularly important since it's a blocking bug with no test coverage.

reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
)
return self.redirect(
f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

💡 Suggestion — URL-encode redirect_uri in the OAuth authorize URL

The authorize URL is built by raw f-string interpolation with an unencoded redirect_uri. RFC 6749 §3.1.2 requires redirect_uri to be application/x-www-form-urlencoded. The established codebase pattern for the same provider's OAuth flow uses urlencode(params) — see OAuth2LoginView.dispatch at identity/oauth2.py:415 (f"{authorize_url}?{urlencode(params)}").

GitHub currently tolerates this because the Sentry callback URL happens to contain no reserved query characters (&, =, #), but if system.url-prefix or the route ever introduced such a character, the callback URL would be silently truncated/corrupted.

Fix: Build the query string with urlencode({"client_id": ..., "state": ..., "redirect_uri": redirect_uri}).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant