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:
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user