diff --git a/app/auth.py b/app/auth.py index de0b1af7..eba8effb 100644 --- a/app/auth.py +++ b/app/auth.py @@ -12,6 +12,7 @@ from app.config import Settings from app.control_chars import contains_control_character +from app.query_validation import reject_control_char_query_param, reject_repeated_query_param def oauth_configured(settings: Settings) -> bool: @@ -109,7 +110,11 @@ def register_auth_routes(app: FastAPI, *, settings: Settings) -> AuthService: auth = AuthService(settings) @app.get("/auth/github/login") - def auth_github_login(next_path: str | None = Query(None, alias="next")) -> RedirectResponse: + 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") safe_next = safe_next_path(next_path) @@ -133,6 +138,9 @@ def auth_github_login(next_path: str | None = Query(None, alias="next")) -> Redi @app.get("/auth/github/callback") 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") cookie_state = request.cookies.get("mrwk_oauth_state") diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index f5dbcfc1..dd149a3b 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -656,6 +656,47 @@ def test_github_login_stores_safe_default_for_backslash_next(sqlite_url: str, mo assert next_path == "/me" +def test_github_login_rejects_repeated_next(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/login?next=/me&next=/admin", follow_redirects=False) + + assert response.status_code == 400 + assert response.json()["detail"] == "next 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&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_oauth_callback_route_is_registered(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", follow_redirects=False) + + assert response.status_code == 422 + + def test_wallet_pages_expose_transfer_and_github_claim_flows(sqlite_url: str) -> None: create_schema(sqlite_url) _, public_hex, address = _keypair()