mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
fix(cli,studio): close the five R4 blocking gaps
P1 — the required Test lane was red, and it was my test. The
hostile-Host SPA case asserted a 200, which only holds when
packages/studio/dist is built: true on a dev box, false in CI, so it
passed locally and failed there. The Host split moved into a pure
buildStudioHeadScriptsForHost() and is asserted directly; the route test
no longer depends on build state. Verified by running the CLI suite with
the bundle moved aside — 2330 pass.
P1 — studio:* still bypassed most privacy controls. It honoured two
localStorage keys but not navigator.doNotTrack,
VITE_HYPERFRAMES_NO_TELEMETRY, Vite dev mode or API-key eligibility, and
canary enrolment honoured a different single control. New
telemetry/policy.ts is the one answer to "may this profile be measured",
consumed by both transports and by enrolment. It imports only ./config,
so no cycle with the modules that import it. Each control is asserted
individually.
P1 — LAN/remote preview lost the authoritative decisions. Withholding
the whole head script for any non-loopback Host also dropped the safe
{enabled, forced} map, sending a supported HYPERFRAMES_PREVIEW_HOST=
0.0.0.0 Studio back to re-deriving. Identity injection is now gated
separately from decision injection: identity is loopback-only, decisions
always publish.
P1 — the breaker latch was neither authoritative nor truthfully
persisted. syncInstallState swallowed its own failures so
writeConfigWithResult always reported ok, and reads took the flag only
from config.json. The latch is now merged into every effective read,
which makes install-state authoritative and closes both the failed-mirror
and stale-concurrent-writer paths; the write additionally reports
mirrored: false rather than swallowing.
P2 — public contracts. canary-rollouts.mdx said a config wipe loses the
breaker (it does not) and documented the superseded {name: boolean} map
with unconditional CLI precedence; both corrected, with the precedence
ladder written out and the override exception stated explicitly. PR body
rewritten — it still named ~/.local/state, claimed state survives
deleting ~/.hyperframes, and carried stale counts.
Tests: 2330 CLI (bundle absent), 3151 Studio, 24 core. Fault injection:
reverting each fix alone fails 5 CLI / 5 Studio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f81ab0162e
commit
5e2a9432f1
@@ -74,22 +74,11 @@ describe("host guarding on identity-bearing responses", () => {
|
||||
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>");
|
||||
});
|
||||
// NOTE: the SPA-injection branch itself is covered in telemetryIdentity.test.ts
|
||||
// via buildStudioHeadScriptsForHost. It cannot be asserted here: this route
|
||||
// only reaches the injection branch when packages/studio/dist is built,
|
||||
// which is true locally and false in the CI test lane, so a route-level
|
||||
// assertion on the returned HTML passes on a dev box and fails in CI.
|
||||
|
||||
it("refuses the identity endpoint for a hostile Host", async () => {
|
||||
server = createStudioServer({ projectDir: tmpProject() });
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./runtimeSource.js";
|
||||
import { VERSION as version } from "../version.js";
|
||||
import {
|
||||
buildStudioHeadScripts,
|
||||
buildStudioHeadScriptsForHost,
|
||||
isLoopbackHost,
|
||||
resolveCliTelemetryDistinctId,
|
||||
} from "./telemetryIdentity.js";
|
||||
@@ -818,13 +818,13 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// 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();
|
||||
// the same distinct id and seed out of the returned HTML.
|
||||
//
|
||||
// Only IDENTITY is withheld from an untrusted Host. The canary decisions
|
||||
// map still goes out — it is non-identifying, and a LAN/remote Studio
|
||||
// (`HYPERFRAMES_PREVIEW_HOST=0.0.0.0`) needs it to stay in agreement with
|
||||
// the CLI. See buildStudioHeadScriptsForHost.
|
||||
const headScript = buildStudioHeadScriptsForHost(buildRuntimeEnvScript(), c.req.header("host"));
|
||||
if (headScript) {
|
||||
html = html.replace("<head>", `<head>${headScript}`);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
buildCliIdentityScript,
|
||||
buildStudioHeadScripts,
|
||||
isLoopbackHost,
|
||||
buildStudioHeadScriptsForHost,
|
||||
} = await import("./telemetryIdentity.js");
|
||||
|
||||
describe("resolveCliTelemetryDistinctId", () => {
|
||||
@@ -206,3 +207,47 @@ describe("isLoopbackHost (DNS-rebinding guard on the identity endpoint)", () =>
|
||||
expect(isLoopbackHost(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStudioHeadScriptsForHost — Host split", () => {
|
||||
const ENV = "<script>window.__HF_STUDIO_ENV__={};</script>";
|
||||
|
||||
beforeEach(() => {
|
||||
shouldTrack.mockReset();
|
||||
readConfig.mockReset();
|
||||
canaryDecisions.mockReset();
|
||||
shouldTrack.mockReturnValue(true);
|
||||
readConfig.mockReturnValue({ anonymousId: "machine-uuid", bucketSeed: "seed-uuid" });
|
||||
canaryDecisions.mockReturnValue({ "de-parallel-router": { enabled: true, forced: false } });
|
||||
});
|
||||
|
||||
it("publishes identity and decisions on a loopback Host", () => {
|
||||
const head = buildStudioHeadScriptsForHost(ENV, "127.0.0.1:5173");
|
||||
expect(head).toContain("__HF_CLI_DISTINCT_ID");
|
||||
expect(head).toContain("__HF_CLI_BUCKET_SEED");
|
||||
expect(head).toContain("__HF_CLI_CANARY_DECISIONS");
|
||||
});
|
||||
|
||||
// DNS rebinding: identity must not be readable from a hostile origin.
|
||||
it("withholds identity and seed from a hostile Host", () => {
|
||||
const head = buildStudioHeadScriptsForHost(ENV, "evil.example.com");
|
||||
expect(head).not.toContain("__HF_CLI_DISTINCT_ID");
|
||||
expect(head).not.toContain("__HF_CLI_BUCKET_SEED");
|
||||
});
|
||||
|
||||
// ...but the decisions map is NOT identifying, and withholding it would send
|
||||
// a supported LAN preview (HYPERFRAMES_PREVIEW_HOST=0.0.0.0) back to
|
||||
// re-deriving locally and disagreeing with the CLI.
|
||||
it.each(["evil.example.com", "192.168.1.10:5173", "my-dev-box.local:5173", undefined])(
|
||||
"still publishes canary decisions for non-loopback Host %s",
|
||||
(host) => {
|
||||
const head = buildStudioHeadScriptsForHost(ENV, host);
|
||||
expect(head).toContain("__HF_CLI_CANARY_DECISIONS");
|
||||
expect(head).not.toContain("__HF_CLI_DISTINCT_ID");
|
||||
},
|
||||
);
|
||||
|
||||
it("always keeps the env script, whatever the Host", () => {
|
||||
expect(buildStudioHeadScriptsForHost(ENV, "evil.example.com")).toContain("__HF_STUDIO_ENV__");
|
||||
expect(buildStudioHeadScriptsForHost(ENV, "localhost")).toContain("__HF_STUDIO_ENV__");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,10 +119,16 @@ function resolveCliCanaryDecisions(): Record<string, CliCanaryDecision> | null {
|
||||
* or browser history. Empty string only when there is nothing at all to
|
||||
* publish.
|
||||
*/
|
||||
export function buildCliIdentityScript(): string {
|
||||
export function buildCliIdentityScript(options: { includeIdentity?: boolean } = {}): string {
|
||||
const { includeIdentity = true } = options;
|
||||
const parts: string[] = [];
|
||||
|
||||
const cliId = resolveCliTelemetryDistinctId();
|
||||
// Identity is the only part gated on a trusted Host. The decisions map below
|
||||
// is not identifying, and withholding it would push a LAN/remote Studio
|
||||
// (`HYPERFRAMES_PREVIEW_HOST=0.0.0.0`, an explicitly supported mode) back to
|
||||
// re-deriving locally — reopening exactly the CLI/Studio disagreement this
|
||||
// whole mechanism exists to close.
|
||||
const cliId = includeIdentity ? resolveCliTelemetryDistinctId() : null;
|
||||
if (cliId) {
|
||||
parts.push(`window.__HF_CLI_DISTINCT_ID=${encodeInlineScriptValue(cliId)};`);
|
||||
const seed = resolveCliBucketSeed();
|
||||
@@ -154,6 +160,21 @@ export function buildCliIdentityScript(): string {
|
||||
* ordering in one pure, tested function guards against a future `<head>` inject
|
||||
* silently landing ahead of the identity script and reintroducing a boot race.
|
||||
*/
|
||||
export function buildStudioHeadScripts(envScript: string): string {
|
||||
return `${buildCliIdentityScript()}${envScript}`;
|
||||
export function buildStudioHeadScripts(
|
||||
envScript: string,
|
||||
options: { includeIdentity?: boolean } = {},
|
||||
): string {
|
||||
return `${buildCliIdentityScript(options)}${envScript}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `<head>` scripts for a request, given its `Host`.
|
||||
*
|
||||
* The Host split lives here rather than in the route so it is testable
|
||||
* without a Studio bundle on disk — the route's own test can only reach the
|
||||
* injection branch when `packages/studio/dist` happens to be built, which is
|
||||
* true locally and false in the CI test lane.
|
||||
*/
|
||||
export function buildStudioHeadScriptsForHost(envScript: string, host: string | undefined): string {
|
||||
return buildStudioHeadScripts(envScript, { includeIdentity: isLoopbackHost(host) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user