Isotherm

The public key is not a secret, and the Origin check does less than I claimed

The site key ships in every tracked page's HTML. The Origin header is the only real gate, it stops browsers and not curl, and two of the comments I wrote around it are wrong.

Someone asked me where to store their Isotherm site key. They wanted to know if it belonged in .env, whether the deploy pipeline should inject it at build time, and whether the value should be rotated on a schedule. Reasonable questions. The answer is that the key is already in the HTML of every page they track, served to every visitor, and nothing they do in their build pipeline changes that:

<script async src="https://isotherm.app/isotherm.js" data-site="pk_live_xxx"></script>

That is the whole install. View source on any tracked page and there it is. A .env var that ends up rendered into a data- attribute is not a secret; it is a secret-shaped ritual around a public string. The pk_ prefix is meant to say this out loud, the same way Stripe's does.

The trouble with the ritual is not that it wastes ten minutes. It is that it teaches the customer the wrong model of where their security boundary is. If they believe the key is what protects them, they will protect the key, and they will not think hard about the thing that actually protects them.

What actually authorizes a write

The key answers "which site is this?" The Origin header answers "is this browser allowed to speak for that site?" Those are different questions and only the second one is a check.

In CollectController::__invoke, the key is pulled out of the JSON body — not a header, because the batch is posted as text/plain to stay a CORS simple request and skip the preflight, which means Laravel does not parse the body for us:

$key = $batch['k'] ?? '';
if (! is_string($key) || $key === '') {
    return $this->respond(['error' => 'missing site key'], 401, $origin);
}

$site = Site::configFor($key);

if (! $site || ! $site->isActive || ! $site->allowsOrigin($origin)) {
    return $this->respond(['error' => 'not authorized for this origin'], 403, $origin);
}

allowsOrigin lives on SiteConfig, a flat readonly value object rather than an Eloquent model, because it is read on every beacon and therefore cached, and a serialized Eloquent object does not reliably survive a round trip through a cache driver. The check itself is boring, which is the point:

public function allowsOrigin(?string $origin): bool
{
    if ($origin === null || $origin === '') {
        return false;
    }

    $host = parse_url($origin, PHP_URL_HOST);
    if (! is_string($host) || $host === '') {
        return false;
    }

    // Str::is handles the "*.acme.com" wildcard form as well as an exact host.
    return Str::is($this->domains, $host);
}

An absent or empty Origin is a rejection, not a pass. That matters: "no origin header" is the state a naive curl request arrives in, and it is also what you get if you write the null check the lazy way and let Str::is decide.

The property this buys is narrow and worth stating precisely. A browser sets Origin on cross-origin requests and script running in a page cannot change it. So if someone lifts pk_live_xxx out of Acme's page source and drops it into their own site's HTML, their visitors' beacons arrive with Origin: https://evil.example, which is not in Acme's allowlist, and they get a 403. The stolen key is useless anywhere except on Acme's own domains — where its owner already wanted it to work.

The limit, said plainly

This stops browser-based abuse. It does not stop curl. Nothing shipped inside a public page can, because "shipped inside a public page" and "unforgeable" are contradictory requirements. Origin is a header set by a user agent that chose to obey the spec, and an attacker running an HTTP client is not obliged to be a browser. They can send Origin: https://app.acme.com with the stolen key and the ingest endpoint will happily accept the events.

I would rather write that sentence than pretend otherwise, because the moment you claim Origin is an authentication mechanism, someone builds something on top of that claim. Which is exactly what I did, so let me be specific about what the damage actually is.

The easy half is fake clicks. Someone can inject click and scroll events into a heatmap they do not own. That is vandalism of an analytics dataset, not exfiltration, and it is annoying rather than dangerous.

The half I underrated is SnapshotController. It carries the same gate — the identical ! $site || ! $site->isActive || ! $site->allowsOrigin($origin) line — and it accepts up to 3 MB of HTML which becomes the frozen page the dashboard renders heatmaps on top of. There is a check that the page and the layout version already exist and that the version does not already have a snapshot, so an attacker cannot invent a version or overwrite a good snapshot. But the fingerprint is computed by the SDK from the page's own DOM: anyone who can load the customer's page can read it out of their own beacon. So a curl attacker with a scraped key can hold a real fingerprint for a version that has not been snapshotted yet and post their own HTML into it.

That is not fake coordinates. That is attacker-authored content stored under the customer's site and later rendered to the logged-in owner as though it were their page. Whether the dashboard's iframe sandboxing holds is a separate question I have not audited to my own satisfaction, and "the sandbox will catch it" is exactly the wrong thing to be depending on. The honest summary is: the residual risk of a leaked key is not only volume. It is volume on /collect, and a stored-content-injection race on /snapshot. The latter is the one that needs a real fix — binding the upload to the election lock rather than merely to "no snapshot yet" — and it is on the list.

For volume, the rate limiter. From AppServiceProvider::boot:

RateLimiter::for('collect', function (Request $request) {
    return Limit::perMinute(120)->by($request->ip().'|'.$this->siteKey($request));
});

Keyed by IP and site key, both. Per-IP alone means one office behind a NAT can lock out a whole customer. Per-site alone means one noisy site eats another's budget. 120/min is deliberately loose. The comment above it says the SDK flushes every 5 seconds, so twelve batches a minute, and that is what I believed until I read the SDK instead of my own comment: flushInterval is 5000 ms, but the batcher also flushes the moment the queue hits maxBatchSize: 60. A visitor moving the mouse across a busy page blows through 60 events in well under five seconds. Twelve a minute is a floor, not a count, and the real headroom under 120 is smaller than the comment implies. It has not bitten yet. It is still the wrong reason to feel safe, and a heatmap that silently drops real traffic is worse than no heatmap, because it still renders and it still looks like data.

The snapshot endpoint gets 5/min on the same key, because a snapshot is multi-megabyte and we only ever elect one visitor per layout version to send one. A legitimate browser sends a handful in its lifetime.

The road not taken: a signed request

The obvious "real" fix is an HMAC. Give each site a secret, sign the batch, verify server-side. This works, and it is what you do for a server-to-server integration.

It does not work here for a reason that has nothing to do with effort: to sign in the browser you must ship the signing key to the browser. You have moved the secret from data-site into a JS variable and gained an extra hop for whoever wants to steal it. Every scheme in this family — obfuscation, a key derived at runtime, a short-lived token minted by an endpoint that anyone can call — reduces to the same thing. Client-side secrets are not secrets. They are secrets with extra steps and a false sense of having done something.

The version that genuinely works is a customer-side proxy: your server signs, the browser posts to you, you forward. That is a real security boundary and I would happily document it for someone who needs it. But it turns a one-line paste into an infrastructure project, for a product whose entire value proposition is that you add one <script> tag to layouts/app.blade.php and are done. For click coordinates, that trade is wrong. For the snapshot upload it is closer than I would like, which is another way of saying the snapshot fix above is not optional.

The vague error does less than the comment says

Look at the guard again, and at the comment I wrote over it:

// One deliberately vague answer for "no such key", "site disabled" and "wrong
// origin". Distinguishing them would let anyone holding a stolen key enumerate
// which domains it is valid for.
if (! $site || ! $site->isActive || ! $site->allowsOrigin($origin)) {
    return $this->respond(['error' => 'not authorized for this origin'], 403, $origin);
}

The comment is wrong, and it took someone reading the controller cold to tell me so. Collapsing the three failures into one 403 hides which failure occurred. It does not hide allowlist membership, because the success case is loudly different from the failure case: a wrong origin gets a 403, an allowed origin gets a 202 with a JSON body. An attacker holding a valid, active key does not need the errors to be distinguishable. They probe Origin: https://staging.acme.com and read 403-versus-202. The oracle I congratulated myself on closing is wide open and always was, and merging three error paths could never have closed it.

So what does the merge actually buy? It hides invalid-key from disabled-site from wrong-origin. That is worth something small — a scraped key list can't be triaged against the endpoint — and it costs a customer one confusing afternoon on first install. I am keeping it, because the debugging cost is genuinely low and the dashboard tells you which of the three you tripped, behind a login. But it is a minor mitigation and I was selling it as a load-bearing one. If domain enumeration matters to you, the fix is not a vaguer error, it is not being able to speak for the site at all without a browser, and I have already said why we can't have that.

While I am correcting comments: this one, on respond(), is also false.

/**
 * CORS is echoed back per-request rather than set to "*", because by the time we
 * answer, the origin has already been checked against this site's allowlist.
 */

It has not. respond() sets Access-Control-Allow-Origin: $origin whenever an Origin header is present, which includes the 400 malformed-body, 401 missing-key and 413 too-large paths — where no allowlist check has run — and the 403 path, where one ran and failed. preflight() reflects any origin at all with no validation whatsoever. The reflection is fine in practice, but for a different reason than the one written down: the endpoint is public, it returns no credentialed or per-user data, and the SDK sends credentials: 'omit', so an attacker's page reading the response learns nothing it could not learn by posting from curl.

"Nothing" is very slightly too strong, and I would rather be precise than tidy. The 202 body carries snapshot, which comes from electSnapshotter, so it does reveal whether a given layout version already has a snapshot or currently has an election lock held. That is not worth defending, but it is not nothing, and the old draft of this post claimed the response "is not an oracle for anything." Absolutes are how you end up with comments like the two above.

Answering a rejected origin with a well-formed CORS 403 rather than a network-level failure is still right: the SDK sees a status code it can log instead of an opaque TypeError: Failed to fetch, which is the single most useless thing a browser can tell you.

What to actually tell customers

Don't tell them to hide the key. Tell them the allowlist is the security boundary, that *.acme.com is a wildcard and they should think about whether they mean it, and that rotating a key is for when they want to stop an old snippet from reporting — not because the key leaked, since the key was never not leaked.

That is what SiteKey::markSeen() exists for, throttled to one write a minute on the hottest path in the system: so an owner deciding whether to revoke an old key can see whether anything is still using it, instead of guessing and going blind. That is a real operational need. Guarding a string that ships in the HTML is not.

And check your comments against your code before you build an argument on them. Two of mine described a system I would have preferred to have written.

Related