refactor(studio/docs): decomplect documents AI-fill onto the ONE canonical gateway_auth
documents.py carried a parity copy of the gateway-auth allowlist (_jwt_alg, _forwardable_bearer, _SECRET_PREFIXES) — and it DID drift: the same crafted-alg hardening + exact-JWS tightening had to be applied to both copies. _ai_token now delegates to gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV); the ~55 duplicated lines are deleted. One and only one fail-closed allowlist, so a future fix can never land on one copy and miss the other. Tests repointed to the _ai_token delegation surface (crafted-alg no-crash, HS256/secret refused, pk-/ asymmetric forwarded, server-key-first). 67 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+7
-52
@@ -756,64 +756,19 @@ def render_document(tmpl, fields, fmt):
|
||||
|
||||
# ── AI-fill: description -> structured fields via the Hanzo gateway ─────────────────
|
||||
|
||||
_SECRET_PREFIXES = ("sk-", "sk_", "hk-", "hk_", "rk-", "rk_")
|
||||
_SERVER_TOKEN_ENV = ("STUDIO_DOC_TOKEN", "STUDIO_COPILOT_TOKEN", "STUDIO_COMMERCE_TOKEN")
|
||||
|
||||
|
||||
def _jwt_alg(tok):
|
||||
"""The ``alg`` from a JWT header, or "" if not a decodable JWT. Used to distinguish a
|
||||
JWKS-verifiable asymmetric token (gateway-acceptable) from an HS256 session token."""
|
||||
import base64
|
||||
try:
|
||||
h = tok.split(".", 1)[0]
|
||||
h += "=" * (-len(h) % 4)
|
||||
alg = json.loads(base64.urlsafe_b64decode(h.encode())).get("alg", "")
|
||||
# A crafted header can make alg a non-string (null/list/number/dict); the
|
||||
# contract is "the alg string or ''", so a non-string is not a usable alg.
|
||||
return alg if isinstance(alg, str) else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _forwardable_bearer(request):
|
||||
"""The caller's own token to forward to the gateway, or "" — fail-closed.
|
||||
|
||||
Reuses the app's one token path (``gpu_dispatch._bearer``: Authorization header or
|
||||
the browser session cookie), then applies a strict allowlist:
|
||||
* a publishable ``pk-`` key — allowed;
|
||||
* a JWT — allowed ONLY when asymmetric/JWKS-verifiable (RS/ES/PS/EdDSA); a symmetric
|
||||
HS256 hanzo.id session token is refused (the gateway rejects it, so forwarding it
|
||||
only produces log noise and a 502);
|
||||
* a secret ``sk-``/``hk-``/``rk-`` key — refused outright.
|
||||
So a secret can never reach the gateway from the browser edge and none is in page source."""
|
||||
from middleware.gpu_dispatch import _bearer # lazy: keeps this module import light
|
||||
raw = _bearer(request)
|
||||
if not raw:
|
||||
return ""
|
||||
tok = raw[7:].strip() if raw[:7].lower() == "bearer " else raw.strip()
|
||||
if not tok or tok.lower().startswith(_SECRET_PREFIXES):
|
||||
return ""
|
||||
if tok.startswith(("pk-", "pk_")):
|
||||
return "Bearer " + tok
|
||||
if tok.count(".") == 2:
|
||||
# Exact JWS asymmetric set (a prefix match also admitted RSA-OAEP / "Edwina"…);
|
||||
# `alg in <tuple>` is crash-safe for a non-string alg too.
|
||||
if _jwt_alg(tok) in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
|
||||
"PS256", "PS384", "PS512", "EdDSA"):
|
||||
return "Bearer " + tok
|
||||
return ""
|
||||
|
||||
|
||||
def _ai_token(request):
|
||||
"""The bearer AI-fill sends to the gateway, "" when none is usable (→ form fallback).
|
||||
|
||||
A server-side gateway key wins (the configured, working path; never in page source);
|
||||
otherwise the caller's own gateway-verifiable token via the fail-closed allowlist."""
|
||||
for env in _SERVER_TOKEN_ENV:
|
||||
v = os.environ.get(env, "").strip()
|
||||
if v:
|
||||
return v if v[:7].lower() == "bearer " else "Bearer " + v
|
||||
return _forwardable_bearer(request)
|
||||
Delegates to the ONE canonical fail-closed allowlist (``middleware.gateway_auth``) —
|
||||
NO private copy (they used to drift: the same crafted-alg hardening had to be applied
|
||||
to both). A server-side gateway key wins (never in page source); else the caller's own
|
||||
token ONLY when the gateway can verify it (a ``pk-`` key or an asymmetric/JWKS JWT) —
|
||||
an HS256 session token and ``sk-``/``hk-``/``rk-`` secrets are refused."""
|
||||
from middleware import gateway_auth # lazy: keeps this module import light
|
||||
return gateway_auth.ai_bearer(request, server_env=_SERVER_TOKEN_ENV)
|
||||
|
||||
|
||||
def _gateway_url():
|
||||
|
||||
@@ -193,16 +193,16 @@ def test_forwardable_bearer_is_fail_closed(monkeypatch):
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
rs, hs = _jwt("RS256"), _jwt("HS256")
|
||||
assert D._forwardable_bearer(_Req("Bearer " + rs)) == "Bearer " + rs # JWKS-verifiable JWT
|
||||
assert D._forwardable_bearer(_Req("Bearer " + hs)) == "" # HS256 session token REFUSED
|
||||
assert D._forwardable_bearer(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
|
||||
assert D._forwardable_bearer(_Req("Bearer sk-secret")) == "" # secret keys refused
|
||||
assert D._forwardable_bearer(_Req("Bearer hk-secret")) == ""
|
||||
assert D._forwardable_bearer(_Req("Bearer rk-secret")) == ""
|
||||
assert D._forwardable_bearer(_Req(None)) == "" # no token
|
||||
assert D._forwardable_bearer(_Req("Bearer notoken")) == "" # opaque, not JWT/pk-
|
||||
assert D._ai_token(_Req("Bearer " + rs)) == "Bearer " + rs # JWKS-verifiable JWT
|
||||
assert D._ai_token(_Req("Bearer " + hs)) == "" # HS256 session token REFUSED
|
||||
assert D._ai_token(_Req("Bearer pk-publishable")) == "Bearer pk-publishable"
|
||||
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret keys refused
|
||||
assert D._ai_token(_Req("Bearer hk-secret")) == ""
|
||||
assert D._ai_token(_Req("Bearer rk-secret")) == ""
|
||||
assert D._ai_token(_Req(None)) == "" # no token
|
||||
assert D._ai_token(_Req("Bearer notoken")) == "" # opaque, not JWT/pk-
|
||||
# session-cookie path (what the browser sends) is honored for a verifiable JWT
|
||||
assert D._forwardable_bearer(_Req(None, {"access_token": rs})) == "Bearer " + rs
|
||||
assert D._ai_token(_Req(None, {"access_token": rs})) == "Bearer " + rs
|
||||
|
||||
|
||||
def test_ai_token_prefers_server_gateway_key(monkeypatch):
|
||||
@@ -477,20 +477,24 @@ def test_control_chars_stripped_and_ooxml_stays_valid():
|
||||
assert _valid("xlsx", D.render_document(b, bf, "xlsx")[0])
|
||||
|
||||
|
||||
def test_crafted_jwt_alg_never_crashes_the_allowlist():
|
||||
import base64
|
||||
def test_ai_token_delegates_to_canonical_gateway_auth(monkeypatch):
|
||||
"""After the decomplect, documents._ai_token routes through the ONE gateway_auth
|
||||
allowlist (no private copy — they used to drift). Prove the fail-closed behavior via
|
||||
the public _ai_token: a crafted non-string alg must not 500; HS256/secret refused;
|
||||
pk-/asymmetric forwarded; a server key wins."""
|
||||
for env in D._SERVER_TOKEN_ENV:
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
|
||||
def jwt_with_alg(alg_value): # a JWT header carrying an arbitrary (maybe non-string) alg
|
||||
import base64
|
||||
h = base64.urlsafe_b64encode(json.dumps({"alg": alg_value}).encode()).rstrip(b"=").decode()
|
||||
return f"{h}.eyJzdWIiOiIxIn0.sig"
|
||||
|
||||
for bad in (None, [], 123, {"x": 1}, True):
|
||||
assert D._jwt_alg(jwt_with_alg(bad)) == "", "a non-string alg must yield '' (no exception)"
|
||||
assert D._jwt_alg(jwt_with_alg("RS256")) == "RS256" # real asymmetric alg still read
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
req = make_mocked_request("POST", "/v1/documents/generate",
|
||||
headers={"Authorization": "Bearer " + jwt_with_alg(None)})
|
||||
assert D._forwardable_bearer(req) == "" # crafted alg → not forwarded, no 500
|
||||
ok = make_mocked_request("POST", "/v1/documents/generate",
|
||||
headers={"Authorization": "Bearer " + jwt_with_alg("RS256")})
|
||||
assert D._forwardable_bearer(ok) == "Bearer " + jwt_with_alg("RS256") # asymmetric still forwarded
|
||||
assert D._ai_token(_Req("Bearer " + jwt_with_alg(bad))) == "" # crafted alg → no 500
|
||||
assert D._ai_token(_Req("Bearer " + jwt_with_alg("HS256"))) == "" # HS256 session token refused
|
||||
assert D._ai_token(_Req("Bearer sk-secret")) == "" # secret refused
|
||||
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer pk-x" # publishable forwarded
|
||||
assert D._ai_token(_Req("Bearer " + jwt_with_alg("RS256"))) == "Bearer " + jwt_with_alg("RS256")
|
||||
monkeypatch.setenv("STUDIO_DOC_TOKEN", "sk-server") # server key wins
|
||||
assert D._ai_token(_Req("Bearer pk-x")) == "Bearer sk-server"
|
||||
|
||||
Reference in New Issue
Block a user