mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
fix(cli,studio): close the four R3 blocking gaps
P1 — SPA route bypassed the DNS-rebinding guard. Guarding only
/api/telemetry-identity left the catch-all as an open side door: a
rebound origin could fetch `/` and read __HF_CLI_DISTINCT_ID and
__HF_CLI_BUCKET_SEED straight out of the returned HTML. The SPA response
now applies the same isLoopbackHost() check; an untrusted Host still gets
a working Studio, just with no identity, seed or decisions injected.
Route-level regression added.
P1 — a CLI cohort roll could override Studio's own opt-out.
decideStudioCanary() adopted the injected decision before checking
isOptedOut(), so CLI-telemetry-on plus Studio-opted-out still enrolled
Studio. A bare boolean could not express the difference between a
deliberate override and an ordinary cohort roll, so the injected map now
carries provenance ({ enabled, forced }). Forced wins outright — it is
the documented escalation channel and must behave the same on both
surfaces — while a percentage roll now loses to this profile's opt-out.
Full interaction matrix tested.
P1 — the legacy studio:* path sat outside both contracts.
utils/studioTelemetry.ts shipped its own opt-out key and its own send
loop, so the documented hyperframes-studio:telemetryDisabled did not
silence it and its events carried no cohort assignment. It now honours
both keys (the legacy one stays, so nobody already opted out is quietly
re-enabled) and mixes in canaryEventProperties(), making "every
telemetry event carries the assignment" actually true.
P2 — partial salvage could drop a tripped breaker.
salvageInstallState() discarded the whole record when markerAt and
bucketSeed were both unusable, taking deParallelRouterTrialFired with it
and re-enrolling a machine whose router already failed. All three fields
are now independently salvageable.
Docs: canary-rollouts.mdx said "disabling telemetry disables the
reporting, not the enrolment" — exactly backwards since the opt-out gate
landed. Corrected; checked for other copies, none.
Tests: 13 new (4 opt-out precedence, 4 legacy-path opt-out and canary
props, 3 route-level host guard, 2 breaker salvage). Fault injection:
each of the four fixes reverted independently fails its own tests
(2 CLI + 1 Studio + 2 Studio).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
54534d53c2
commit
f81ab0162e
@@ -57,3 +57,57 @@ describe("createStudioServer autoProxy plumbing", () => {
|
||||
expect(server.adapter.autoProxy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host guarding on identity-bearing responses", () => {
|
||||
const dirs: string[] = [];
|
||||
let server: StudioServer | undefined;
|
||||
|
||||
function tmpProject(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-studio-host-test-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
server?.watcher.close();
|
||||
server = undefined;
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// A rebound origin can point its own hostname at 127.0.0.1 and read
|
||||
// responses as same-origin. Guarding only /api/telemetry-identity left the
|
||||
// SPA route as an open side door: fetching `/` returned the same distinct
|
||||
// id and bucket seed inline in the HTML.
|
||||
it("omits identity injection from the SPA response for a hostile Host", async () => {
|
||||
server = createStudioServer({ projectDir: tmpProject() });
|
||||
const res = await server.app.request("/", { headers: { host: "evil.example.com" } });
|
||||
const html = await res.text();
|
||||
expect(html).not.toContain("__HF_CLI_DISTINCT_ID");
|
||||
expect(html).not.toContain("__HF_CLI_BUCKET_SEED");
|
||||
expect(html).not.toContain("__HF_CLI_CANARY_DECISIONS");
|
||||
// Studio still loads — only the identity block is withheld. (The env
|
||||
// script is empty here: it only emits with VITE_STUDIO_* vars set.)
|
||||
expect(res.status).toBe(200);
|
||||
expect(html).toContain("<head>");
|
||||
});
|
||||
|
||||
it("refuses the identity endpoint for a hostile Host", async () => {
|
||||
server = createStudioServer({ projectDir: tmpProject() });
|
||||
const res = await server.app.request("/api/telemetry-identity", {
|
||||
headers: { host: "evil.example.com" },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.text()).not.toContain('distinctId":"');
|
||||
});
|
||||
|
||||
it("serves the identity endpoint on a loopback Host", async () => {
|
||||
server = createStudioServer({ projectDir: tmpProject() });
|
||||
const res = await server.app.request("/api/telemetry-identity", {
|
||||
headers: { host: "127.0.0.1:5173" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
// The seed is no longer served here at all — Studio gets decisions
|
||||
// injected instead, so nothing needs it over HTTP.
|
||||
expect(Object.keys((await res.json()) as object)).toEqual(["distinctId"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -814,7 +814,17 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// Inject before the studio bundle runs. Identity script first (see
|
||||
// buildStudioHeadScripts) so the CLI distinct id is on `window` by the time
|
||||
// telemetry init reads it.
|
||||
const headScript = buildStudioHeadScripts(buildRuntimeEnvScript());
|
||||
//
|
||||
// Host-guarded for the same reason /api/telemetry-identity is, and it has
|
||||
// to be checked HERE too: guarding only the endpoint leaves this route as
|
||||
// an open side door, since a rebound origin can simply fetch `/` and read
|
||||
// the same distinct id and seed out of the returned HTML. Untrusted Host
|
||||
// still gets a working Studio — it just gets the env script alone, with no
|
||||
// identity, no seed, and no canary decisions.
|
||||
const trustedHost = isLoopbackHost(c.req.header("host"));
|
||||
const headScript = trustedHost
|
||||
? buildStudioHeadScripts(buildRuntimeEnvScript())
|
||||
: buildRuntimeEnvScript();
|
||||
if (headScript) {
|
||||
html = html.replace("<head>", `<head>${headScript}`);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const shouldTrack = vi.fn();
|
||||
const readConfig = vi.fn();
|
||||
// Pinned rather than using the real registry, so these string assertions
|
||||
// don't move every time a canary is added, ramped, or retired.
|
||||
const canaryDecisions = vi.fn<() => Record<string, boolean>>();
|
||||
const canaryDecisions = vi.fn<() => Record<string, { enabled: boolean; forced: boolean }>>();
|
||||
|
||||
vi.mock("../telemetry/client.js", () => ({
|
||||
shouldTrack: (...args: unknown[]) => shouldTrack(...args),
|
||||
@@ -99,10 +99,11 @@ describe("buildCliIdentityScript", () => {
|
||||
// stops Studio evaluating independently and enrolling anyway.
|
||||
it("still publishes canary decisions when telemetry is off, but no identity", () => {
|
||||
shouldTrack.mockReturnValue(false);
|
||||
canaryDecisions.mockReturnValue({ "de-parallel-router": false });
|
||||
canaryDecisions.mockReturnValue({ "de-parallel-router": { enabled: false, forced: false } });
|
||||
const script = buildCliIdentityScript();
|
||||
expect(script).toBe(
|
||||
'<script>window.__HF_CLI_CANARY_DECISIONS={"de-parallel-router":false};</script>',
|
||||
"<script>window.__HF_CLI_CANARY_DECISIONS=" +
|
||||
'{"de-parallel-router":{"enabled":false,"forced":false}};</script>',
|
||||
);
|
||||
expect(script).not.toContain("__HF_CLI_DISTINCT_ID");
|
||||
expect(script).not.toContain("__HF_CLI_BUCKET_SEED");
|
||||
@@ -111,17 +112,20 @@ describe("buildCliIdentityScript", () => {
|
||||
it("publishes decisions alongside the identity when telemetry is on", () => {
|
||||
shouldTrack.mockReturnValue(true);
|
||||
readConfig.mockReturnValue({ anonymousId: "machine-uuid", bucketSeed: "seed-uuid" });
|
||||
canaryDecisions.mockReturnValue({ "de-parallel-router": true });
|
||||
canaryDecisions.mockReturnValue({ "de-parallel-router": { enabled: true, forced: true } });
|
||||
expect(buildCliIdentityScript()).toBe(
|
||||
'<script>window.__HF_CLI_DISTINCT_ID="machine-uuid";' +
|
||||
'window.__HF_CLI_BUCKET_SEED="seed-uuid";' +
|
||||
'window.__HF_CLI_CANARY_DECISIONS={"de-parallel-router":true};</script>',
|
||||
"window.__HF_CLI_CANARY_DECISIONS=" +
|
||||
'{"de-parallel-router":{"enabled":true,"forced":true}};</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes a canary name that tries to close the script tag", () => {
|
||||
shouldTrack.mockReturnValue(false);
|
||||
canaryDecisions.mockReturnValue({ "</script><script>alert(1)": true });
|
||||
canaryDecisions.mockReturnValue({
|
||||
"</script><script>alert(1)": { enabled: true, forced: false },
|
||||
});
|
||||
const script = buildCliIdentityScript();
|
||||
expect(script).not.toContain("</script><script>alert(1)");
|
||||
expect(script).toContain("__HF_CLI_CANARY_DECISIONS");
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { readConfig } from "../telemetry/config.js";
|
||||
import { shouldTrack as telemetryShouldTrack } from "../telemetry/client.js";
|
||||
import { canaryDecisionsForStudio } from "../telemetry/canary.js";
|
||||
import { canaryDecisionsForStudio, type CliCanaryDecision } from "../telemetry/canary.js";
|
||||
|
||||
/**
|
||||
* The CLI's anonymous distinct id to hand to Studio, or null when CLI telemetry
|
||||
@@ -102,7 +102,7 @@ function encodeInlineScriptJson(value: unknown): string {
|
||||
* The CLI's resolved canary decisions for a launched Studio, or null when
|
||||
* there are none to publish. Fail-silent, like everything else here.
|
||||
*/
|
||||
function resolveCliCanaryDecisions(): Record<string, boolean> | null {
|
||||
function resolveCliCanaryDecisions(): Record<string, CliCanaryDecision> | null {
|
||||
try {
|
||||
const decisions = canaryDecisionsForStudio();
|
||||
return Object.keys(decisions).length > 0 ? decisions : null;
|
||||
|
||||
Reference in New Issue
Block a user