mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
fix(cli,core,studio): close 15 review findings + 2 R5 blockers
R5 blockers
- Negative install-state latch was cached for the process lifetime, but
only `true` is monotonic across processes. A long-lived preview server
held a stale `false` and could re-enrol after another process tripped
the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
{ok:true, mirrored:false} to success, so a run that mirrored nothing
reported done with the latch only on the erasable store. It consumes
writeConfigWithResult and retries until both stores carry it.
Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
"anonymous": computed against the shipped hash, 100% of them were
enrolled in calibration-50 rather than 50%, and they merged into one
PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
write-once authoritative, but readConfig took config.json's blindly, so
the stores could hold different seeds until a re-mint flipped every
cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
re-rolling the seed on every command, and the "cohorts will not be
stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
forever — poisoning the exact metric this work exists to produce.
Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
disable` during a running preview server was ignored for hours while
the server kept serving pre-opt-out decisions. The memo is keyed on the
telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
argument, minting and PERSISTING a tracking id for an opted-out profile
— a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
for installs that opted out of telemetry entirely.
Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
stitching and split one user across two PostHog persons. Identity is
now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
non-browser client sets Host freely. It is a browser DNS-rebinding
mitigation, not access control, and now says so.
Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
the fast path, so the registry's "delete the entry at 100" step was an
unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
core suite would go red on 2026-09-15 for every unrelated PR; and `>`
against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
enough to fail ~1 run in 200. Seeded.
Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.
Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5e2a9432f1
commit
3f69a2c635
@@ -18,7 +18,7 @@ import {
|
||||
import { VERSION as version } from "../version.js";
|
||||
import {
|
||||
buildStudioHeadScriptsForHost,
|
||||
isLoopbackHost,
|
||||
identityAllowed,
|
||||
resolveCliTelemetryDistinctId,
|
||||
} from "./telemetryIdentity.js";
|
||||
import { emitStudioRenderComplete, emitStudioRenderError } from "./studioRenderTelemetry.js";
|
||||
@@ -668,7 +668,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
// attacker's hostname) is refused. Same-origin Studio traffic always
|
||||
// presents the bound loopback host.
|
||||
app.get("/api/telemetry-identity", (c) => {
|
||||
if (!isLoopbackHost(c.req.header("host"))) {
|
||||
if (!identityAllowed(c.req.header("host"))) {
|
||||
return c.json({ error: "forbidden" }, 403);
|
||||
}
|
||||
return c.json({ distinctId: resolveCliTelemetryDistinctId() });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
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
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
buildStudioHeadScripts,
|
||||
isLoopbackHost,
|
||||
buildStudioHeadScriptsForHost,
|
||||
identityAllowed,
|
||||
} = await import("./telemetryIdentity.js");
|
||||
|
||||
describe("resolveCliTelemetryDistinctId", () => {
|
||||
@@ -251,3 +252,49 @@ describe("buildStudioHeadScriptsForHost — Host split", () => {
|
||||
expect(buildStudioHeadScriptsForHost(ENV, "localhost")).toContain("__HF_STUDIO_ENV__");
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
it.each(["0.0.0.0:3000", "192.168.1.10:3000", "my-dev-box.local:3000"])(
|
||||
"allows %s once the operator opted into LAN exposure",
|
||||
(host) => {
|
||||
expect(identityAllowed(host)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("still allows loopback in that mode", () => {
|
||||
expect(identityAllowed("localhost:3000")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,14 +57,17 @@ function resolveCliBucketSeed(): string | null {
|
||||
* Is this request's `Host` a loopback name the studio server could have been
|
||||
* reached on directly?
|
||||
*
|
||||
* Guards the identity endpoint against DNS rebinding: an attacker-controlled
|
||||
* page can resolve its own hostname to 127.0.0.1 and read the response as
|
||||
* same-origin, but the request still carries THAT hostname in `Host`. Genuine
|
||||
* same-origin Studio traffic always presents the bound loopback host.
|
||||
*
|
||||
* 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;
|
||||
@@ -175,6 +178,28 @@ export function buildStudioHeadScripts(
|
||||
* injection branch when `packages/studio/dist` happens to be built, which is
|
||||
* true locally and false in the CI test lane.
|
||||
*/
|
||||
/**
|
||||
* May this request receive the CLI's identity (distinct id + bucket seed)?
|
||||
*
|
||||
* Two regimes, because the server binds loopback by DEFAULT and exposes the
|
||||
* LAN only when an operator sets `HYPERFRAMES_PREVIEW_HOST` (portUtils.ts,
|
||||
* F-001):
|
||||
*
|
||||
* - **Loopback-bound (default).** Anything reaching us came via loopback, so
|
||||
* the only interesting attacker is a rebinding browser page — which the
|
||||
* Host check catches, because a browser cannot forge `Host`.
|
||||
* - **Explicitly LAN-bound.** The operator opted into exposing this server,
|
||||
* and the Host header is trivially forgeable by any non-browser client, so
|
||||
* the check buys nothing. Withholding identity there only broke the
|
||||
* CLI-to-Studio stitch for the supported mode: the user browses
|
||||
* `http://0.0.0.0:3000` or the machine's LAN IP, `isLoopbackHost` says no,
|
||||
* and Studio mints a second anonymous person for the same human.
|
||||
*/
|
||||
export function identityAllowed(host: string | undefined): boolean {
|
||||
const lanBound = (process.env["HYPERFRAMES_PREVIEW_HOST"] ?? "").trim() !== "";
|
||||
return lanBound || isLoopbackHost(host);
|
||||
}
|
||||
|
||||
export function buildStudioHeadScriptsForHost(envScript: string, host: string | undefined): string {
|
||||
return buildStudioHeadScripts(envScript, { includeIdentity: isLoopbackHost(host) });
|
||||
return buildStudioHeadScripts(envScript, { includeIdentity: identityAllowed(host) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user