Skip to content

fix(auth): reject repeated OAuth query params (#820)#1184

Open
yanyishuai wants to merge 1 commit into
ramimbo:mainfrom
yanyishuai:fix/issue-820-oauth-query-validation
Open

fix(auth): reject repeated OAuth query params (#820)#1184
yanyishuai wants to merge 1 commit into
ramimbo:mainfrom
yanyishuai:fix/issue-820-oauth-query-validation

Conversation

@yanyishuai

@yanyishuai yanyishuai commented Jun 30, 2026

Copy link
Copy Markdown

Summary

Harden GitHub OAuth login and callback routes against repeated scalar query
parameters (next, code, state) using the existing
reject_repeated_query_param and reject_control_char_query_param helpers.
Closes #820.

Changes

  • app/auth.py
    • /auth/github/login: validate next before redirect.
    • /auth/github/callback: validate code and state before token exchange.
  • tests/test_wallet_api.py
    • Reject repeated next on login (400).
    • Reject repeated code on callback (400).
    • Regression: /auth/github/callback is registered (422 without params, not 404).

Why

FastAPI silently accepts the last value when a scalar query param is repeated.
For OAuth flows that is ambiguous and can mask proxy/cache bugs. Matching the
wallet API query validation pattern keeps behavior consistent and fail-fast.

Test plan

python -m pytest tests/test_wallet_api.py -k github

Wallet

Do4v7foHJvRJLpRRoGaVPWX6DDEjX3yTK7J91gpwUQpE

Closes #820

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation on GitHub sign-in and callback requests to reject repeated query parameters and suspicious control characters.
    • Added safeguards so malformed OAuth request data is blocked before login or token exchange continues.
  • Tests

    • Added coverage for duplicate query-parameter handling on GitHub login and callback flows.
    • Verified the GitHub callback route is registered and responds as expected.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

app/auth.py imports two query-parameter rejection helpers and calls them on next in the GitHub login route and on code and state in the callback route before any OAuth state or token logic. Three tests are added covering repeated next, repeated code, and callback route registration.

OAuth Query Parameter Validation

Layer / File(s) Summary
Login and callback validation
app/auth.py
Imports reject_repeated_query_param and reject_control_char_query_param; applies both to next on /auth/github/login and to code and state on /auth/github/callback before any OAuth state checks or token exchange.
Tests
tests/test_wallet_api.py
Adds test_github_login_rejects_repeated_next (400), test_github_callback_rejects_repeated_state (400 on repeated code), and test_github_oauth_callback_route_is_registered (422 without params).

Possibly related PRs

  • ramimbo/mergework#400: Establishes the GitHub OAuth login/callback flow in app/auth.py that this PR extends with repeated-parameter and control-character rejection.
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Core validation looks aligned, but the requested repeated-state and valid-login-redirect tests from #820 aren't evidenced. Add or verify tests for repeated state and a valid single-next login redirect, and confirm the callback test covers repeated code.
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Short, concrete, and accurately names the auth-side OAuth query-parameter hardening.
Description check ✅ Passed Mostly complete: it summarizes the change, rationale, tests, and linked issue, though it doesn't follow the repo template exactly.
Out of Scope Changes check ✅ Passed Only GitHub OAuth auth routes and related tests changed, which stays within the stated scope.
Mergework Public Artifact Hygiene ✅ Passed PR description and touched files only add OAuth validation/tests; no investment, price, cash-out, payout, or private security claims appear.
Bounty Pr Focus ✅ Passed PASS: The PR is tightly scoped to GitHub OAuth query validation in app/auth.py plus targeted tests in tests/test_wallet_api.py, matching refs #820 with no unrelated surfaces.

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.

Warning

⚠️ This pull request has been flagged as potential spam (promotional) by CodeRabbit slop detection and should be reviewed carefully.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 92a31c7c-da72-43ff-935d-32b487b77d62

📥 Commits

Reviewing files that changed from the base of the PR and between 3bc87d2 and 4f75bef.

📒 Files selected for processing (2)
  • app/auth.py
  • tests/test_wallet_api.py

Comment thread app/auth.py
Comment on lines +116 to 119
reject_repeated_query_param(request, "next")
reject_control_char_query_param(request, "next")
if not oauth_configured(settings):
raise HTTPException(status_code=503, detail="GitHub OAuth is not configured")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the existing 503 behavior when OAuth is disabled.

These new guards run before oauth_configured(settings), so repeated/control-character requests now fail with 400 instead of the previous 503 when GitHub OAuth is not configured. That breaks the unconfigured-path contract called out in the PR objectives.

Proposed fix
     def auth_github_login(
         request: Request, next_path: str | None = Query(None, alias="next")
     ) -> RedirectResponse:
-        reject_repeated_query_param(request, "next")
-        reject_control_char_query_param(request, "next")
         if not oauth_configured(settings):
             raise HTTPException(status_code=503, detail="GitHub OAuth is not configured")
+        reject_repeated_query_param(request, "next")
+        reject_control_char_query_param(request, "next")
         safe_next = safe_next_path(next_path)
     async def auth_github_callback(request: Request, code: str, state: str) -> RedirectResponse:
-        for name in ("code", "state"):
-            reject_repeated_query_param(request, name)
-            reject_control_char_query_param(request, name)
         if not oauth_configured(settings):
             raise HTTPException(status_code=503, detail="GitHub OAuth is not configured")
+        for name in ("code", "state"):
+            reject_repeated_query_param(request, name)
+            reject_control_char_query_param(request, name)
         cookie_state = request.cookies.get("mrwk_oauth_state")

Also applies to: 141-145

Comment thread tests/test_wallet_api.py
Comment on lines +672 to +685
def test_github_callback_rejects_repeated_state(sqlite_url: str, monkeypatch) -> None:
monkeypatch.setenv("MERGEWORK_GITHUB_OAUTH_CLIENT_ID", "client-id")
monkeypatch.setenv("MERGEWORK_GITHUB_OAUTH_CLIENT_SECRET", "client-secret")
monkeypatch.setenv("MERGEWORK_COOKIE_SECRET", "test-cookie-secret")
monkeypatch.setenv("MERGEWORK_PUBLIC_BASE_URL", "https://mrwk.example.test")
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))

response = client.get(
"/auth/github/callback?code=abc&code=def&state=xyz",
follow_redirects=False,
)

assert response.status_code == 400
assert response.json()["detail"] == "code must be provided at most once"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

This test never exercises repeated state.

The function name says state, but the request repeats code, so the new state rejection path in app/auth.py is still unproven.

Proposed fix
 def test_github_callback_rejects_repeated_state(sqlite_url: str, monkeypatch) -> None:
@@
     response = client.get(
-        "/auth/github/callback?code=abc&code=def&state=xyz",
+        "/auth/github/callback?code=abc&state=xyz&state=uvw",
         follow_redirects=False,
     )
@@
-    assert response.json()["detail"] == "code must be provided at most once"
+    assert response.json()["detail"] == "state must be provided at most once"

As per coding guidelines, "Add or update tests for changed behavior"; as per path instructions, "Focus on whether tests prove the changed behavior and include negative, replay, boundary, or regression cases where relevant."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_github_callback_rejects_repeated_state(sqlite_url: str, monkeypatch) -> None:
monkeypatch.setenv("MERGEWORK_GITHUB_OAUTH_CLIENT_ID", "client-id")
monkeypatch.setenv("MERGEWORK_GITHUB_OAUTH_CLIENT_SECRET", "client-secret")
monkeypatch.setenv("MERGEWORK_COOKIE_SECRET", "test-cookie-secret")
monkeypatch.setenv("MERGEWORK_PUBLIC_BASE_URL", "https://mrwk.example.test")
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))
response = client.get(
"/auth/github/callback?code=abc&code=def&state=xyz",
follow_redirects=False,
)
assert response.status_code == 400
assert response.json()["detail"] == "code must be provided at most once"
def test_github_callback_rejects_repeated_state(sqlite_url: str, monkeypatch) -> None:
monkeypatch.setenv("MERGEWORK_GITHUB_OAUTH_CLIENT_ID", "client-id")
monkeypatch.setenv("MERGEWORK_GITHUB_OAUTH_CLIENT_SECRET", "client-secret")
monkeypatch.setenv("MERGEWORK_COOKIE_SECRET", "test-cookie-secret")
monkeypatch.setenv("MERGEWORK_PUBLIC_BASE_URL", "https://mrwk.example.test")
client = TestClient(create_app(database_url=sqlite_url, webhook_secret="secret"))
response = client.get(
"/auth/github/callback?code=abc&state=xyz&state=uvw",
follow_redirects=False,
)
assert response.status_code == 400
assert response.json()["detail"] == "state must be provided at most once"

Sources: Coding guidelines, Path instructions

@qingfeng312 qingfeng312 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed current head 4f75bef78527c909fd6ceb0e2c3bfb832cf4633a. The auth route changes apply the existing raw-query validators before OAuth redirect or callback processing, so repeated next, code, and state values are rejected before FastAPI scalar coercion or token exchange can silently choose one value. The single-value safety path is preserved through safe_next_path, the unconfigured-OAuth behavior remains behind the existing 503 guard, and the added wallet/auth tests cover duplicate next, duplicate callback query handling, and callback route registration. Hosted quality checks are passing. I did not find a blocking issue.

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.

Proposed work: validate OAuth scalar query parameters

2 participants