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:
+47
-20
@@ -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) });
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user