Form Security — Threat Model & Mitigations (Phase 10)
Scope: the Phase-10 Forms surface (TASK-0106 builder, TASK-0107 public page/whitelist, TASK-0108 submit pipeline, TASK-0109 responses). Companion docs: forms.md (API), forms.md (UI), security-architecture.md (platform rules).
The platform invariants that govern this surface (AGENTS.md): rule 19 — the frontend is never trusted for authorization; rule 20 — all user input is untrusted; rule 26/27 — observability + audit on security-sensitive operations. Every control below is asserted by the integration + E2E suites, not just described.
Prime directive
A public form is an unauthenticated write path into a workspace's database.
Everything a submitter can send is untrusted: the answers object, the
fileIds, the client IP, and the payload's size and content-type. The enforceable
facts — which questions exist, what is valid, which property a question maps
to, which files are allowed, whether the form is open, and who may see a
submitter's IP — are decided server-side only. The public page renders what
it can; it is never the boundary.
The second directive: no workspace data leaves the public surface. The public payload is hand-picked, and nothing about the workspace, its database, its other forms, its users, or its existing submissions is ever exposed.
1. The public whitelist (TASK-0107)
GET /api/v1/public/forms/:slug returns only the form + its questions,
through public-forms.service.ts — no ORM object is echoed whole.
Never returned (asserted in forms.integration.test.ts, TASK-0107):
- the workspace name/slug,
databaseId, or any property id/name; - a question's
propertyId(the answer→column mapping); - other forms in the same database;
- any user data or existing submission data.
Status handling (no leak):
- DRAFT or unknown slug → 404 — drafts are not discoverable, and a soft-deleted form's released slug 404s identically, so an attacker cannot distinguish "draft" from "does not exist."
- CLOSED → 200 with
status: "closed"— the closed state is intentionally visible (the page renders its closed notice), andvalidationis emptied (no inputs, no business-rule hints).
Config is re-parsed, not echoed. The stored config Json is run through
the shared strict FORM_CONFIG_SCHEMA (safeConfig); a corrupt value fails
closed to the empty config rather than being echoed raw. intro is rendered
markdown with HTML stripped (sanitized). iconUrl is a 15-min signed GET that
only exists when the icon is READY and an image.
Validation is the public-safe subset. toPublicSafeValidation exposes only
minLength/maxLength/min/max/maxFiles/options. The pattern source is never
sent — regexes run server-side only, so a business rule (e.g. "invoices must
match INV-\d+") is not leaked to an unauthenticated caller.
2. Abuse protection on submit (TASK-0107/0108)
The submit pipeline (public-forms.service.ts) gates, in order:
- Content-type — must be
application/json, else415(rejects form- encoded spray and non-JSON bodies before parsing). - Payload size — ≤ 64 KB, else
413(a body is a DoS vector; bound it early). - Honeypot — two visually-hidden fields (
website,hp). A bot fills them; a human does not. A non-empty honeypot →400generic (the caller is not told it was caught — revealing that confirms the trap). Logged asform.honeypot_triggered slug=… ip=…. Bots do not consume rate-limit slots (the check runs first). - Rate limit — 5 submits/min per form + client IP (Redis-backed,
Phase-1
rate-limit:{key}counters). Exceeded →429+Retry-After. Keying on form+IP (not IP alone) means one abusive form cannot exhaust a shared IP's (e.g. NAT/CGN) quota for other forms. - ReCAPTCHA — config stub only (
config.recaptcha.enabled = falseby default; the verification hook point exists). Real verification ships Phase 13+ (see the API doc §4.1).
The client IP is read as req.ip ?? "unknown". Note: the API runs without
trust proxy, so req.ip is the direct socket address — correct for a
directly-exposed API, but if the API is ever put behind a load balancer the
deploy must configure trust proxy and the rate-limit key will then reflect
the real client (recorded; no code change now).
3. The submission pipeline (TASK-0108)
Server-side validation is the only authority. Every answer is validated by
submit-schema.ts against the question's type + validation (the same
per-type schemas the builder uses — single source of truth in
@factory/shared/forms.ts). All violations are returned together in
422 error.details[] ({ questionId, code, message }). A client-side check is
UX only; a forged or stripped client cannot pass an invalid answer.
Atomicity. The record + its backing page + RecordProperty rows + the
FormSubmission (raw answers, the audit trail) + file links are written in
one Prisma transaction. A failure at any point persists nothing — a
"half-submission" (a record with no audit row, or vice versa) is impossible.
FILE answers are ownership-verified. A submitted fileId must (a) belong
to the form's workspace, (b) be READY, and (c) have been opened for
this form (its upload session stamped metadata.formId = this form). A file
from another workspace, a different form, or an unfinished upload →
422 FILE_NOT_OWNED (logged form.file_not_owned). Because the submit
pipeline is the only writer of metadata.submissionId, forging a file link
to another submission is impossible — you cannot attach a file you did not
upload through this form's session.
Event IP is redacted. The form.submitted stream event carries the IP
redacted to /24 (1.2.3.***); the full IP lives only in the
FormSubmission.ipAddress column (DB-only). A consumer of the event stream
(the Phase-11 automation) never sees the full client address.
4. Responses access control & IP masking (TASK-0109)
- List / delete are database-scoped:
VIEWto list,EDITto delete (asserted server-side viaassertCan; the UI gating is cosmetic). - IP masking by role (
isWorkspaceAdmin= OWNER|ADMIN):- OWNER / ADMIN → full IP (
ipMasked: false). - MEMBER →
maskIp(e.g.1.2.***.***),ipMasked: true. A member can never read a submitter's full address, and theq=search matches the IP as the caller's role sees it (a member cannot use search to recover an octet the list masks). The split (full vs masked) is asserted by the integration suite; the E2E runs as the OWNER, so it asserts an IP is present (not masked).
- OWNER / ADMIN → full IP (
- Submitter identity —
submittedByisnullfor anonymous submissions. A submitter whose user row was later deleted still renders as "Former member" (the id survives on the submission row; no crash, no leak of a now-gone user's other data).
Delete is a gated cascade. DELETE …/submissions/:id?confirm=true
requires EDIT and the confirm=true flag (else 422 CONFIRM_REQUIRED —
a guard against accidental/mass deletion). In one transaction it removes the
linked record + its page + blocks, the linked files + versions, the submission
row, and adjusts the workspace byte counter; after commit it purges
object-storage (best-effort per key) and writes audit rows. The raw answers
are gone with the row.
5. Audit & observability (rule 27)
Security-sensitive operations write audit rows (action values):
Audit action |
When |
|---|---|
form.created |
a form is created (builder) |
form.deleted |
a form is deleted |
form.submitted |
every submission (mandatory — the submit is a security-relevant write) |
form.public_upload_created |
an anonymous upload session is opened for a form |
form.public_upload_completed |
an anonymous upload is finalized |
form.submission_deleted |
a submission (+record +files) is deleted |
record.deleted |
the cascade removes the record (metadata via: "form_submission_deleted") |
Plus structured log lines for the abuse path: form.honeypot_triggered,
form.file_not_owned (both with slug + IP). Observability (rule 26) rides the
shared request logger (request failed <status> on <method> <path>).
6. Threat model summary
| Threat | Control |
|---|---|
| Enumerate forms / leak the workspace | strict whitelist; DRAFT/unknown → 404; no ids/property data |
| Leak business rules via validation | public-safe subset only; pattern source never sent |
| Spam / brute-force submit | honeypot (silent 400) + 5/min form+IP rate limit + 64 KB bound |
| Inject invalid / out-of-range answers | server-side dynamic validation; all-or-nothing 422 |
| Attach a file from another workspace/form/submission | ownership + READY + metadata.formId checks; FILE_NOT_OWNED |
| Read a submitter's IP as a member | role-based maskIp (full only OWNER/ADMIN); search respects the mask |
| Accidental / mass deletion | confirm=true required; EDIT gated; audited cascade |
| Half-submission (no audit / no record) | single transaction; event + audit after commit |
| Full IP via the event stream | event IP redacted to /24; full IP DB-only |
| DoS via a huge payload / bad content-type | 415 + 413 guards before parsing |