mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:17:29 +00:00
Merge pull request #2854 from heygen-com/feat/canary-rollouts
feat(core): percentage-based canary rollouts + calibration experiment
This commit is contained in:
@@ -312,4 +312,29 @@ describe("studioRenderTelemetry", () => {
|
||||
expect(p.observabilityExtractVfrPreflightCount).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
// The browser profile's opt-out is invisible to the CLI's own policy, so
|
||||
// without an explicit signal these fired for an opted-out user — attributed
|
||||
// to the install id, which is worse than attributing them correctly.
|
||||
describe("browser telemetry opt-out", () => {
|
||||
it("emits nothing for a render whose browser opted out", () => {
|
||||
emitStudioRenderComplete({ ...opts, telemetryOptOut: true }, 5000, fullPerf);
|
||||
emitStudioRenderError(
|
||||
{ ...opts, telemetryOptOut: true },
|
||||
1200,
|
||||
"encode",
|
||||
new Error("boom"),
|
||||
undefined,
|
||||
);
|
||||
expect(trackRenderComplete).not.toHaveBeenCalled();
|
||||
expect(trackRenderError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An older client sends no flag at all. That is not consent withdrawn, and
|
||||
// treating it as such would silently drop every pre-upgrade render.
|
||||
it.each([undefined, false])("still emits when telemetryOptOut is %s", (flag) => {
|
||||
emitStudioRenderComplete({ ...opts, telemetryOptOut: flag }, 5000, fullPerf);
|
||||
expect(trackRenderComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,17 @@ export interface StudioRenderOpts {
|
||||
// outcome joins their studio_session_start / studio_render_start events.
|
||||
// Undefined for older studio clients → falls back to the install anonymousId.
|
||||
distinctId?: string;
|
||||
/**
|
||||
* The browser profile that triggered this render has telemetry disabled.
|
||||
*
|
||||
* The CLI's own policy cannot see a localStorage opt-out or `DoNotTrack` in
|
||||
* someone else's browser, so without this the server happily emitted
|
||||
* render_complete / render_error for a user who had opted out — the events
|
||||
* merely landed on the install id instead of theirs, which is worse, not
|
||||
* better. Explicit `true` only: an old client sends nothing here, and that
|
||||
* is not consent withdrawn.
|
||||
*/
|
||||
telemetryOptOut?: boolean;
|
||||
}
|
||||
|
||||
type RenderCompleteProps = Parameters<typeof trackRenderComplete>[0];
|
||||
@@ -103,6 +114,7 @@ export function emitStudioRenderError(
|
||||
// user-supplied worker count (the producer picks its default), so on early
|
||||
// failures we genuinely don't know one. The CLI side has the value from
|
||||
// `options.workers` even before `job.perfSummary` exists; studio doesn't.
|
||||
if (opts.telemetryOptOut === true) return;
|
||||
trackRenderError({
|
||||
fps: fpsToNumber(opts.fps),
|
||||
quality: opts.quality,
|
||||
@@ -122,6 +134,7 @@ export function emitStudioRenderComplete(
|
||||
elapsedMs: number,
|
||||
perf: RenderPerfSummary | undefined,
|
||||
): void {
|
||||
if (opts.telemetryOptOut === true) return;
|
||||
trackRenderComplete({
|
||||
durationMs: elapsedMs,
|
||||
fps: fpsToNumber(opts.fps),
|
||||
|
||||
@@ -77,3 +77,46 @@ describe("createStudioServer autoProxy plumbing", () => {
|
||||
await expect(response.json()).resolves.toMatchObject({ browserGpuMode: "software" });
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
// 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() });
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
loadRuntimeSourceSignature,
|
||||
} from "./runtimeSource.js";
|
||||
import { VERSION as version } from "../version.js";
|
||||
import { buildStudioHeadScripts, resolveCliTelemetryDistinctId } from "./telemetryIdentity.js";
|
||||
import {
|
||||
buildStudioHeadScriptsForHost,
|
||||
identityAllowed,
|
||||
refreshTelemetryPosture,
|
||||
resolveCliTelemetryDistinctId,
|
||||
} from "./telemetryIdentity.js";
|
||||
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import {
|
||||
@@ -424,6 +429,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
rendersDir: () => join(projectDir, "renders"),
|
||||
|
||||
startRender(opts): RenderJobState {
|
||||
// The render POST is a request boundary like any other. Without this an
|
||||
// already-open Studio tab keeps rendering under the posture cached when
|
||||
// the server booted.
|
||||
refreshTelemetryPosture();
|
||||
const abortController = new AbortController();
|
||||
const state: RenderJobState = {
|
||||
id: opts.jobId,
|
||||
@@ -503,6 +512,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
metaPath,
|
||||
JSON.stringify({ status: "complete", durationMs: Date.now() - startTime }),
|
||||
);
|
||||
// Refreshed HERE, not just at render start: a render can run for
|
||||
// minutes, and `hyperframes telemetry disable` during one must be
|
||||
// honoured by the event that reports it. Studio never polls
|
||||
// /api/telemetry-identity, so this process would otherwise keep its
|
||||
// startup-cached posture for the life of the preview server.
|
||||
refreshTelemetryPosture();
|
||||
emitStudioRenderComplete(opts, Date.now() - startTime, job.perfSummary);
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -512,6 +527,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
state.status = "failed";
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
// fallow-ignore-next-line code-duplication
|
||||
refreshTelemetryPosture();
|
||||
emitStudioRenderError(opts, Date.now() - startTime, state.stage, err, renderJob);
|
||||
try {
|
||||
const metaPath = opts.outputPath.replace(/\.(mp4|webm|mov)$/, ".meta.json");
|
||||
@@ -692,7 +708,26 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// clients that can't rely on the injected global. Returns the CLI's anonymous
|
||||
// distinct id (no PII) so the browser session can join the CLI's PostHog
|
||||
// person, or `{ distinctId: null }` when CLI telemetry is disabled.
|
||||
//
|
||||
// Deliberately does NOT serve `bucketSeed`. Studio gets its canary answers
|
||||
// from the injected `window.__HF_CLI_CANARY_DECISIONS` (booleans, not the
|
||||
// value cohorts derive from), so nothing needs the seed over HTTP — and an
|
||||
// unauthenticated local endpoint is a strictly worse place for it than an
|
||||
// inline script scoped to Studio's own document.
|
||||
//
|
||||
// Host-guarded against DNS rebinding: a remote page can point a hostname it
|
||||
// controls at 127.0.0.1 and read this response as same-origin. Pinning the
|
||||
// Host header to a loopback name means such a request (which carries the
|
||||
// attacker's hostname) is refused. Same-origin Studio traffic always
|
||||
// presents the bound loopback host.
|
||||
app.get("/api/telemetry-identity", (c) => {
|
||||
if (!identityAllowed(c.req.header("host"))) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
// Same request-boundary refresh the head-script route does: this endpoint
|
||||
// is polled by a long-lived Studio tab, so a cached posture here outlives
|
||||
// an opt-out run in another terminal just as visibly.
|
||||
refreshTelemetryPosture();
|
||||
return c.json({ distinctId: resolveCliTelemetryDistinctId() });
|
||||
});
|
||||
|
||||
@@ -836,7 +871,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.
|
||||
//
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { hostname, networkInterfaces } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// CLI → Studio telemetry identity seeding (Layer 1). Verifies the server only
|
||||
// hands the browser a distinct id when CLI telemetry is enabled, and passes
|
||||
@@ -6,21 +7,45 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
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, { enabled: boolean; forced: boolean }>>();
|
||||
|
||||
// Every export the module under test imports must be mocked. Omitting
|
||||
// `resetTelemetryPostureCache` / `readConfigFresh` made `refreshTelemetryPosture`
|
||||
// throw a missing-export error that its own catch swallowed, so every
|
||||
// assertion below ran against a refresh that silently did nothing.
|
||||
const resetPostureCache = vi.fn();
|
||||
const readConfigFresh = vi.fn();
|
||||
|
||||
vi.mock("../telemetry/client.js", () => ({
|
||||
shouldTrack: (...args: unknown[]) => shouldTrack(...args),
|
||||
resetTelemetryPostureCache: () => resetPostureCache(),
|
||||
}));
|
||||
vi.mock("../telemetry/config.js", () => ({
|
||||
readConfig: (...args: unknown[]) => readConfig(...args),
|
||||
readConfigFresh: () => readConfigFresh(),
|
||||
}));
|
||||
vi.mock("../telemetry/canary.js", () => ({
|
||||
canaryDecisionsForStudio: () => canaryDecisions(),
|
||||
}));
|
||||
|
||||
const { resolveCliTelemetryDistinctId, buildCliIdentityScript, buildStudioHeadScripts } =
|
||||
await import("./telemetryIdentity.js");
|
||||
const {
|
||||
resolveCliTelemetryDistinctId,
|
||||
buildCliIdentityScript,
|
||||
buildStudioHeadScripts,
|
||||
isLoopbackHost,
|
||||
buildStudioHeadScriptsForHost,
|
||||
refreshTelemetryPosture,
|
||||
identityAllowed,
|
||||
} = await import("./telemetryIdentity.js");
|
||||
|
||||
describe("resolveCliTelemetryDistinctId", () => {
|
||||
beforeEach(() => {
|
||||
shouldTrack.mockReset();
|
||||
readConfig.mockReset();
|
||||
canaryDecisions.mockReset();
|
||||
canaryDecisions.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("returns the CLI anonymousId when telemetry is enabled", () => {
|
||||
@@ -56,6 +81,8 @@ describe("buildCliIdentityScript", () => {
|
||||
beforeEach(() => {
|
||||
shouldTrack.mockReset();
|
||||
readConfig.mockReset();
|
||||
canaryDecisions.mockReset();
|
||||
canaryDecisions.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("emits a script that sets window.__HF_CLI_DISTINCT_ID when telemetry is on", () => {
|
||||
@@ -66,11 +93,68 @@ describe("buildCliIdentityScript", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("emits an empty string when telemetry is disabled (nothing to seed)", () => {
|
||||
it("also seeds window.__HF_CLI_BUCKET_SEED when the config carries a bucket seed", () => {
|
||||
shouldTrack.mockReturnValue(true);
|
||||
readConfig.mockReturnValue({ anonymousId: "machine-uuid", bucketSeed: "seed-uuid" });
|
||||
expect(buildCliIdentityScript()).toBe(
|
||||
'<script>window.__HF_CLI_DISTINCT_ID="machine-uuid";window.__HF_CLI_BUCKET_SEED="seed-uuid";</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits an empty string when telemetry is off and there are no canaries", () => {
|
||||
shouldTrack.mockReturnValue(false);
|
||||
expect(buildCliIdentityScript()).toBe("");
|
||||
});
|
||||
|
||||
// The cross-surface fix: with telemetry off the CLI resolves every canary
|
||||
// to telemetry_opt_out, and Studio cannot see that from its own separate
|
||||
// localStorage flag. Publishing the DECISIONS (not the identity) is what
|
||||
// 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": { enabled: false, forced: false } });
|
||||
const script = buildCliIdentityScript();
|
||||
expect(script).toBe(
|
||||
"<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");
|
||||
});
|
||||
|
||||
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": { 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":{"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)": { enabled: true, forced: false },
|
||||
});
|
||||
const script = buildCliIdentityScript();
|
||||
expect(script).not.toContain("</script><script>alert(1)");
|
||||
expect(script).toContain("__HF_CLI_CANARY_DECISIONS");
|
||||
});
|
||||
|
||||
it("survives a throwing canary resolver — telemetry must never break preview", () => {
|
||||
shouldTrack.mockReturnValue(true);
|
||||
readConfig.mockReturnValue({ anonymousId: "machine-uuid" });
|
||||
canaryDecisions.mockImplementation(() => {
|
||||
throw new Error("registry blew up");
|
||||
});
|
||||
expect(buildCliIdentityScript()).toBe(
|
||||
'<script>window.__HF_CLI_DISTINCT_ID="machine-uuid";</script>',
|
||||
);
|
||||
});
|
||||
|
||||
it("JSON-encodes the id so it can't break out of the script literal", () => {
|
||||
shouldTrack.mockReturnValue(true);
|
||||
readConfig.mockReturnValue({ anonymousId: "</script><script>alert(1)" });
|
||||
@@ -85,6 +169,8 @@ describe("buildStudioHeadScripts", () => {
|
||||
beforeEach(() => {
|
||||
shouldTrack.mockReset();
|
||||
readConfig.mockReset();
|
||||
canaryDecisions.mockReset();
|
||||
canaryDecisions.mockReturnValue({});
|
||||
});
|
||||
|
||||
const ENV_SCRIPT = "<script>window.__HF_STUDIO_ENV__={};</script>";
|
||||
@@ -97,8 +183,213 @@ describe("buildStudioHeadScripts", () => {
|
||||
expect(head.indexOf("__HF_CLI_DISTINCT_ID")).toBeLessThan(head.indexOf("__HF_STUDIO_ENV__"));
|
||||
});
|
||||
|
||||
it("returns just the env script when identity is suppressed (telemetry off)", () => {
|
||||
it("returns just the env script when there is no identity and no canary", () => {
|
||||
shouldTrack.mockReturnValue(false);
|
||||
expect(buildStudioHeadScripts(ENV_SCRIPT)).toBe(ENV_SCRIPT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopbackHost (DNS-rebinding guard on the identity endpoint)", () => {
|
||||
it.each([
|
||||
"localhost",
|
||||
"localhost:5173",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:5173",
|
||||
"127.1.2.3",
|
||||
"[::1]",
|
||||
"[::1]:5173",
|
||||
"LOCALHOST:5173",
|
||||
])("accepts the loopback host %s", (host) => {
|
||||
expect(isLoopbackHost(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
// The rebinding case: an attacker hostname resolving to 127.0.0.1 still
|
||||
// arrives with ITS name in Host, which is what makes this catchable.
|
||||
"evil.example.com",
|
||||
"evil.example.com:5173",
|
||||
"127.0.0.1.evil.com",
|
||||
"notlocalhost",
|
||||
"localhost.evil.com",
|
||||
"192.168.1.10",
|
||||
"0.0.0.0",
|
||||
])("rejects %s", (host) => {
|
||||
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__");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Non-loopback names this machine answers to. Computed, not hardcoded: the
|
||||
* rule under test is "an address/name this host actually has", so a literal
|
||||
* like `192.168.1.10` would pass only by accident on one developer's laptop.
|
||||
*/
|
||||
function localHostCandidates(): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const entries of Object.values(networkInterfaces())) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (!entry.internal && entry.family === "IPv4") names.add(entry.address);
|
||||
}
|
||||
}
|
||||
const self = hostname().split(".")[0];
|
||||
if (self !== undefined && self !== "") names.add(`${self}.local`);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
describe("identityAllowed — loopback-bound vs explicitly LAN-bound", () => {
|
||||
const original = process.env["HYPERFRAMES_PREVIEW_HOST"];
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env["HYPERFRAMES_PREVIEW_HOST"];
|
||||
else process.env["HYPERFRAMES_PREVIEW_HOST"] = original;
|
||||
});
|
||||
|
||||
describe("loopback-bound (the default)", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env["HYPERFRAMES_PREVIEW_HOST"];
|
||||
});
|
||||
|
||||
it.each(["localhost:5173", "127.0.0.1", "[::1]:3000"])("allows %s", (host) => {
|
||||
expect(identityAllowed(host)).toBe(true);
|
||||
});
|
||||
|
||||
// A rebinding page cannot forge Host, so it arrives carrying its own name.
|
||||
it.each(["evil.example.com", "127.0.0.1.evil.com", "192.168.1.10:3000", undefined])(
|
||||
"refuses %s",
|
||||
(host) => {
|
||||
expect(identityAllowed(host)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("explicitly LAN-bound", () => {
|
||||
beforeEach(() => {
|
||||
process.env["HYPERFRAMES_PREVIEW_HOST"] = "0.0.0.0";
|
||||
});
|
||||
|
||||
// The mode this regressed: browsing your own LAN-exposed Studio lost the
|
||||
// CLI stitch entirely, so the same human became two PostHog persons. The
|
||||
// names come from this machine, because that is now the actual rule —
|
||||
// a hardcoded `192.168.1.10` asserted only that the check was absent.
|
||||
it.each(["0.0.0.0:3000", ...localHostCandidates().map((n) => `${n}:3000`)])(
|
||||
"allows %s once the operator opted into LAN exposure",
|
||||
(host) => {
|
||||
expect(identityAllowed(host)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
// Setting the env var opted into LAN exposure, NOT into handing identity
|
||||
// to whatever name a rebinding page invents. This is the hole: the old
|
||||
// rule returned true for every one of these.
|
||||
it.each(["evil.example.com", "127.0.0.1.evil.com", "attacker.test:3000", undefined])(
|
||||
"still refuses hostile Host %s",
|
||||
(host) => {
|
||||
expect(identityAllowed(host)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a LAN address this machine does not answer on", () => {
|
||||
expect(identityAllowed("203.0.113.7:3000")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A loopback bind exposes nothing, so the Host check stays a live rebinding
|
||||
// mitigation — previously ANY non-empty value disabled it wholesale.
|
||||
describe("bound to loopback explicitly", () => {
|
||||
beforeEach(() => {
|
||||
process.env["HYPERFRAMES_PREVIEW_HOST"] = "127.0.0.1";
|
||||
});
|
||||
|
||||
it.each(["localhost:5173", "127.0.0.1"])("still allows %s", (host) => {
|
||||
expect(identityAllowed(host)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["evil.example.com", "192.168.1.10:3000"])("still refuses %s", (host) => {
|
||||
expect(identityAllowed(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// A long-lived preview server: the posture it cached at boot must not outlive
|
||||
// an opt-out run in another terminal. Studio has no poller for
|
||||
// /api/telemetry-identity, so the refresh has to happen on the paths that
|
||||
// actually run — the SPA document and the render boundary.
|
||||
describe("cross-process opt-out refresh", () => {
|
||||
beforeEach(() => {
|
||||
resetPostureCache.mockClear();
|
||||
readConfigFresh.mockClear();
|
||||
});
|
||||
|
||||
it("actually invalidates both caches — the mocks used to swallow this", () => {
|
||||
refreshTelemetryPosture();
|
||||
expect(readConfigFresh).toHaveBeenCalledTimes(1);
|
||||
expect(resetPostureCache).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes before building a head script", () => {
|
||||
shouldTrack.mockReturnValue(true);
|
||||
readConfig.mockReturnValue({ anonymousId: "id-1", bucketSeed: "seed-1" });
|
||||
canaryDecisions.mockReturnValue({});
|
||||
buildStudioHeadScriptsForHost("", "localhost:3000");
|
||||
expect(resetPostureCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops publishing identity once another process disables telemetry", () => {
|
||||
canaryDecisions.mockReturnValue({});
|
||||
readConfig.mockReturnValue({ anonymousId: "id-1", bucketSeed: "seed-1" });
|
||||
|
||||
shouldTrack.mockReturnValue(true);
|
||||
expect(buildStudioHeadScriptsForHost("", "localhost:3000")).toContain("__HF_CLI_DISTINCT_ID");
|
||||
|
||||
// `hyperframes telemetry disable` in another terminal.
|
||||
shouldTrack.mockReturnValue(false);
|
||||
const after = buildStudioHeadScriptsForHost("", "localhost:3000");
|
||||
expect(after).not.toContain("__HF_CLI_DISTINCT_ID");
|
||||
expect(after).not.toContain("__HF_CLI_BUCKET_SEED");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
// server's heavy render dependencies (@hyperframes/producer, engine, …).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { readConfig } from "../telemetry/config.js";
|
||||
import { shouldTrack as telemetryShouldTrack } from "../telemetry/client.js";
|
||||
import { hostname, networkInterfaces } from "node:os";
|
||||
import { readConfig, readConfigFresh } from "../telemetry/config.js";
|
||||
import {
|
||||
resetTelemetryPostureCache,
|
||||
shouldTrack as telemetryShouldTrack,
|
||||
} from "../telemetry/client.js";
|
||||
import { canaryDecisionsForStudio, type CliCanaryDecision } from "../telemetry/canary.js";
|
||||
|
||||
/**
|
||||
* The CLI's anonymous distinct id to hand to Studio, or null when CLI telemetry
|
||||
@@ -35,21 +40,123 @@ export function resolveCliTelemetryDistinctId(): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* `<script>` tag to inject into the served index.html `<head>`, publishing the
|
||||
* CLI distinct id as `window.__HF_CLI_DISTINCT_ID` before the studio bundle
|
||||
* runs. Preferred over a URL param so the id never leaks into `$current_url` /
|
||||
* `url_hash` telemetry or browser history. Empty string when there's nothing to
|
||||
* seed (telemetry off / no id).
|
||||
* The CLI's canary bucket seed to hand to Studio, or null. Injected alongside
|
||||
* the distinct id so a CLI-launched Studio buckets canaries on the SAME unit
|
||||
* as the CLI — without it the two surfaces would agree only while the seed
|
||||
* still equals whatever Studio falls back to, and a rollout spanning render
|
||||
* and editor would split one user across cohorts. Same telemetry gate as the
|
||||
* distinct id: seeding is part of the identity stitch, not a separate channel.
|
||||
*/
|
||||
export function buildCliIdentityScript(): string {
|
||||
const cliId = resolveCliTelemetryDistinctId();
|
||||
if (!cliId) return "";
|
||||
// The id is a randomUUID() so this is belt-and-suspenders, but JSON.stringify
|
||||
// does not escape "<" or "/". Escaping both means no "</script>" (or "</…")
|
||||
// sequence can form in the emitted value, so it can never terminate the
|
||||
// inline <script> or open a new tag.
|
||||
const encoded = JSON.stringify(cliId).replace(/</g, "\\u003c").replace(/\//g, "\\/");
|
||||
return `<script>window.__HF_CLI_DISTINCT_ID=${encoded};</script>`;
|
||||
function resolveCliBucketSeed(): string | null {
|
||||
try {
|
||||
if (!telemetryShouldTrack()) return null;
|
||||
const seed = readConfig().bucketSeed;
|
||||
return typeof seed === "string" && seed.length > 0 ? seed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this request's `Host` a loopback name the studio server could have been
|
||||
* reached on directly?
|
||||
*
|
||||
* A bare `[::1]`/`localhost`/dotted-quad check rather than a full parse: the
|
||||
* port is irrelevant (any port on loopback is us), and anything exotic enough
|
||||
* to miss here should be refused rather than guessed at.
|
||||
*
|
||||
* Scope, stated precisely: this is a **DNS-rebinding mitigation for browsers**,
|
||||
* not access control. A browser sets `Host` from the URL it was given, so a
|
||||
* page that rebinds its own hostname to 127.0.0.1 arrives carrying that
|
||||
* hostname and is refused. A non-browser client sets `Host` to whatever it
|
||||
* likes, so this stops nothing there — but on a loopback-bound server such a
|
||||
* client is already local, and on a LAN-bound one it can read the project
|
||||
* files through the unauthenticated studio API anyway. See `identityAllowed`.
|
||||
*/
|
||||
export function isLoopbackHost(host: string | undefined): boolean {
|
||||
if (!host) return false;
|
||||
// Strip the port. IPv6 literals are bracketed (`[::1]:1234`), so take the
|
||||
// bracketed part when present and only split on ":" otherwise.
|
||||
const bracketed = /^\[([^\]]+)\]/.exec(host);
|
||||
const hostname = (bracketed ? bracketed[1] : host.split(":")[0])?.toLowerCase() ?? "";
|
||||
if (hostname === "localhost" || hostname === "::1" || hostname === "0:0:0:0:0:0:0:1") return true;
|
||||
// 127.0.0.0/8 — the whole loopback block, not just 127.0.0.1.
|
||||
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
||||
}
|
||||
|
||||
// JSON.stringify does not escape "<" or "/". Escaping both means no
|
||||
// "</script>" (or "</…") sequence can form in the emitted value, so it can
|
||||
// never terminate the inline <script> or open a new tag. (The values are
|
||||
// randomUUID()s, so this is belt-and-suspenders.)
|
||||
function encodeInlineScriptValue(value: string): string {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\//g, "\\/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Same escaping, for a structured value. Separate from the string form
|
||||
* because that one stringifies its argument — passing an object through it
|
||||
* would double-encode into a quoted JSON blob.
|
||||
*
|
||||
* The keys here are registry canary names, so they are developer-authored and
|
||||
* ASCII by convention rather than user input; the escaping is belt-and-braces
|
||||
* for the same reason it is on the ids.
|
||||
*/
|
||||
function encodeInlineScriptJson(value: unknown): string {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/\//g, "\\/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, CliCanaryDecision> | null {
|
||||
try {
|
||||
const decisions = canaryDecisionsForStudio();
|
||||
return Object.keys(decisions).length > 0 ? decisions : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `<script>` tag to inject into the served index.html `<head>`, publishing the
|
||||
* CLI distinct id as `window.__HF_CLI_DISTINCT_ID` (plus the bucket seed and
|
||||
* resolved canary decisions) before the studio bundle runs. Preferred over a
|
||||
* URL param so the id never leaks into `$current_url` / `url_hash` telemetry
|
||||
* or browser history. Empty string only when there is nothing at all to
|
||||
* publish.
|
||||
*/
|
||||
export function buildCliIdentityScript(options: { includeIdentity?: boolean } = {}): string {
|
||||
const { includeIdentity = true } = options;
|
||||
const parts: string[] = [];
|
||||
|
||||
// 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();
|
||||
if (seed) parts.push(`window.__HF_CLI_BUCKET_SEED=${encodeInlineScriptValue(seed)};`);
|
||||
}
|
||||
|
||||
// Emitted even when telemetry is OFF and the identity block above is empty —
|
||||
// that is the case it exists for. With telemetry off the CLI resolves every
|
||||
// canary to `telemetry_opt_out`, but Studio's opt-out is a separate
|
||||
// localStorage flag it cannot see, so left to itself Studio would evaluate
|
||||
// normally and could enrol on a render the CLI had already excluded. Same
|
||||
// for an `HF_CANARY_*` override, which never crosses into the browser.
|
||||
//
|
||||
// Safe to publish unconditionally: these are booleans about features, not
|
||||
// identity, and strictly less than the seed they replace as Studio's input.
|
||||
const decisions = resolveCliCanaryDecisions();
|
||||
if (decisions !== null) {
|
||||
parts.push(`window.__HF_CLI_CANARY_DECISIONS=${encodeInlineScriptJson(decisions)};`);
|
||||
}
|
||||
|
||||
return parts.length === 0 ? "" : `<script>${parts.join("")}</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +167,126 @@ 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.
|
||||
*/
|
||||
/** Hostname of a `Host` header, port and IPv6 brackets removed. */
|
||||
function hostnameOf(host: string | undefined): string {
|
||||
if (!host) return "";
|
||||
const bracketed = /^\[([^\]]+)\]/.exec(host);
|
||||
return (bracketed ? bracketed[1] : host.split(":")[0])?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Names this machine legitimately answers to on a wildcard bind: every
|
||||
* interface address, plus its own hostname and the `.local` mDNS form people
|
||||
* actually type. Fail closed — a throw yields an empty set, which denies.
|
||||
*/
|
||||
function localNames(): Set<string> {
|
||||
const names = new Set<string>();
|
||||
try {
|
||||
for (const entries of Object.values(networkInterfaces())) {
|
||||
for (const entry of entries ?? []) names.add(entry.address.toLowerCase());
|
||||
}
|
||||
const self = hostname().toLowerCase();
|
||||
if (self !== "") {
|
||||
names.add(self);
|
||||
// `my-box` is reachable as `my-box.local`, and a `my-box.lan.example`
|
||||
// FQDN is reachable by its short form. Register both directions.
|
||||
names.add(`${self}.local`);
|
||||
const short = self.split(".")[0];
|
||||
if (short !== undefined && short !== "") {
|
||||
names.add(short);
|
||||
names.add(`${short}.local`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fail closed */
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wildcard binds answer on every local name; a specific bind on itself only.
|
||||
*
|
||||
* This is the part that was missing: the previous rule accepted ANY Host once
|
||||
* the env var was set, so a rebinding page could name itself `evil.example.com`
|
||||
* and still be handed the CLI identity.
|
||||
*/
|
||||
function hostMatchesBind(host: string | undefined, bind: string): boolean {
|
||||
const requested = hostnameOf(host);
|
||||
if (requested === "") return false;
|
||||
const bound = bind.toLowerCase();
|
||||
if (requested === bound) return true;
|
||||
return bound === "0.0.0.0" || bound === "::" || bound === "*"
|
||||
? localNames().has(requested)
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this request receive the CLI's identity (distinct id + bucket seed)?
|
||||
*
|
||||
* The server binds loopback by DEFAULT and exposes the LAN only when an
|
||||
* operator sets `HYPERFRAMES_PREVIEW_HOST` (portUtils.ts, F-001). A loopback
|
||||
* `Host` is always fine: whatever reached us came via loopback, and the only
|
||||
* interesting attacker is a rebinding browser page — which the Host check
|
||||
* catches, because a browser cannot forge `Host`.
|
||||
*
|
||||
* For anything else the bind decides:
|
||||
*
|
||||
* - **Unset, or bound to loopback.** No LAN exposure was requested, so the
|
||||
* Host check is a live rebinding mitigation and a non-loopback Host is
|
||||
* refused. Previously ANY non-empty value disabled the check, so even
|
||||
* `HYPERFRAMES_PREVIEW_HOST=127.0.0.1` — which exposes nothing — turned the
|
||||
* loopback service from hostile-Host refusal into accept-everything.
|
||||
* - **Bound to a LAN address.** The operator opted into exposure, so identity
|
||||
* has to reach the LAN name the user browses or the CLI-to-Studio stitch
|
||||
* breaks and Studio mints a second person for the same human. But it is
|
||||
* still checked: the Host must name an address this machine actually
|
||||
* answers on, not any attacker-chosen name a rebinding page supplies.
|
||||
*/
|
||||
export function identityAllowed(host: string | undefined): boolean {
|
||||
if (isLoopbackHost(host)) return true;
|
||||
const bind = (process.env["HYPERFRAMES_PREVIEW_HOST"] ?? "").trim();
|
||||
if (bind === "" || isLoopbackHost(bind)) return false;
|
||||
return hostMatchesBind(host, bind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the persisted telemetry preference for this request.
|
||||
*
|
||||
* Both caches have to go, together. `readConfig()` memoizes the parsed config
|
||||
* and `shouldTrack()` memoizes its own boolean on top of it, so clearing
|
||||
* either alone still yields the stale answer: the canary layer asks
|
||||
* `readConfig()`, the identity layer asks `shouldTrack()`, and they would
|
||||
* disagree mid-refresh. A long-lived `hyperframes preview` otherwise keeps
|
||||
* serving pre-opt-out decisions and injecting the CLI id for hours after
|
||||
* `hyperframes telemetry disable` runs in another terminal.
|
||||
*
|
||||
* Fail-silent and once per request — a config re-read, not a hot path.
|
||||
*/
|
||||
export function refreshTelemetryPosture(): void {
|
||||
try {
|
||||
readConfigFresh();
|
||||
resetTelemetryPostureCache();
|
||||
} catch {
|
||||
/* telemetry must never break the preview server */
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStudioHeadScriptsForHost(envScript: string, host: string | undefined): string {
|
||||
refreshTelemetryPosture();
|
||||
return buildStudioHeadScripts(envScript, { includeIdentity: identityAllowed(host) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user