diff --git a/gnexus_creds/mcp.py b/gnexus_creds/mcp.py index 45305ee..c06faa9 100644 --- a/gnexus_creds/mcp.py +++ b/gnexus_creds/mcp.py @@ -65,6 +65,14 @@ raise AppError("bad_request", "Invalid status value.", status_code=400) from exc +def _parse_int(value, name: str, maximum: int) -> int: + try: + parsed = int(value) + except (ValueError, TypeError) as exc: + raise AppError("bad_request", f"Invalid {name}.", status_code=400) from exc + return min(max(parsed, 0), maximum) # offset stays >= 0 without a 500 + + @router.get("/sse") async def mcp_sse(actor: Actor = Depends(actor_from_request)) -> StreamingResponse: _mcp_actor(actor) @@ -100,8 +108,8 @@ category=args.get("category"), status=_parse_status(status_raw) if status_raw and str(status_raw).strip() else None, include_archived=False, - offset=int(args.get("offset", 0)), - limit=int(args.get("limit", 20)), + offset=_parse_int(args.get("offset", 0), "offset", 100000), + limit=_parse_int(args.get("limit", 20), "limit", 200), mcp=True, ) return {"items": [item.model_dump(mode="json") for item in items], "total": total} diff --git a/gnexus_creds/oauth.py b/gnexus_creds/oauth.py index f47b0cb..51c0c58 100644 --- a/gnexus_creds/oauth.py +++ b/gnexus_creds/oauth.py @@ -37,6 +37,14 @@ ) +def _purge_expired_states(db: Session) -> None: + # states expire in 10 minutes but nothing ever removed the leftovers — + # the table would just grow; drop the dead ones opportunistically + db.query(OAuthState).filter( + OAuthState.expires_at < datetime.now(UTC) + ).delete(synchronize_session=False) + + def _validate_return_to(value: str) -> str: if not value.startswith("/") or value.startswith("//") or "\\" in value: return "/" @@ -54,6 +62,7 @@ check_rate_limit( db, key=f"oauth_login:{_client_ip(request)}", max_requests=20, window_seconds=60 ) + _purge_expired_states(db) return_to = _validate_return_to(return_to) config = _config() state = PkceGenerator.generate_state() @@ -85,13 +94,25 @@ check_rate_limit( db, key=f"oauth_callback:{_client_ip(request)}", max_requests=20, window_seconds=60 ) + _purge_expired_states(db) settings = get_settings() saved = db.get(OAuthState, state) if saved is None or is_expired(saved.expires_at, now=datetime.now(UTC)): raise AppError("invalid_oauth_state", "Invalid or expired OAuth state.", status_code=400) config = _config() - token_set = HttpTokenEndpoint(config).exchange_authorization_code(code, saved.pkce_verifier) - auth_user = HttpRuntimeUserProvider(config).fetch_user(token_set.access_token) + try: + token_set = HttpTokenEndpoint(config).exchange_authorization_code( + code, saved.pkce_verifier + ) + auth_user = HttpRuntimeUserProvider(config).fetch_user(token_set.access_token) + except WebhookPayloadException: + raise + except Exception as exc: # exchange/fetch failures must not leak a traceback + raise AppError( + "auth_server_error", + "Authorization server is unreachable or rejected the login.", + status_code=502, + ) from exc profile = auth_user.profile or {} user = upsert_user_from_auth( db, @@ -144,7 +165,9 @@ @webhook_router.post("/gnexus-auth/") async def gnexus_auth_webhook(request: Request, db: Session = Depends(get_db)) -> dict[str, str]: settings = get_settings() - raw = (await request.body()).decode() + raw = (await request.body()).decode("utf-8", errors="replace") + # invalid utf-8 never reaches a 500: replaced bytes just fail the + # signature check below (401) headers = dict(request.headers) config = _config() try: diff --git a/tests/test_auth.py b/tests/test_auth.py index d06e02b..6b290d1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -317,3 +317,44 @@ assert cookie_only.status_code == 200 # and the session row survived — the token failure did not touch it assert db_session.get(SessionRecord, "alive-web-session") is not None + +@pytest.mark.anyio +async def test_expired_oauth_states_are_purged_on_login(auth_app, db_session): + from gnexus_creds.models import OAuthState + from datetime import UTC, datetime, timedelta + + db_session.add( + OAuthState( + state="stale-state", + pkce_verifier="v", + scopes=[], + expires_at=datetime.now(UTC) - timedelta(minutes=1), + ) + ) + db_session.commit() + + async with AsyncClient(transport=ASGITransport(app=auth_app), base_url="http://test") as client: + # the login route redirects to the auth server without a live network call + response = await client.get("/auth/login") + assert response.status_code in (302, 307, 502) # redirect or prod-guard failure + + assert db_session.get(OAuthState, "stale-state") is None + + +@pytest.mark.anyio +async def test_mcp_legacy_garbage_offset_is_400(app, actor): + from gnexus_creds.models import ApiToken + + actor.channel = "mcp" + actor.api_token = ApiToken( + user_id=actor.user.id, + public_id="m", + name="m", + token_hash="hash", + scopes=["mcp", "read"], + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/mcp/tools/search_secrets", json={"arguments": {"offset": "abc"}} + ) + assert response.status_code == 400