Skip to content

fix(publish): stop author bindings publishing the account's email address - #318

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/author-binding-and-display-names
Jul 31, 2026
Merged

fix(publish): stop author bindings publishing the account's email address#318
DavidBabinec merged 1 commit into
mainfrom
fix/author-binding-and-display-names

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

The leak

A template containing {currentEntry.author} published this into live HTML:

<p class="caption">Written by {"displayName":"owner@example.com","roleSlug":"owner","roleName":"Owner"}</p>

Found while reviewing a generated site — the address was in the page and in every screenshot taken of it. Three independent causes stack.

The interpolator serialised internals. tokenInterpolation.ts:283 JSON-stringified any non-scalar value, commented as keeping "mistakes visible". That is an author-facing debug affordance running unconditionally in the public publish path.

The frame put an object where an author would reach. loopPrefetch.ts:83 and dataRows.ts:168,315 overlaid the internal PublicDataUserReference into the same flat namespace as user cells, under the key author. A safe-field list already existed at dataRows.ts:500-516 — deliberately excluding author, including authorName — but walkFieldPath never consulted it, so the list was advertising for site_list_loop_sources and nothing more.

Display names were emails. setup.ts:73 and users.ts:288 both defaulted display_name to the account's email. That field is rendered publicly through author bindings, so the legitimate {currentEntry.authorName} leaked the address too. publicDataUser.ts documented itself as stripping email addresses; it strips the email field while displayName carries the value.

The fix

author and publishedBy are now the display name itself — what a binding to them was always meant to produce — and both are declared in the loop source's field list so they are discoverable instead of accidental.

A non-scalar binding renders as missing, honouring any author-supplied fallback, rather than dumping whatever internal shape sat behind the key. The frame cannot know which of its values are safe to print, so it prints none of them.

Display names are empty until somebody sets one. Setup accepts an optional displayName and the first onboarding screen now offers it — previously the account page was the only place to set it, after the fact. Migration 024 clears display names that are exactly the account's email, since otherwise no existing install benefits.

Admin surfaces are unchanged: UserAvatar.tsx:39, AccountMenuButton.tsx:57, ContentSettingsPanel.tsx:91, ActivityWidget.tsx:190 and ContentPage.tsx:144 already fall back to the email for their own display. That fallback is authenticated-only and stays.

Verification

Unit — authorBindingLeak.test.ts: {currentEntry.author} renders the display name; object and array bindings render empty and still honour a fallback; scalars untouched. setupDisplayName.test.ts: setup with, without, and with a whitespace-only name; createUser no longer falls back to the email.

Live — a fresh install now stores display_name = '' rather than the address. An existing instance carrying owner@masterclass.invalid in that field had it cleared by migration 024 on next boot, verified before and after against two real databases.

Suite: 6470 pass / 0 fail. Lint and tsc --noEmit clean.

React Doctor flags prefer-useReducer on AdminPreAuthForm — the new field takes it from 6 useState calls to 7. Those are independent form scalars plus two status flags; a reducer would not simplify them, and refactoring an auth form does not belong in a security fix.

Follow-ups (not in this PR)

Two further defects found in the same investigation, planned as separate PRs: entry routes serve the oldest published page's runtime manifest rather than their own (getLatestPublishedSiteSnapshot is order by created_at asc limit 1, and its runtime_assets_json rides along by accident); and content_set_document_status silently reassigns the editor's active document, which also discards a human's unsaved draft.

🤖 Generated with Claude Code

…ress

`{currentEntry.author}` rendered
`{"displayName":"owner@example.com","roleSlug":"owner","roleName":"Owner"}`
into live pages. Three causes stacked.

The token interpolator JSON-stringified any non-scalar "so mistakes stay
visible" — an author-facing debug affordance running in the public publish
path. The entry and loop frames overlaid the internal `PublicDataUserReference`
object into the same flat namespace as user cells, under the very key an author
would reach for. And both setup and `createUser` defaulted `display_name` — a
publicly rendered field — to the account's email address, so the legitimate
`authorName` field leaked it too.

`author` and `publishedBy` are now the display name itself, which is what a
binding to them was always meant to produce, and both are declared in the loop
source's field list so they are discoverable rather than accidental. A
non-scalar binding now renders as missing (honouring any author fallback)
instead of dumping whatever internal shape sat behind the key — the frame
cannot know which of its values are safe to print.

Display names are empty until somebody sets one. Setup takes an optional
`displayName` and the first onboarding screen offers it, since the account page
was previously the only place to set it. Migration 024 clears display names
that are exactly the account's email, because otherwise no existing install
benefits. Admin surfaces already fall back to the email for their own
authenticated display and are unchanged.

Test: `{currentEntry.author}` renders the display name; object and array
bindings render empty and still honour a fallback; scalars are untouched; setup
with and without a name; `createUser` no longer falls back to the email.

React Doctor flags `prefer-useReducer` on AdminPreAuthForm: the new field takes
it from 6 useState calls to 7. Those are independent form scalars plus two
status flags — a reducer would not simplify them, and refactoring an auth form
does not belong in a security fix.
@DavidBabinec
DavidBabinec merged commit 19b04b6 into main Jul 31, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant