Skip to content

Commit 9e9de1b

Browse files
rustyconoverclaude
andcommitted
Add origin allowlist for return_to redirects and early auth redirect (v0.6.9)
Restrict _vgi_return_to redirects to configured allowed origins (default: cupola.query-farm.services) and localhost. Add process_request hook to redirect already-authenticated users immediately without re-running the OAuth flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a0cf462 commit 9e9de1b

File tree

3 files changed

+68
-6
lines changed

3 files changed

+68
-6
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-rpc"
3-
version = "0.6.8"
3+
version = "0.6.9"
44
description = "Vector Gateway Interface - RPC framework based on Apache Arrow"
55
readme = "README.md"
66
requires-python = ">=3.13"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vgi_rpc/http/_oauth_pkce.py

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,16 +324,43 @@ def _validate_original_url(url: str, prefix: str) -> str:
324324
return url
325325

326326

327-
def _validate_return_to(url: str) -> str:
328-
"""Validate an external return-to URL. Returns empty string if invalid."""
327+
def _is_localhost(hostname: str) -> bool:
328+
"""Check if a hostname is localhost (any port)."""
329+
return hostname in ("localhost", "127.0.0.1", "[::1]")
330+
331+
332+
# Default origins allowed for _vgi_return_to redirects.
333+
_DEFAULT_ALLOWED_RETURN_ORIGINS: frozenset[str] = frozenset(("https://cupola.query-farm.services",))
334+
335+
336+
def _validate_return_to(url: str, allowed_origins: frozenset[str] = frozenset()) -> str:
337+
"""Validate an external return-to URL against an origin allowlist.
338+
339+
Returns the URL if it matches an allowed origin or is localhost,
340+
otherwise returns empty string. Only the scheme and host (ignoring
341+
port for localhost) are checked — any path is permitted.
342+
"""
329343
if not url or len(url) > 2048:
330344
return ""
331345
parsed = urlparse(url)
332346
if parsed.scheme not in ("http", "https"):
333347
return ""
334348
if not parsed.netloc:
335349
return ""
336-
return url
350+
# localhost with any port is always allowed
351+
hostname = parsed.hostname or ""
352+
if _is_localhost(hostname) and parsed.scheme == "http":
353+
return url
354+
# Check against allowlist (scheme + host, ignoring path)
355+
origin = f"{parsed.scheme}://{parsed.hostname}"
356+
if origin in allowed_origins:
357+
return url
358+
# Also try with explicit port
359+
if parsed.port:
360+
origin_with_port = f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"
361+
if origin_with_port in allowed_origins:
362+
return url
363+
return ""
337364

338365

339366
# ---------------------------------------------------------------------------
@@ -638,6 +665,7 @@ class _OAuthPkceMiddleware:
638665
"""
639666

640667
__slots__ = (
668+
"_allowed_return_origins",
641669
"_client_id",
642670
"_oidc_discovery",
643671
"_prefix",
@@ -656,6 +684,7 @@ def __init__(
656684
secure_cookie: bool,
657685
redirect_uri: str,
658686
scope: str = "openid email",
687+
allowed_return_origins: frozenset[str] | None = None,
659688
) -> None:
660689
self._session_key = session_key
661690
self._oidc_discovery = oidc_discovery
@@ -664,6 +693,39 @@ def __init__(
664693
self._secure_cookie = secure_cookie
665694
self._redirect_uri = redirect_uri
666695
self._scope = scope
696+
self._allowed_return_origins = (
697+
allowed_return_origins if allowed_return_origins is not None else _DEFAULT_ALLOWED_RETURN_ORIGINS
698+
)
699+
700+
def process_request(self, req: falcon.Request, resp: falcon.Response) -> None:
701+
"""If the user is already authenticated and has _vgi_return_to, redirect immediately.
702+
703+
This relies on _AuthMiddleware running first (Falcon processes
704+
process_request in middleware list order). If the cookie token is
705+
expired or invalid, _AuthMiddleware raises 401 *before* we get here,
706+
so we only redirect when the token is genuinely valid.
707+
"""
708+
if req.method != "GET":
709+
return
710+
return_to = _validate_return_to(req.get_param("_vgi_return_to") or "", self._allowed_return_origins)
711+
if not return_to:
712+
return
713+
# Check for existing auth token in cookie
714+
token = req.cookies.get(_AUTH_COOKIE_NAME)
715+
if not token:
716+
return # Not authenticated — let normal flow handle it
717+
# Already authenticated with a return_to — redirect back with the token
718+
separator = "#" if "#" not in return_to else "&"
719+
fragment_params = [f"token={token}"]
720+
redirect_url = f"{return_to}{separator}{'&'.join(fragment_params)}"
721+
logger.info("OAuth already authenticated, redirecting to external frontend: %s", return_to.split("?")[0])
722+
raise falcon.HTTPStatus(
723+
"302 Found",
724+
headers={
725+
"Location": redirect_url,
726+
"Cache-Control": "no-cache, no-store, must-revalidate",
727+
},
728+
)
667729

668730
def process_response(
669731
self,
@@ -711,7 +773,7 @@ def process_response(
711773
original_url = _validate_original_url(original_url, self._prefix)
712774

713775
# Check for external frontend return URL
714-
return_to = _validate_return_to(req.get_param("_vgi_return_to") or "")
776+
return_to = _validate_return_to(req.get_param("_vgi_return_to") or "", self._allowed_return_origins)
715777

716778
# Pack session cookie
717779
cookie_value = _pack_oauth_cookie(

0 commit comments

Comments
 (0)