Form submissions (FormGuard)#
WebSite.pushForm(form, locale, data, {path?, origin?}) forwards one public
form submission to the core API (/public/form) through the site's SDK
session, so the store token never reaches the browser. Content is
validated against the form's contract: the kind:'form' schema field
carries the structure ({key, kind:'variable', input} entries), and each
variable's constraints/strings live in the placing document's per-locale
value as a FormVariableValue descriptor ({type, label?, required?, pattern?, pattern_message?, max_length?, …}). The core resolves the two into
the document's _f stamp (form slug → contract) on every save, and both
this package and the core's re-validation read the contract from there — no
schema read on a render server (a doc not stamped yet gets no local
pre-validation; the core still validates it). Pass the submitting page's
path (e.g. the Referer pathname) as options.path and pushForm validates
locally first — against the very descriptors the visitor's page was
rendered from, so rejections carry the doc's localized pattern_message
and cost no core round-trip. A local rejection throws FormValidationError
(map it to a 400). When the path resolves, the placing document's ref is
forwarded as the push's source, so the core API re-validates against
the same contract; a missing/unresolvable path forwards without a source
(the core then applies only its baseline caps — object shape + 32 KB).
Pass the browser's origin (the proxy request's Origin header) as
options.origin — the core checks it against the form doc's origins
allowlist (sdk-js forms.setOrigins; exact entries or one-label
wildcards https://*.example.com; empty = unrestricted, mismatch = 401)
since server-to-server calls carry no browser Origin of their own. A form
doc with require_source: true refuses a push that arrives without a
resolvable page (400 "Source required").
Uploads (input: image | file | files variables, .dev/plans/forms-v2.md):
the built-in POST /form/{locale}/{form}/upload (multipart — a file part
- the text field
key) runs the same guard chain, thenWebSite.pushFormUploadresolves the page's contract (the variable must be a file kind; itsaccept/max_sizeapply) and forwards the file to the core's/public/form/uploadthrough the site's SDK session. The file is stored PRIVATE (no URL); the answer{upload, name, size, content_type}is what the page puts under the variable key in its laterpushForm({upload}, an array forfiles) —validateFormData(…, {uploads: 'ref'})lets the claim through locally and the core resolves it (a ticket of this form, unconsumed, within 24 h).WebServerOptions.formUploadLimitcaps the multipart body (default 10 MiB + the envelope). The body is parsed with the platform's ownResponse.formData()— no multipart dependency, nothing new in the browser bundle. Transport-level abuse is the proxy's job. Run a guard chain in front (seeexamples/instore/src/server.tsfor the referencePOST /form/:locale/:formroute — the locale comes from the route the rendered page posts to, never from the payload):
import {
FormGuardChain, HoneypotGuard, MinFillTimeGuard, MemoryRateLimitGuard,
} from 'drop-core-server';
const guards = new FormGuardChain([
new HoneypotGuard(),
new MinFillTimeGuard({key: process.env.FORM_KEY!}),
new MemoryRateLimitGuard({limit: 5, windowMs: 60_000}),
]);
const verdict = await guards.verify({sid, form, data, ip: req.ip, headers});
if (!verdict.ok) return res.status(403).json({reason: verdict.reason});
for (const f of Object.keys(await guards.issue({sid, form}))) delete data[f]; // ← see below
await site.pushForm(form, locale, data, {path: refererPathname(req), origin: req.get('origin')});An unguarded form endpoint on a public site is a spam sink — an empty chain is a local-dev default, not a deployment one.
FormGuard has two hooks: optional issue() returns hidden inputs/tokens to
embed at render time; verify() judges one submission. FormGuardChain
merges issue() maps and runs verify() in order — first failure wins,
meta accumulates. Strip guard fields from data before pushForm —
the core API rejects undeclared keys.
The built-in POST /form/:locale/:form route#
WebPreviewServer serves that route itself (reserved, like /data/**), so a
store served through it needs no form plumbing at all: pass a formGuard
(optional) and it reads the body (64 KiB cap), runs verify() with the
transport facts from the request, strips the guard's issue() keys, and calls
pushForm with the Referer path + the browser Origin. It answers
200 {id, status} · 400 {error, message} (validation — message is already
localized by the document's pattern_message — or an unknown/inactive form) ·
403 {error, reason} (guard, or the core's captcha refusal — see
"reCAPTCHA" below). The visitor's IP — the guards' ctx.ip and the relayed
remoteIp — comes from WebPreviewServerOptions.clientIp(headers); the
default reads the first x-forwarded-for hop, which a browser controls behind
a proxy that appends to that header (Fly, nginx), so a host behind one
passes a resolver over its trusted header (the tenant does:
TENANT_CLIENT_IP_HEADER).
GET on the same path issues the guard fields: {fields: {form_ts: "<ts>.<sig>", email_2: "", …}} (cache-control: no-store; {fields: {}}
with no guard). The page's submit script fetches it at load and folds each
field into the payload — an empty value is a honeypot (render it CSS-hidden,
never type="hidden"), a non-empty value is a token to send back verbatim
(type="hidden"). Issued per request, never baked into rendered HTML: pages
are render-cached, so an embedded timestamp would stamp render time, not the
visitor's page load — a guard with issue() fields but no fetch-at-load
support in the page kit rejects every real submission. Consumers driving a bare WebSite (like
examples/instore) implement the same path by hand; keep the shape, because
that path is the contract rendered pages are written against:
// in the page template's JS — DROP_BASE/DROP_LOCALE are injected into every
// rendered page. A native <form> POST does NOT work: the route parses JSON.
const res = await fetch(`${window.DROP_BASE}/form/${window.DROP_LOCALE}/contact`, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify({email, message, subscribe: true}), // declared keys only
});The built-in guards#
Three ship with the package. None needs a dependency, an account or a network call, and they are complementary — run all three. They stop undirected volume, which is what actually fills a submissions table; a captcha is the answer to a targeted attacker.
| guard | rejects | notes |
|---|---|---|
HoneypotGuard |
a filled hidden field (bot) |
Hide it with CSS, not type="hidden" — bots skip hidden inputs and fill visible ones. Give the wrapper aria-hidden, the input tabindex="-1". |
MinFillTimeGuard |
submitted < minMs (3 s) after render → bot; > maxMs (1 h) → expired; bad HMAC → forged |
Needs a key: the token is <ts>.<HMAC-SHA256(ts, key)> (the access-seed scheme), so a bot that rewrites the timestamp breaks the signature. |
MemoryRateLimitGuard |
more than limit (5) per IP per windowMs (60 s) → rate_limited |
Per-process (N replicas = N budgets) and read-modify-write, so it undercounts under concurrency. A first line, not a hard quota — back it with a shared Cache for that. The window slides: an over-limit IP stays blocked until it goes quiet. No ctx.ip ⇒ passes (a misconfigured proxy blocking every real submission is the worse failure). |
Strip the guards' fields from data before pushForm — they are not in
the form's contract and the core API rejects undeclared keys, failing the whole
submission. WebPreviewServer's built-in route does this for you;
examples/instore shows the hand-rolled version.
reCAPTCHA (verified by the core, relayed here)#
reCAPTCHA v3 is a platform feature, not a guard (.dev/plans/form-recaptcha.md): the core verifies, per form, so every path — this route, a hand-rolled server, a direct SDK caller — is covered by one check, and the store's secret never reaches a render server.
- Store key — the store owner puts their own Google reCAPTCHA v3 (score)
key in the console (Settings › Web › Spam protection): the site key lands
in the public
setup/web.captcha({enabled, site_key, hide_badge}), the secret in the store's encrypted secret store. - Per-form switch — the form doc's
captcha: {enabled, min_score?, low_score?}makes the core refuse a submission (and an upload) without a valid token for the actionform_{slug}(captchaActionin core-lib). - Header relay — the browser sends the token as the
x-drop-captcharequest header (CAPTCHA_HEADER); the built-in routes relay it topushForm/pushFormUploadas{captcha, remoteIp}(the SDK'scaptcha/remote_ipparams). Nothing is checked here. - 403 pass-through — the core refuses with
403andbody.reasoncaptcha_required/captcha_failed(the SDK error's.status/.body); the routes answer403 {error: 'Forbidden', reason}— the guard shape pages already handle. Every other core rejection stays400 {error: 'Rejected', message}. - Page runtime — with
setup/web.captcha.enabled+ a site key,WebSitecomposescaptcha_runtime.tsinto every page (see "Page composition"): it wrapsfetch/XMLHttpRequestfor same-originPOSTs to{DROP_BASE}/form/{locale}/{form}[/upload]only, lazy-loadswww.recaptcha.net/recaptcha/api.json the first form focus (or the first such POST), mints a fresh token per POST and sets the header; a refusal without amessagegets the localizedcaptcha.failed/captcha.consentcopy added, and withhide_badgeGoogle's badge is hidden. The runtime prints nothing on the page — a site that hides the badge states its use of reCAPTCHA itself, where and how it chooses (privacy policy, a line in the form template). Existing form templates need no change;window.DropCaptcha.token(form)is the escape hatch for code the wrappers can't see. TheGETguard-fields answer addscaptcha: {provider, site_key}for hand-written clients.
Other captchas stay sketches — they need a site key, a secret and a vendor call, and pinning that choice here would be worse than the few lines it saves:
- Cloudflare Turnstile — POST
ctx.data['cf-turnstile-response']tohttps://challenges.cloudflare.com/turnstile/v0/siteverifywith{secret, response, remoteip: ctx.ip};!outcome.success→{ok: false, reason: 'bot'}.