Spec · draft · for Robert’s red-pen

Form privacy check — guest list

A privacy check on every finished form was built as a list of banned outside servers — it only stops names someone hand-wrote onto it. Hamilton’s JotForm caught the gap this week. The fix: flip it to a list of invited servers, so an unknown leak becomes a loud wall instead of a silent ship — for every form tool, including ones we’ve never seen.

Draft for Robert’s annotation · trust-gate behavior change · your call before any build
00

What’s already true

Three facts that frame everything below — not up for debate, just the ground you’re standing on.

Origin
Hamilton’s JotForm caught it
The first cold, form-heavy site tripped the privacy check correctly. Digging into why it tripped is what exposed the deeper problem below.
Today’s mechanism
A list of banned guests
The check stops five specific outside servers it’s been told to watch for. Anyone not on that list walks straight in.
The fleet reality
JotForm is one of many
Gravity Forms, Contact Form 7, Formidable, WPForms, HubSpot, and more run across the sites we’ve captured — several more common than JotForm.
  1. Today, a form privacy guard runs on every finished site. When the factory rebuilds a practice site, it pulls each contact/appointment form fully in-house so the served form never phones home to an outside company. A check scans the finished form and fails the build if it still points at an outside server.
    ↘ go deeper — the spec the builder reads
    Per-form check: scripts/il-form-rewrite.tsvalidateServedHtml() + LEAK_PATTERNS, currently a 5-entry denylist at ~line 793. This is the gate to flip.

    Runtime allowlist gate: scripts/il-form-boundary-verify.tsALLOWED_HOSTS (line 18). This one is already a fail-closed allowlist — but its coverage is narrow: one hardcoded form URL, loaded once at networkidle, no interaction.
  2. The check works by luck, not by design. It stops the CAPTCHA host, two JotForm hosts, our own font host, and Google Fonts — five names someone hand-wrote onto the list. JotForm got caught this week only because someone had already written JotForm’s servers onto that list. The fleet is not one form tool. Most forms render on the practice’s own site (safe), but the outside-service kind — JotForm, HubSpot, and whatever else — are exactly JotForm’s situation. The first cold site that uses a form tool we haven’t hand-added to the list could ship a form that quietly talks to an outside server — and our check would say nothing. That is the opposite of what a privacy guarantee should do.
  3. The decision: flip it from a banned list to a guest list. The finished form may reach only the outside servers we’ve explicitly invited — the CAPTCHA it needs to work, our fonts, a payment processor if the form takes payment. Any other outside server = the build stops, loudly. The difference in one line: today, an unknown leak ships silently; after the flip, an unknown leak becomes a loud wall we catch every time — exactly like Hamilton’s JotForm did, but for every form tool, including ones we’ve never seen.
    The invariant: finished forms may reach only invited hosts; anything else fails the build.
    ↘ go deeper — the spec the builder reads
    Replace the denylist semantics of validateServedHtml with allowlist semantics:
    1. Extract every external host authority from the served form bytes — all https:// and protocol-relative // references across script/link/iframe/img/form-action/inline-url surfaces (reuse the hostname-anchored, entity-encoding-safe matcher the JotForm-family fix is introducing).
    2. For each external host: allowed iff it is (a) the site’s own build origin, or (b) on the explicit FORM_ALLOWED_HOSTS set. Anything else → a violation, build fails closed.
    3. FORM_ALLOWED_HOSTS is authored from the fleet census output — each entry carries a role + why-it-must-reach comment so the list stays auditable. Known must-reach so far: challenges.cloudflare.com (Turnstile CAPTCHA) plus the site’s own build origin. Everything else pends the census.
    Existing must-not-ship hard checks (submit/upload.jotform.com → intake) stay as-is; they become redundant-but-harmless once the allowlist subsumes them.

    A latent bug this dissolves: today LEAK_PATTERNS is asymmetric with the strip/localize logic — some hosts get stripped but aren’t in the validation list, so a leak of them ships silently. The allowlist flip dissolves this class structurally — validation no longer enumerates bad hosts, so there’s nothing to keep in sync.

    Pending: the fleet-form-carrier census (running now) returns allowlistCandidates[] — every external host any form legitimately reaches across the fleet, tagged allow-must-reach / localize / strip-block. The allow-must-reach set becomes FORM_ALLOWED_HOSTS; this section fills in when the census returns.
  4. Why this is the right shape. It’s fail-safe by design, not by luck — we stop maintaining an ever-growing list of banned servers and instead maintain a small, stable list of the handful we trust. It protects carriers we haven’t met yet: the next cold site on Typeform or Calendly can’t slip a leak past us — worst case it’s a clean wall, never a silent ship. And it matches how the page-level check already works elsewhere in the pipeline (that one is already a guest list) — this makes the form check consistent with it.
  5. Your calls — the red-pen targets. Four decisions need your word before any build starts. The first is the one that matters.
    1
    Approve the flip in principle?
    Banned-list → guest-list for form privacy. This is the one that matters — everything else below is detail once this is decided.
    2
    Roll out in shadow first?
    My strong recommendation: run the new guest-list in “watch-only” mode for a short while — it reports what it would have blocked without failing any build — so we discover every legitimate guest (a CAPTCHA variant, a payment host) before it can block a real practice’s form. Then flip to enforcing. Yes/no, and how long.
    3
    One guest list for all sites, or per-tool?
    A single trusted list is simpler and safer to reason about; a per-tool list is more precise but more to maintain. My lean: one list, with each site’s own domain always trusted automatically.
    4
    Who owns adding a new guest?
    When a legitimate new host appears (a new CAPTCHA, a payment processor), who approves adding it — and is that a code change with review, or a config entry?
  6. What could go wrong — and the guard for each. The combination is powerful but has real failure modes. We design for these, not around them.
    We forget to invite a guest a real form needs
    This is the real risk — we break a working form. The guard: shadow mode (call 2). The census running right now produces the complete guest list from real forms across the fleet before we enforce anything; watch-only mode catches any we missed without hurting a live build.
    We over-strip and break the CAPTCHA
    The appointment forms use a real Cloudflare CAPTCHA that must reach Cloudflare to work. It’s an explicit guest; the check must never touch it.
    A site legitimately reaches its own domain
    The guest list always includes the practice’s own site automatically — that’s never an outside server.
  7. The second gate needs broadening too. There’s already a runtime check that loads a form and watches what it actually reaches — its philosophy is right, its exercise is too narrow. It only checks one hardcoded form URL, loaded once, with no interaction.
    ↘ go deeper — the spec the builder reads
    Broaden il-form-boundary-verify.ts’s ALLOWED_HOSTS:
    • Iterate every served form, not one hardcoded URL.
    • Drive at least one interaction path (focus a field, trigger validation, attempt submit) so hosts that only load on interaction (dynamic widget chunks, payment iframes) actually fire under observation.
    • Keep ALLOWED_HOSTS and FORM_ALLOWED_HOSTS as one shared source so the static check and the runtime check can never disagree.
  8. Rollout: shadow, then enforce. The guard against breaking a real form is sequencing — watch before we block.
    1. Shadow. Land the allowlist logic behind a watch-only flag that logs every host it would reject, but doesn’t fail the build. Run it across a batch of already-captured sites; collect the would-block set.
    2. Reconcile. Every would-block host gets triaged — a real leak (good, keep blocking) or a missed guest (add it to the trusted list with its role). Iterate until the would-block set is all real leaks.
    3. Enforce. Remove the shadow flag; the gate fails closed. Default state going forward.

    A separate, prevalence-ordered backlog: outside-service form tools with no in-house rebuild path yet (only JotForm has one today). This spec doesn’t build those — it makes them fail loudly instead of shipping silently. Their rebuild paths are future, prioritized work.

  9. How we’ll know it worked. Every cold site with any form tool either certifies clean or stops at a loud, named privacy wall — never ships a form that talks to an uninvited server. The watch-only period ends with zero surprise guests: the census’s list matched reality. Hamilton, and a re-run of an already-certified site, both still pass — no regression.
    ↘ go deeper — the spec the builder reads
    Verification (definition of done):
    • Unit: a served-form fixture reaching an un-allowed host fails; one reaching only allowed hosts (own origin + Turnstile) passes; the site’s own origin is never flagged. Turnstile-preserved.
    • Shadow run across ≥N captured sites emits a would-block report; reconciled to zero missed guests.
    • Regression: Hamilton (post JotForm-family-fix) and one already-certified site both still pass.
    • No gate weakened; make gate / targeted form tests green; tsc clean.
    Rollback: the flip is one gate’s semantics behind a flag. Rollback = re-enable shadow mode (report-don’t-fail) or revert the branch. No data migration, no irreversible step.
Ref

Reference strip — by type

For the builder: jump straight to the machinery, grouped by kind.

Filesil-form-rewrite.ts (per-form check) · il-form-boundary-verify.ts (runtime gate)
GatesvalidateServedHtml allowlist flip · runtime ALLOWED_HOSTS broadening
Rollout stepsshadow · reconcile · enforce
Verification & rollbackdefinition of done + rollback