Skip to content

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.

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.

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.

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 (localStorage is writable by any script on the origin). On restore, each entry is checked against the field’s declared type: an object restored into a number field, an array into a string field, or anything into a File field is dropped and reported (draft-shape) instead of causing type confusion downstream. null is 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 at errorsFor("")).
  • Path safety (pre-existing): field paths are validated at creation — __proto__, prototype, constructor and empty segments are rejected everywhere paths enter (fields, drafts, dynamic config).
  • Opt-in (0.x default): sanitize defaults 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) with field(..., { sanitize: "off" }) or a custom function.
  • High-risk surfaces (values re-rendered as HTML downstream, AI- generated forms, public intake): "strict" on identity fields plus maxValueLength; 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 gated

For 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 sides
import { 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 defaults
import { createZodForm } from "@modyra/zod";
const form = createZodForm(orderSchema);
// server: the SAME schema gates the payload
app.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.

  • 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, \n and \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'!A0 survives 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(), custom ValidatorFn) — 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 arrivesvalidators.pattern in a document, or matches inside 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.