studio: fail-closed pk allowlist + chat stream fallback + parser/guard tests

Resolves the three review mustFix before a publishable key is provisioned to the
studio pod (the next deploy step), which flips on the streaming path and relies
on the key guard:

- M1 (security): _publishable is now an ALLOWLIST — emit only `pk-` (the
  gateway's single client-safe prefix, iamauth.go:472-476). The old denylist
  missed fw_/hz_/JWT server secrets and failed OPEN, leaking them into
  window.HZ.pk in page source.
- M2 (robustness): the /studio Chat stream degrades to the same-origin copilot on
  any transport failure (fetch throws / non-2xx / no body) instead of
  dead-ending, so a PK provisioned before the gateway allows CORS+Authorization
  for studio.hanzo.ai can't hard-break Chat. SSE parse decomplected into pure
  sseLines/sseDelta; stream behavior otherwise identical.
- M3 (coverage): commit publishable_test.py (allowlist + <script> break-out incl.
  U+2028/9) and sse_parser_test.py (runs the SHIPPED shell.js parser under node).

103/103 studio_home_test, 207/207 middleware_test green.
This commit is contained in:
Hanzo AI
2026-07-24 16:49:17 -07:00
parent 131081184a
commit d75983c437
4 changed files with 211 additions and 28 deletions
+47 -20
View File
@@ -97,11 +97,43 @@
// (api.hanzo.ai/v1/chat/completions, OpenAI-compatible SSE). Otherwise the secure
// same-origin copilot path (cookie-authed; also carries graph ops in the editor).
// Same transcript UI either way.
function send(text) { return USE_STREAM ? sendStream(text) : sendCopilot(text); }
async function sendStream(text) {
function send(text) {
HISTORY.push({ role: "user", content: text });
addMsg("u", text);
return USE_STREAM ? sendStream() : sendCopilot();
}
// Pure SSE helpers (no DOM) — decomplected so the stream loop and the unit tests
// share ONE parser. `sseLines()` returns a stateful splitter that holds a partial
// line across chunk boundaries; `sseDelta(line)` maps one line to its content
// delta (or null for keep-alives, `[DONE]`, non-data lines, or malformed JSON).
function sseLines() {
var buf = "";
return function (chunk) {
buf += chunk;
var out = [], nl;
while ((nl = buf.indexOf("\n")) >= 0) { out.push(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
return out;
};
}
function sseDelta(line) {
line = line.replace(/\r$/, "").trim();
if (line.slice(0, 5) !== "data:") return null;
var d = line.slice(5).trim();
if (!d || d === "[DONE]") return null;
try {
var j = JSON.parse(d);
var delta = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content;
return delta || null;
} catch (_) { return null; }
}
// Home + publishable key: stream enso from the gateway. On a TRANSPORT failure
// (fetch throws, non-2xx, or no body — e.g. a PK provisioned before the gateway
// allows CORS/Authorization for this origin) degrade to the same-origin copilot
// instead of dead-ending, so Chat never hard-breaks. A partial stream that drops
// mid-flight keeps whatever text already arrived.
async function sendStream() {
var box = addMsg("a", ""); box.classList.add("streaming");
var acc = "";
try {
@@ -110,31 +142,26 @@
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + PK },
body: JSON.stringify({ model: chatModel(), messages: HISTORY.slice(-20), stream: true }),
});
if (!res.ok || !res.body) { box.classList.remove("streaming"); box.textContent = "chat failed (" + res.status + ")"; return; }
var reader = res.body.getReader(), dec = new TextDecoder(), buf = "";
if (!res.ok || !res.body) { box.remove(); return sendCopilot(); }
var reader = res.body.getReader(), dec = new TextDecoder(), feed = sseLines();
for (;;) {
var rd = await reader.read(); if (rd.done) break;
buf += dec.decode(rd.value, { stream: true });
var nl;
while ((nl = buf.indexOf("\n")) >= 0) {
var line = buf.slice(0, nl).replace(/\r$/, "").trim(); buf = buf.slice(nl + 1);
if (line.slice(0, 5) !== "data:") continue;
var d = line.slice(5).trim(); if (!d || d === "[DONE]") continue;
try {
var j = JSON.parse(d);
var delta = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content;
if (delta) { acc += delta; box.textContent = acc; $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight; }
} catch (_) {}
var lines = feed(dec.decode(rd.value, { stream: true }));
for (var k = 0; k < lines.length; k++) {
var delta = sseDelta(lines[k]);
if (delta) { acc += delta; box.textContent = acc; $("hzmsgs").scrollTop = $("hzmsgs").scrollHeight; }
}
}
box.classList.remove("streaming");
if (acc) HISTORY.push({ role: "assistant", content: acc }); else box.textContent = "(no response)";
} catch (e) { box.classList.remove("streaming"); box.textContent = "error: " + e.message; }
} catch (e) {
box.classList.remove("streaming");
if (acc) HISTORY.push({ role: "assistant", content: acc });
else { box.remove(); return sendCopilot(); }
}
}
async function sendCopilot(text) {
HISTORY.push({ role: "user", content: text });
addMsg("u", text);
async function sendCopilot() {
var pending = addMsg("sys", "…");
var payload = Object.assign({ model: chatModel(), messages: HISTORY.slice(-20) }, await graphContext());
var r = await getJSON("/v1/copilot/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
+8 -8
View File
@@ -1551,17 +1551,17 @@ _SHELL_BODY = '<script src="/studio/shell.js" defer></script>'
# Chat sidebar falls back to the secure same-origin copilot path (httpOnly cookie),
# so no key is exposed. NEVER a server secret.
_PUBLISHABLE_KEY_ENV = ("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "STUDIO_PUBLISHABLE_KEY")
_SECRET_PREFIXES = ("sk-", "sk_", "hk-", "hk_", "secret")
_PUBLISHABLE_PREFIX = "pk-" # gateway iamauth.go: the ONE client-safe key family
def _publishable(pk: str) -> str:
"""Fail closed: refuse to hand the browser anything that looks like a SERVER
secret. A misconfigured secret is dropped (Chat falls back to the same-origin
copilot) rather than leaked into page source."""
low = pk.strip().lower()
if not low or any(low.startswith(p) for p in _SECRET_PREFIXES):
return ""
return pk.strip()
"""Fail CLOSED via allowlist: hand the browser a value ONLY when it is a
publishable key (`pk-`, the single client-safe prefix the gateway defines).
Every server secret (`hk-`/`sk-`/`fw_`/`hz_`), JWT, or unknown value is dropped
so a misconfigured secret can never reach page source; Chat then falls back to
the secure same-origin copilot path."""
pk = pk.strip()
return pk if pk.startswith(_PUBLISHABLE_PREFIX) else ""
def _hz_config_script() -> str:
@@ -0,0 +1,80 @@
"""Guard tests for the browser-injected publishable key (middleware/studio_home.py).
The publishable key is the ONE credential Studio prints into page source, so these
pin the invariants that keep a server secret out of the client:
* ``_publishable`` is an ALLOWLIST -- only ``pk-`` (the gateway's single
client-safe prefix, iamauth.go) survives; every server-secret family
(``hk-``/``sk-``/``fw_``/``hz_``) and JWT is dropped (fail CLOSED).
* ``_hz_config_script`` cannot be broken out of its ``<script>`` tag, even by a
hostile value that also satisfies the allowlist.
"""
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from middleware import studio_home as sh
_PREFIX, _SUFFIX = "<script>window.HZ=", ";</script>"
_LINE_SEP, _PARA_SEP = chr(0x2028), chr(0x2029) # raw JS statement terminators (U+2028/9)
def _parse_hz(script: str) -> dict:
assert script.startswith(_PREFIX) and script.endswith(_SUFFIX)
payload = script[len(_PREFIX):-len(_SUFFIX)]
return json.loads(payload.replace("<\\/", "</")) # reverse the </ neutralization
@pytest.mark.parametrize("secret", [
"hk-live-abc", "sk-live-abc", "fw_live_abc", "hz_live_abc", # gateway server-secret families
"eyJhbGciOiJIUzI1NiJ9.body.sig", # a JWT
"secret-abc", "PK-upper", "pk_underscore", "pkno-dash", # not the pk- publishable prefix
" ", "", # blank
])
def test_publishable_drops_everything_but_pk(secret):
assert sh._publishable(secret) == ""
@pytest.mark.parametrize("pk,want", [
("pk-abc123", "pk-abc123"),
(" pk-abc123 ", "pk-abc123"), # trimmed
])
def test_publishable_passes_pk(pk, want):
assert sh._publishable(pk) == want
def test_hz_config_injects_publishable_env(monkeypatch):
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "pk-live-xyz")
assert _parse_hz(sh._hz_config_script())["pk"] == "pk-live-xyz"
def test_hz_config_drops_secret_env(monkeypatch):
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("STUDIO_PUBLISHABLE_KEY", "hz_server_secret") # a real server key
assert _parse_hz(sh._hz_config_script())["pk"] == ""
def test_hz_config_empty_when_unset(monkeypatch):
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
assert _parse_hz(sh._hz_config_script())["pk"] == ""
def test_hz_config_script_cannot_break_out(monkeypatch):
# A hostile value that still satisfies the pk- allowlist must not be able to
# close the <script> tag or smuggle a raw JS line/paragraph separator (U+2028/9).
for k in sh._PUBLISHABLE_KEY_ENV:
monkeypatch.delenv(k, raising=False)
monkeypatch.setenv("NEXT_PUBLIC_HANZO_PUBLISHABLE_KEY", "pk-</script><img>" + _LINE_SEP + _PARA_SEP)
script = sh._hz_config_script()
body = script[len(_PREFIX):-len(_SUFFIX)]
assert "</" not in body # no tag can be opened/closed
assert _LINE_SEP not in body and _PARA_SEP not in body # separators escaped, not raw
assert _parse_hz(script)["pk"].startswith("pk-") # value survives intact, just neutralized
@@ -0,0 +1,76 @@
"""The Chat SSE parser lives in middleware/shell.js as two pure functions
(``sseLines`` splits streamed chunks into whole lines across buffer boundaries;
``sseDelta`` maps one line to its content delta). This test extracts them from the
SHIPPED shell and exercises them under node -- chunk-boundary splits, CRLF, [DONE],
keep-alives and malformed lines -- so the one parser the browser runs is the one
under test. Skipped where node is unavailable, keeping ``uv run pytest`` green.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
SHELL = Path(__file__).resolve().parents[2] / "middleware" / "shell.js"
def _fn(src: str, name: str) -> str:
"""Return the source of the top-level `function <name>(...) {...}` by brace match."""
i = src.index("function " + name + "(")
depth = 0
start = src.index("{", i)
for j in range(start, len(src)):
if src[j] == "{":
depth += 1
elif src[j] == "}":
depth -= 1
if depth == 0:
return src[i:j + 1]
raise AssertionError("unbalanced braces extracting " + name)
_CHECKS = r"""
function eq(a, b, m) {
if (JSON.stringify(a) !== JSON.stringify(b)) {
console.error("FAIL " + m + ": " + JSON.stringify(a) + " !== " + JSON.stringify(b));
process.exit(1);
}
}
// sseDelta: line -> content delta (or null)
eq(sseDelta('data: {"choices":[{"delta":{"content":"hi"}}]}'), "hi", "basic");
eq(sseDelta('data: {"choices":[{"delta":{"content":"x"}}]}\r'), "x", "trailing-CR-tolerated");
eq(sseDelta('data: [DONE]'), null, "done-sentinel");
eq(sseDelta(': keep-alive'), null, "comment-line");
eq(sseDelta('event: ping'), null, "non-data-line");
eq(sseDelta('data: {bad json'), null, "malformed-json");
eq(sseDelta('data: {"choices":[{"delta":{}}]}'), null, "no-content-field");
eq(sseDelta(''), null, "empty-line");
// sseLines: a line split across two feeds emerges once, whole (chunk boundary)
var feed = sseLines();
eq(feed('data: {"choices":[{"delta":{"content":"He'), [], "partial-held");
var out = feed('llo"}}]}\ndata: [DONE]\n');
eq(out.length, 2, "two-lines-after-boundary");
eq(sseDelta(out[0]), "Hello", "reassembled-delta");
eq(sseDelta(out[1]), null, "trailing-done");
// sseLines: multiple CRLF-terminated lines in one chunk; the CR rides through for
// sseDelta to strip, and a dangling partial (no newline) is withheld.
var f2 = sseLines();
var o2 = f2("data: a\r\ndata: b\r\ndata: c");
eq(o2, ["data: a\r", "data: b\r"], "crlf-split-holds-partial");
eq(sseDelta(o2[0]), null, "plain-a-not-json");
console.log("OK");
"""
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_sse_parser_under_node():
src = SHELL.read_text()
driver = _fn(src, "sseLines") + "\n" + _fn(src, "sseDelta") + "\n" + _CHECKS
r = subprocess.run([shutil.which("node"), "-e", driver],
capture_output=True, text=True, timeout=30)
assert r.returncode == 0, "node failed:\nSTDERR:\n" + r.stderr + "\nSTDOUT:\n" + r.stdout
assert "OK" in r.stdout