Appearance
Content safety & uploads
How FlowRunner keeps untrusted content — rich text you paste, files your visitors attach, and data your public forms receive — from turning into an attack on you, your team, or your visitors.
A no-code builder deals in content it didn't write: text pasted from the web, HTML in a display block, files uploaded by strangers, form fields filled in by bots. This page describes the specific defenses the code enforces at each of those boundaries.
HTML is sanitized at every boundary
Rich text (headings, buttons, links, alerts, typography) can contain HTML. Every string that reaches the DOM or a generated export passes through one central sanitizer, sanitizeHtml, built on DOMPurify. There is deliberately no "trusted HTML" path — nothing skips it.
The sanitizer applies a strict allowlist:
| Rule | Effect |
|---|---|
| Allowed tags | Formatting only — b, i, a, p, ul/ol/li, h1–h6, code, pre, etc. |
| Allowed attributes | href, target, rel only — every on* event handler is stripped. |
| Allowed URLs | http(s):, mailto:, tel:, #, / only — javascript: and data: links are removed. |
| Link hardening | Every <a> gets rel="noopener noreferrer" so a new tab can't reach window.opener. |
Because sanitizing happens at both the render boundary (preview and published form) and the export boundary (the Next.js and React Native code you download), a malicious <script> stored in a project can never execute — this blocks stored XSS even for content saved before you owned the workspace.
NOTE
The old Raw HTML block ("HTML / Code") is deprecated and hidden from the palette — it was the widest XSS surface for non-technical builders. Existing documents that still contain it keep rendering, but its content is sanitized on the way out just like everything else, and you can no longer add a new one.
File uploads: public in, member-gated out
Form visitors aren't signed in, so the upload endpoint has to be public. It is fenced on three sides:
- Rate-limited per client IP (30 uploads/minute), returning
429withRetry-Afteron a burst. - Size-capped at 10 MB per file — a larger file is rejected with
413and nothing is stored. - Project must exist — uploads are refused unless they target a real, non-deleted project.
Getting a file back out is a different story. The download endpoint requires you to be signed in and a member (viewer or above) of the file's project. Even then, bytes are served as an attachment with Content-Disposition: attachment, X-Content-Type-Options: nosniff, and Cache-Control: private, no-store. That combination means an uploaded HTML or SVG file can't execute in the app's origin — the browser downloads it instead of rendering it, and won't MIME-sniff it into something executable. See File uploads for the visitor-facing behavior.
Public form submissions
The submission receiver is public too, and defends the same way plus two extra checks:
- Honeypot — a hidden
_hpfield a human never sees. When a bot fills it, the server responds201 OK(so the bot doesn't learn to adapt) but silently drops the submission. - Server-side required-field validation — client-side validation is bypassable, so the server independently re-checks that every required field is present and rejects with
400and the list of missing names. See Validation. - Plus per-IP rate limiting and a size cap (~100 KB per submission).
Flow HTTP endpoints fail closed
Flows exposed over HTTP authenticate with fail-closed logic. If an auth type is configured but its expected secret is empty or an unresolved ${secrets.X}, every request is rejected — an unset secret can never accidentally allow anonymous access. Credentials are compared with a constant-time check (timingSafeEqual), so an attacker can't recover a token by measuring response times. See HTTP triggers.
WARNING
Server-side flow execution is not sandboxed yet. Only first-party, built-in nodes run in the engine today; running untrusted third-party node logic server-side is a design goal, not a current guarantee. Until that lands, only install nodes you trust. See Sandboxing.