Skip to content

Commit 58ddac9

Browse files
rustyconoverclaude
andcommitted
release: 0.22.0 — proxy-proof resolver, vgi-rpc floor to 0.28.2
Adds VGI_PROXY_PROOF_* handling to the worker's env-driven auth resolution, so a worker can refuse any request that did not arrive through a trusted proxy. Reads MODE / ORIGIN_ID / SECRETS / SKEW and builds the gate from vgi-rpc. The gate is composed with require_all(), not chained. Chaining is first-success-wins, so a gate placed in a chain would be satisfied by whichever credential came next — precisely the bypass it exists to close. `inner` may be None: proof alone means "only my proxy may call this worker", with user identity handled upstream. Restructures _resolve_authenticate()'s hardcoded two-way combiner into a list so a third scheme is one entry rather than another branch, keeping JWT ahead of bearer (bearer does a constant-time scan over its token set, so the cheaper check goes last). Malformed configuration aborts startup rather than degrading to off. The secret is shared with an independently-deployed proxy, so a typo would otherwise silently derive a different key on each side and turn require mode into a total rejection outage with nothing pointing at the cause. Floor moved to vgi-rpc>=0.28.2, where the proxy-proof API lives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6cdac1c commit 58ddac9

3 files changed

Lines changed: 87 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "vgi-python"
3-
version = "0.21.0"
3+
version = "0.22.0"
44
description = "Vector Gateway Interface - Connect DuckDB to external programs via Apache Arrow"
55
readme = "README.md"
66
keywords = [
@@ -40,7 +40,7 @@ dependencies = [
4040
"pyarrow",
4141
"typer>=0.9",
4242
"platformdirs",
43-
"vgi-rpc>=0.26.0",
43+
"vgi-rpc>=0.28.2",
4444
"httpx>=0.24",
4545
]
4646

uv.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vgi/serve.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import sys
3333
from collections.abc import Callable
3434
from types import ModuleType
35-
from typing import TYPE_CHECKING, Any
35+
from typing import TYPE_CHECKING, Any, Literal
3636

3737
from vgi.logging_config import LogFormat, LogLevel
3838

@@ -429,6 +429,10 @@ def _resolve_authenticate() -> Callable[..., Any] | None:
429429
- ``VGI_JWT_ISSUER`` + ``VGI_JWT_AUDIENCE``: JWT/JWKS auth
430430
(requires ``vgi[oauth]`` extra). Optional ``VGI_JWT_JWKS_URI``.
431431
- When both bearer and JWT are set, they are chained (JWT first).
432+
- ``VGI_PROXY_PROOF_MODE``: ``allow`` or ``require`` gates every request
433+
on proof that it arrived through a trusted proxy. This is a
434+
precondition ANDed with whichever credential above is configured, not
435+
an alternative to one.
432436
433437
Returns:
434438
An authenticate callback, or None if no auth env vars are set.
@@ -438,14 +442,84 @@ def _resolve_authenticate() -> Callable[..., Any] | None:
438442
JWT issuer without audience).
439443
440444
"""
441-
bearer_auth = _resolve_bearer_authenticate()
442-
jwt_auth = _resolve_jwt_authenticate()
445+
# Ordered by cost: JWT resolves a signature, bearer does a constant-time
446+
# scan over the token set, so the cheaper one goes last.
447+
candidates = [fn for fn in (_resolve_jwt_authenticate(), _resolve_bearer_authenticate()) if fn is not None]
443448

444-
if bearer_auth is not None and jwt_auth is not None:
449+
inner: Callable[..., Any] | None
450+
if len(candidates) > 1:
445451
from vgi_rpc.http import chain_authenticate
446452

447-
return chain_authenticate(jwt_auth, bearer_auth)
448-
return jwt_auth or bearer_auth
453+
inner = chain_authenticate(*candidates)
454+
else:
455+
inner = candidates[0] if candidates else None
456+
457+
gate = _resolve_proxy_proof_gate()
458+
if gate is None:
459+
return inner
460+
461+
# AND, not OR: chaining the gate would let any later credential satisfy
462+
# the request on its own, which is exactly the bypass the gate exists to
463+
# close. `inner` may be None — proof alone means "only my proxy may call
464+
# this worker", with user identity handled upstream.
465+
from vgi_rpc.http import require_all
466+
467+
return require_all(gate, inner)
468+
469+
470+
def _resolve_proxy_proof_gate() -> Any | None:
471+
"""Build a proxy-proof gate from ``VGI_PROXY_PROOF_*`` environment variables.
472+
473+
Env vars:
474+
475+
- ``VGI_PROXY_PROOF_MODE``: ``off`` (default), ``allow`` or ``require``.
476+
- ``VGI_PROXY_PROOF_ORIGIN_ID``: this worker's identifier. Folded into
477+
every MAC but never transmitted, so it must match what the proxy is
478+
configured to prove to.
479+
- ``VGI_PROXY_PROOF_SECRETS``: ``kid:hex`` pairs, comma-separated. The
480+
``kid`` doubles as the proxy's label in the audit trail.
481+
- ``VGI_PROXY_PROOF_SKEW``: acceptance half-window in seconds (default 30).
482+
483+
Returns:
484+
A gate for ``require_all``, or None when the feature is off.
485+
486+
Raises:
487+
SystemExit: On any malformed value. Deliberately fail-closed: the
488+
secret is shared with an independently-deployed proxy, so a typo
489+
would otherwise silently reject every request with no diagnostic.
490+
491+
"""
492+
raw_mode = (os.environ.get("VGI_PROXY_PROOF_MODE") or "off").strip().lower()
493+
if raw_mode == "off":
494+
return None
495+
if raw_mode not in ("allow", "require"):
496+
sys.stderr.write(
497+
f"Error: VGI_PROXY_PROOF_MODE must be 'off', 'allow' or 'require', got {raw_mode!r}\n",
498+
)
499+
sys.exit(1)
500+
mode: Literal["allow", "require"] = "allow" if raw_mode == "allow" else "require"
501+
502+
from vgi_rpc.http import ProxyProofConfig, parse_secrets, proxy_proof_gate
503+
504+
raw_secrets = os.environ.get("VGI_PROXY_PROOF_SECRETS") or ""
505+
skew_raw = os.environ.get("VGI_PROXY_PROOF_SKEW") or "30"
506+
try:
507+
secrets = parse_secrets(raw_secrets)
508+
config = ProxyProofConfig(
509+
mode=mode,
510+
origin_id=os.environ.get("VGI_PROXY_PROOF_ORIGIN_ID") or "",
511+
secrets=secrets,
512+
skew_seconds=int(skew_raw),
513+
)
514+
except ValueError as exc:
515+
sys.stderr.write(
516+
f"Error: invalid proxy-proof configuration: {exc}\n"
517+
"Set VGI_PROXY_PROOF_MODE=off to disable, or fix "
518+
"VGI_PROXY_PROOF_ORIGIN_ID / VGI_PROXY_PROOF_SECRETS "
519+
"(kid:hex pairs, 64 hex chars each; generate with 'openssl rand -hex 32').\n",
520+
)
521+
sys.exit(1)
522+
return proxy_proof_gate(config)
449523

450524

451525
def _resolve_bearer_authenticate() -> Callable[..., Any] | None:

0 commit comments

Comments
 (0)