Injection prevention
Modyra never renders field values as HTML (see SECURITY.md)
— but values still flow into places where invisible or markup characters do
damage: confirmation emails, PDF exports, logs, CSV downloads, downstream
systems that do render HTML, and UI-spoofing tricks based on bidi or
zero-width characters ("admin\u202E" looks like admin but isn’t).
The security option adds a prevention layer inside the engine, at the
single point every write passes through — the field’s value signal. User
input, patch()/setValue(), draft restore, array push/insert and
bindings writing the signal directly are all covered by construction: there
is no path around it.
import { createForm, field } from "@modyra/core";
const form = createForm( { name: field(""), bio: field(""), code: field("", [], { sanitize: "off" }), // per-field exemption html: field("", [], { sanitize: domPurify }), // per-field custom }, { security: { sanitize: "text", // form-level default profile maxValueLength: 10_000, // truncate longer strings onViolation: (v) => telemetry(v), // telemetry hook }, },);The policy lives in the core, so every adapter takes the same options on its
own constructor — createForm, mdyForm, useMdyForm, createLitForm,
createVueForm, createSolidForm, createSvelteForm. Which one you call
changes nothing about what is enforced.
Sanitization profiles
Section titled “Sanitization profiles”| Profile | What it does |
|---|---|
"off" (default) |
Values pass through untouched. |
"text" |
Strips control characters except tab (\t), line feed (\n) and carriage return (\r), plus DEL/C1, zero-width characters (U+200B–200D, U+FEFF), bidi overrides/isolates (U+202A–202E, U+2066–2069) and line/paragraph separators. Kills the invisible-character class of UI spoofing — "admin\u202E" becomes admin. All legitimate text — accents, emoji, CJK, newlines — is preserved. It does not make a value safe to concatenate into a log line or a CSV cell; see below. |
"strict" |
Everything "text" does, plus removes <, ` and >. The value can never form markup. For names, labels and identifiers that must stay plain text everywhere. Quotes and & stay: O'Brien & Co is a legitimate name. |
| function | Full control: receives the whole field value, returns the sanitized one. Must be pure and idempotent (it runs on every write). This is the DOMPurify/allow-list escape hatch — the core stays dependency-free on purpose. |
Resolution order per field: field(..., { sanitize }) →
security.sanitize → "off".
Sanitization is deep: strings inside plain objects and arrays in the
field value (multi-selects, object-valued fields) are processed too, and
maxValueLength applies to every string found. Values that can’t carry
text (File, Date, class instances) are never touched. When nothing
changes, the original reference is returned so signal identity checks keep
working.
Violation telemetry
Section titled “Violation telemetry”Every interception is reported to onViolation:
interface MdySecurityViolation { kind: "sanitized" | "max-length" | "draft-shape" | "error-path" | "sanitizer-error"; path: string; // dotted field path detail: string; // human-readable, for logs}Errors thrown by the hook are swallowed (surfaced as dev warnings) — a faulty telemetry pipeline can never break a form.
Always-on structural checks
Section titled “Always-on structural checks”These are not configurable: they only ever drop data the form itself could never have produced.
- Draft shape validation. A stored draft is untrusted input
(
localStorageis writable by any script on the origin). On restore, each entry is checked against the field’s declared type: an object restored into anumberfield, an array into astringfield, or anything into aFilefield is dropped and reported (draft-shape) instead of causing type confusion downstream.nullis always allowed (Modyra’s empty sentinel). Fields without a declared initial (raw-engine usage, where drafts legitimately create fields) restore as-is. - Server-error path validation. Errors returned by the submit action
with prototype-polluting paths (
__proto__and friends) are dropped and reported (error-path). Errors with unknown-but-safe paths keep the existing behavior (surfaced aterrorsFor("")). - Path safety (pre-existing): field paths are validated at creation —
__proto__,prototype,constructorand empty segments are rejected everywhere paths enter (fields, drafts, dynamic config).
Choosing a posture
Section titled “Choosing a posture”- Opt-in (0.x default):
sanitizedefaults to"off"— zero behavior change for existing forms. The structural checks above are always active. - Recommended for new apps:
sanitize: "text"form-wide. It is invisible to legitimate users and kills the entire invisible-character class of problems. Exempt fields that legitimately need arbitrary bytes (code editors, rich text) withfield(..., { sanitize: "off" })or a custom function. - High-risk surfaces (values re-rendered as HTML downstream, AI-
generated forms, public intake):
"strict"on identity fields plusmaxValueLength; a custom sanitizer (e.g. DOMPurify) on rich-text fields. For AI-generated forms see the AI-generated forms guide: a form-level policy automatically covers every field the LLM declares. - The default is
"off". Sanitization is opt-in: nothing is stripped until you ask for it. Making"text"the default is on the roadmap and would be a breaking change, announced in the changelog when it lands.
Trust model: option whitelisting and anti-tampering
Section titled “Trust model: option whitelisting and anti-tampering”“If the select offers one and two, three must not be accepted.”
Two different defenses answer that, and both ship with Modyra:
Client-side (UX + first line). The oneOf/eachOneOf validators
whitelist a field’s value against the allowed options:
import { field, oneOf, eachOneOf, required } from "@modyra/core";
const form = createForm({ plan: field(null, [required(), oneOf(["one", "two"])]), tags: field([], [eachOneOf(["a", "b"])]), // multiselect: every element});form.f.plan.set("three"); // scripted tampering → field invalid, submit gatedFor option-based dynamic fields the whitelist is automatic:
buildDynamicFieldValidators() constrains select/radio/segmented
values and every multiselect element to the declared options — so a
CMS/LLM-generated form is tamper-resistant client-side with zero extra
code (see the AI-generated forms guide).
Server-side (the real boundary). Client-side checks are defense-in-depth, never coverage: anything in the browser can be bypassed with curl/Postman/DevTools. The honest anti-tampering story is the isomorphic one — Modyra’s engine runs in plain Node, so one schema can drive the form and gate the API:
// shared/order-schema.ts — one schema, both sidesimport { z } from "zod";export const orderSchema = z.object({ plan: z.enum(["one", "two"]).default("one"), qty: z.number().int().min(1).max(10).default(1),});
// client: schema-driven form (@modyra/zod) — initials come from the schema's defaultsimport { createZodForm } from "@modyra/zod";const form = createZodForm(orderSchema);
// server: the SAME schema gates the payloadapp.post("/order", (req, res) => { const result = orderSchema.safeParse(req.body); if (!result.success) return res.status(422).json(result.error.issues); // …accept});This exact flow is executable — a scripted form.f.plan.set("three") is
invalid client-side, and a forged POST {"plan": "three"} gets a 422
from safeParse (verified against the built packages in CI-adjacent
tests). Any Standard Schema library (valibot, arktype…) works the same
way via @modyra/standard-schema.
What this is not
Section titled “What this is not”- Not a substitute for output encoding. Sanitization reduces what a
value can carry; whoever renders or stores it still owns correct
encoding/parameterization. No renderer writes a field value through
innerHTML: the only HTML any of them writes is its own icon geometry, from a frozen constant with no registration API, and the devtools panel’s table, which escapes the path, the value and every error message. That is a measurement, not a proof of absence — see what has been attacked. - Not log or CSV safety. Both profiles keep
\t,\nand\r, because a textarea legitimately holds them — so"ok\rINJECTED admin logged in"arrives at your logger intact and can forge a line. Neither profile touches a leading=,+,-or@, so=cmd|' /C calc'!A0survives into a spreadsheet cell, and that is deliberate: a formula is a legitimate value in a text field and stripping it would corrupt the data. Line breaks and formula prefixes belong to whoever writes the log line or encodes the CSV, at the boundary where the meaning exists. Measured on both profiles. - Not validation. A sanitized value is silently modified, not rejected.
To reject suspicious input instead, keep using validators
(
pattern(), customValidatorFn) — the two compose: sanitize first (write path), validate the result (error path). - Not ReDoS protection for the patterns you write.
pattern()executes the regex you give it, and a pathological one is your own risk. A pattern that arrives —validators.patternin a document, ormatchesinside an expression — is a different door and is analysed: an exponentially backtracking shape is refused at parse with a diagnostic naming it, while IPv4, hostname, slug and card-number patterns pass. Measured in what has been attacked.