fix(cli,studio,core): close five R5 telemetry and canary findings

- A long-lived preview cached its telemetry posture in two places
  (readConfig and shouldTrack). Running `telemetry disable` in another
  terminal left it resolving canaries and injecting the CLI id for hours.
  Both caches are now dropped together at a request boundary.
- Studio minted and shipped a telemetry id for every render regardless of
  the browser profile's opt-out, and the server emitted the outcome under
  CLI policy, which cannot see localStorage or DNT. The browser now sends
  an explicit telemetryOptOut, distinct from an old client's omission.
- Any non-empty HYPERFRAMES_PREVIEW_HOST disabled the DNS-rebinding guard,
  so even a loopback bind accepted a hostile Host. The guard now holds for
  loopback binds and, on a LAN bind, admits only names this machine
  answers on.
- sunsetAfter had no reader of the current date. A scheduled workflow runs
  scripts/check-canary-sunset.ts weekly, so a failure lands on the
  rollout's owner rather than on an unrelated PR author.
- The install-state seed memo outlived `rm -rf ~/.hyperframes`,
  resurrecting a cleared cohort. Removed; it only saved a read on a
  readConfig cache miss.

Docs updated for the Host rule and the 100% exclusion carve-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-01 17:21:41 -07:00
co-authored by Claude Opus 5
parent 3f69a2c635
commit 6f0df2640b
16 changed files with 504 additions and 47 deletions
@@ -1,3 +1,4 @@
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
@@ -253,6 +254,23 @@ describe("buildStudioHeadScriptsForHost — Host split", () => {
});
});
/**
* 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"];
@@ -285,16 +303,44 @@ describe("identityAllowed — loopback-bound vs explicitly LAN-bound", () => {
});
// 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"])(
// 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);
},
);
it("still allows loopback in that mode", () => {
expect(identityAllowed("localhost:3000")).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);
});
});
});