How We Made the Branding Preview Render the Real Page
Until last week, customising your branding in SendRec meant guessing. You picked colours, saved, opened a shared link in a private window, and looked at what a viewer actually got. If it was wrong, you went back and guessed again.
There was a preview on the settings page, but it was a lie. It drew four colour swatches in a box. No logo, no footer, no custom CSS, no player, no comment thread. It could tell you that your accent colour was green. It could not tell you that your green looked terrible next to the seek bar, which is the thing you actually wanted to know.
The worst part is structural. A mock of a page drifts from the page. Every time we changed the watch page, the mock stayed where it was, and nobody noticed until a user asked why their preview and their shared link disagreed.
So we deleted it and made the preview the page.
The shape of the thing
The settings form POSTs the values it currently holds. The server validates them the same way saving does, stores them under a random id, and returns a URL. The form drops that URL into an iframe. The GET renders the real watch page template with the real branding resolution, the same code path a viewer hits.
The preview cannot drift, because there is nothing to drift from. It is the same template.
Why not just put the values in the URL
The obvious design is a GET that takes branding in the query string. No storage, no indirection, one request instead of two.
It is also a phishing kit. That URL would be reachable by anyone, and it renders an arbitrary company name, an arbitrary footer, and arbitrary CSS on your own domain. Anyone could dress a page at app.sendrec.eu to look like any company they liked and mail the link around. The domain is the whole point of the attack. We would be hosting it.
Minting the id behind an authenticated POST kills that. A stranger cannot craft an id, only receive one, and only by already having an account.
Why the URL itself has no auth
This part surprised me. The preview URL is unauthenticated, which looks careless next to the paragraph above.
An iframe cannot send an Authorization header. We could have used srcdoc and handed the HTML over directly, except the watch page has an inline <style> block carrying a CSP nonce, and a srcdoc document inherits the parent’s CSP without inheriting a usable nonce. The styles get blocked and the preview renders naked.
A same-origin URL gets its own nonce from the security middleware we already run, which is exactly what the template expects. So the id is the credential. It is 32 random bytes, it expires in ten minutes, and it grants access to nothing except a rendering of values the requester typed a moment ago.
The bug that only appears on the second server
Here is the part worth the post.
The first implementation held previews in a map in memory, guarded by a mutex, swept on write. It worked. Every test passed. It would have worked in production too, for exactly as long as we ran one replica.
The POST and the GET are separate HTTP requests. Nothing routes them to the same process. Behind two replicas and a round-robin load balancer, the iframe asks the wrong instance about half the time and gets a 404. Restart the binary in the gap between the two requests and you get the same result on a single replica.
This is a boring bug with a boring fix, and it is very easy to ship. The feature works perfectly on your laptop. It works in CI. It works in staging if staging runs one pod. It breaks in production under load, intermittently, in a way that looks like a flaky frontend.
The fix is a table:
CREATE TABLE branding_previews (
id TEXT PRIMARY KEY,
branding JSONB NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
Expired rows get swept whenever a new preview is written, which is enough housekeeping for something that lives for minutes:
WITH swept AS (DELETE FROM branding_previews WHERE expires_at < now())
INSERT INTO branding_previews (id, branding, expires_at)
VALUES ($1, $2, now() + make_interval(secs => $3))
Note the now() in the INSERT. The first version computed the expiry in Go and sent a timestamp. That means one clock writes the deadline and a different clock judges it. If the application host drifts a few minutes ahead of the database, previews outlive their window. If it drifts behind, they arrive already expired and the iframe shows an error the instant you click the button. Letting Postgres set the timestamp it will later compare removes the question.
One id, or many
The first version deleted the row on read. One id, one render, which sounds responsibly paranoid.
It also means reloading the iframe shows a 404. So does hitting Back. So does React remounting the component for its own reasons. The operator sees a broken frame and has no idea why, because from where they sit nothing happened.
We changed it to render as often as the frame asks until the row expires. The id is unguessable and short-lived, and the content is the operator’s own unsaved branding. Refusing the second read bought nothing and cost a working feature. When the row is gone we render a small styled page that says the preview expired and to press refresh, rather than the web server’s default 404, which inside an iframe reads as a broken product.
Standing in for things that are not there
The watch page expects a video, a share token, and a comment thread. A preview has none of them.
The first version handed the template empty strings and hoped. The page then did what you would expect. It rendered <source src="">, so the browser fired a media error and showed a dead player. Its comment script fetched /api/watch//comments, got a 404, and left the thread blank instead of showing the empty state a real viewer sees. If the operator happened to have an auth token in storage, it went along for the ride on that bogus request.
Now the template knows when it is a preview. It leaves the <source> out rather than pointing at nothing. It renders a sample thread client-side so the comment styling is visible without asking the API for a token it does not have. It places the seek markers against the duration the page declares, because a preview has no media to read a duration from and every marker otherwise collapses onto the end of the bar.
It also reads the account’s plan from the stored row. Below a paid plan the watch page carries a SendRec footer the operator cannot style away, and a preview that quietly assumed otherwise would be showing them somebody else’s page.
The hole we found on the way
While reviewing the validation, we noticed the branding payload accepts a logoKey, and resolving branding presigns a download URL for whatever that key names.
Nothing checked that the key belonged to you. You could submit any object key in the bucket and get back a page containing a working, time-limited URL for it. No write, nothing in an audit log, just a rendered page with someone else’s object in an <img> tag.
The preview did not introduce this. Saving had the same hole and had had it for months. The preview made it cheaper to exploit, which is how we found it.
Logo keys are deterministic. They are minted from the account or workspace id, so the fix is to reject anything that is not the key this scope could have uploaded, and to read that key shape from one function so minting and validating cannot drift apart.
What I would take from this
The general lesson is not about previews. It is that “works on my machine” has a quieter cousin: works in one process. Any feature that spans two requests and keeps something in between needs to answer where that something lives, and “memory” is only an answer if you will never run a second replica. That is a promise about your infrastructure that most code has no business making.
The whole change is in PR #246 if you want to read it, migration and all.