fix(studio/create): RED findings — dedup double-bill, dim type-confusion, alg over-match, sub-caption XSS (v0.18.2)

- F1 (MED): _dedup_key now keys on stable identity (org|kind|prompt|refs|uploads),
  not the random-tagged output_prefix, so two identical creates collapse to one
  dispatch (the anti-double-bill guard actually fires now).
- F2 (LOW): _image_graph/_video_graph raise DispatchError on non-numeric dims (clean 400, not 500).
- F3 (LOW): alg allowlist tightened to the exact JWS set in gateway_auth + documents.
- F4 (INFO): esc() the gallery sub-caption (crafted-upload filename_prefix XSS).
Red verified /v1/library/image cross-org/traversal/secret-exfil fail closed. +8 tests, 65 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
z
2026-07-25 09:31:48 -07:00
co-authored by Claude Opus 4.8
parent c00384cf1a
commit 20d2f809af
8 changed files with 68 additions and 16 deletions
+4 -2
View File
@@ -796,8 +796,10 @@ def _forwardable_bearer(request):
if tok.startswith(("pk-", "pk_")):
return "Bearer " + tok
if tok.count(".") == 2:
alg = _jwt_alg(tok)
if isinstance(alg, str) and (alg[:2] in ("RS", "ES", "PS") or alg.startswith("Ed")):
# 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 ""
+4 -2
View File
@@ -66,8 +66,10 @@ def forwardable_bearer(request) -> str:
if tok.startswith(("pk-", "pk_")):
return "Bearer " + tok
if tok.count(".") == 2:
alg = jwt_alg(tok)
if isinstance(alg, str) and (alg[:2] in ("RS", "ES", "PS") or alg.startswith("Ed")):
# Exact JWS asymmetric set — a prefix match ("RS"/"Ed") also admitted non-JWS
# names like RSA-OAEP / "Edwina"; `alg in <tuple>` is crash-safe for any type.
if jwt_alg(tok) in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512",
"PS256", "PS384", "PS512", "EdDSA"):
return "Bearer " + tok
return ""
+2 -2
View File
@@ -1368,7 +1368,7 @@ function render(){
const vid=isVideo(a.path);
const m3d=is3d(a.path);
const media=img||vid||m3d; // shares votes / archive / Re-run / Versions
const prov=a.prov&&a.prov.workflow?` · ${a.prov.workflow}`:'';
const prov=a.prov&&a.prov.workflow?` · ${esc(a.prov.workflow)}`:''; // esc: workflow = an embedded (crafted-upload) filename_prefix
const picked=SEL.has(a.path);
const cardClick=SELMODE?`onclick="pick(event,'${a.path.replace(/'/g,"\\'")}')"`:'';
const inTray=trayHasLib(a.path);
@@ -1386,7 +1386,7 @@ function render(){
:`<div class="doc">${esc((a.path||'').split('/').pop())}</div>`}
<div class="meta">
<span class="t">${esc((a.path||'').split('/').pop())}</span>
<span class="sub">${a.design||a.kind||''}${prov}</span>
<span class="sub">${esc(a.design||a.kind||'')}${prov}</span>
${((a.status==='deleted'||a.status==='flagged')||a.gate==='skipped')?`<div class="row">${(a.status==='deleted'||a.status==='flagged')?`<span class="st ${a.status}">${a.status==='deleted'?'archived':'flagged'}</span>`:''}${a.gate==='skipped'?`<span class="st" style="background:#7a6329;color:#ffd98a" title="Auto-judge could not sample this video — review manually">ungated</span>`:''}</div>`:''}
${media&&!SELMODE?`<div class="fbrow" onclick="event.stopPropagation()">
<button class="vote up ${(RATINGS[a.path]||{}).vote===1?'on':''}" title="Good result — 👍 teaches the model" onclick="castVote('${pe}',1)">👍</button>
+19 -8
View File
@@ -704,11 +704,14 @@ def _video_graph(prompt: str, prefix: str, *, ref_name: str | None = None,
AND its start_image key are omitted, for pure text-to-video (proof caveat). VAEDecode
CreateVideo(24 fps) SaveVideo(mp4/h264). Pure dict builder, same shape as _fix_graph;
cross-checked against zen-video/zen_wan_workflow.py build_prompt (not imported)."""
w = max(256, min(1280, int(width)))
try:
w = max(256, min(1280, int(width)))
h = max(256, min(1280, int(height)))
n = max(9, min(121, int(length)))
except (TypeError, ValueError):
raise DispatchError("width, height and length must be numbers") # → a clean 400
w -= w % 32 # snap to the 32-px grid (proof caveat)
h = max(256, min(1280, int(height)))
h -= h % 32
n = max(9, min(121, int(length)))
n = ((n - 1) // 4) * 4 + 1 # snap to 4n+1
pos = prompt.strip()
neg = _WAN_NEG
@@ -752,9 +755,12 @@ def _image_graph(prompt: str, prefix: str, *, width: int = 1024, height: int = 1
same shape as _video_graph the text-to-image analogue the composer's default lane
dispatches. Unlike _fix_graph this DOES start from an EmptySD3LatentImage: it is a true
text-to-image create, not an edit of an existing asset."""
w = max(256, min(2048, int(width)))
try:
w = max(256, min(2048, int(width)))
h = max(256, min(2048, int(height)))
except (TypeError, ValueError):
raise DispatchError("width and height must be numbers") # → a clean 400, not a 500
w -= w % 16
h = max(256, min(2048, int(height)))
h -= h % 16
pos = prompt.strip()
if lessons:
@@ -1199,9 +1205,14 @@ _RECENT_DISPATCH: dict = {} # key -> (ts, prompt_id)
_DEDUP_WINDOW_S = 60
def _dedup_key(org: str, output_prefix: str, prompt: str, refs: list) -> str:
def _dedup_key(org: str, kind: str, prompt: str, refs: list, uploads: list) -> str:
# Identify a render by its STABLE inputs — NOT output_prefix, which every lane
# stamps with a random _job_tag() before dispatch (so keying on it made two
# identical submits always miss → the anti-double-bill guard never fired). This
# keys on what actually defines the render: org, lane, prompt, and its inputs.
import hashlib
raw = "|".join([org, output_prefix or "", (prompt or "").strip(), ",".join(sorted(refs or []))])
raw = "|".join([org, kind or "", (prompt or "").strip(),
",".join(sorted(refs or [])), ",".join(sorted(uploads or []))])
return hashlib.md5(raw.encode()).hexdigest()
@@ -1227,7 +1238,7 @@ async def _dispatch(request, server, org: str, graph: dict, *, kind: str, prompt
params["lessons"] = lessons # what the user's notes taught THIS job to avoid
common = dict(kind=kind, prompt=prompt, refs=refs, uploads=uploads,
parents=parents, output_prefix=output_prefix, params=params)
key = _dedup_key(org, output_prefix, prompt, refs)
key = _dedup_key(org, kind, prompt, refs, uploads)
dup = _recent_dispatch(key)
if dup:
return dup # identical render already in flight — reuse it
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-studio"
version = "0.18.1"
version = "0.18.2"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is
# updated in pyproject.toml.
__version__ = "0.18.1"
__version__ = "0.18.2"
@@ -96,3 +96,12 @@ def test_forwardable_bearer_survives_crafted_non_string_alg():
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg(bad))) == ""
assert G.jwt_alg(jwt_bad_alg("RS256")) == "RS256" # real asymmetric alg still read
assert G.forwardable_bearer(_Req("Bearer " + jwt_bad_alg("RS256"))) == "Bearer " + jwt_bad_alg("RS256")
def test_alg_allowlist_is_the_exact_jws_set_not_a_prefix_match():
"""F3: a prefix match ("RS"/"Ed") admitted non-JWS names like RSA-OAEP / 'Edwina';
the allowlist is now the exact JWS asymmetric set."""
for good in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "EdDSA"):
assert G.forwardable_bearer(_Req("Bearer " + _jwt(good))) == "Bearer " + _jwt(good)
for bad in ("RSA-OAEP", "RSA1_5", "Edwina", "ES", "RS", "none", "HS256", "PSK"):
assert G.forwardable_bearer(_Req("Bearer " + _jwt(bad))) == ""
@@ -109,3 +109,31 @@ def test_bars_are_opaque_and_dev_control_is_gated():
def test_mobile_collapses_the_filter_pills():
assert ".chips{flex-wrap:nowrap;overflow-x:auto" in _HTML
# ── RED create-lane regressions (@c00384cf): F1 dedup identity, F2 dim coercion ──
def test_dedup_key_is_stable_identity_not_the_random_prefix():
# F1: the key must be the STABLE render identity (org|kind|prompt|refs|uploads) —
# NOT output_prefix, which each lane stamps with a random _job_tag() so two
# identical submits used to miss and double-bill.
k = sh._dedup_key("acme", "image", "a jazz poster", [], [])
assert k == sh._dedup_key("acme", "image", "a jazz poster", [], []), "identical creates share a key"
assert k != sh._dedup_key("acme", "image", "a DIFFERENT poster", [], []), "different prompt → different key"
assert k != sh._dedup_key("other", "image", "a jazz poster", [], []), "different org → different key"
assert k != sh._dedup_key("acme", "video", "a jazz poster", [], []), "different lane → different key"
assert k != sh._dedup_key("acme", "image", "a jazz poster", ["ref.png"], []), "different refs → different key"
def test_image_and_video_graphs_reject_non_numeric_dims():
# F2: a non-numeric width/height/length raises DispatchError (→ clean 400), never a bare ValueError (→ 500).
for bad in ("abc", "10px", [], {}):
with pytest.raises(sh.DispatchError):
sh._image_graph("a valid prompt", "image/x", width=bad)
with pytest.raises(sh.DispatchError):
sh._image_graph("a valid prompt", "image/x", height=bad)
with pytest.raises(sh.DispatchError):
sh._video_graph("a valid prompt", "video/x", width=bad)
# numeric strings still coerce and render
g = sh._image_graph("a valid prompt", "image/x", width="512", height="512")
assert g["7"]["inputs"]["width"] == 512 and g["7"]["inputs"]["height"] == 512