Data cleaning: allowlist first. Cleaning is not encoding

Soap and a brush beside a drain still holding coral dirt, house style.

CVE-2026-53606 landed on NVD on 12 June 2026. sanitize-html before 2.17.5 let a javascript URI through when a developer allowlisted action or poster.

The sibling page is the door itself: input validation. This page is the habit that page has to kill. Teams still run a regex, delete the scary characters, store the leftover string, and skip the bind and the HTML encode. Pair the sink rules with XSS and injection. Keep the secure coding checklist next to both.

CWE-20 is the door. CWE-116 is the sink

MITRE splits the work. CWE-20 is Improper Input Validation: missing or wrong checks on a value that later changes control flow. CWE-116 is Improper Encoding or Escaping of Output. I opened both CWE pages.

Three jobs, in this order:

  1. Parse. Type, closed set, length. Reject with 400. Do not rewrite a mystery string into something you hope is harmless.
  2. Shape. Trim, Unicode NFC, lowercase an email local-part if your mailer needs that. This is consistency, not sink work.
  3. Encode or bind. HTML body uses entities or textContent. SQL uses $1. A file uses a token you minted, then realpath.

I opened the OWASP Input Validation Cheat Sheet. It says validation should not be the primary method of preventing XSS or SQL injection. That sentence is the whole argument against “we cleaned it.” A legal display name can still contain a quote or an angle bracket. The door accepted it. The sink still has to treat it as data.

HabitWhat it actually isStill required
Strip {}()[]Denylist. Breaks JSON and math.Parse, then encode
validator.escapeFive HTML entitiesParse first. Bind SQL anyway
sanitize-htmlMarkup allowlistOnly if the product must keep tags
Trim and NFCShapeAfter a successful parse

Character stripping is not a control

That fails in both directions. It deletes legal text: a price in $, a markdown link, a JSON blob you meant to store as a string. It misses the next encoding. HTML can use entities. SQL can use a comment that never needed a brace. A path can use .. with no bracket in sight.

Denylist filters for a script tag, 1=1, or a quote have the same shape. They block O'Brien and they miss the next encoding. Name the legal set. For a role that is reader or editor, use an enum. For a sort key, map a token you wrote to a column you wrote. Never interpolate the client string into ORDER BY.

JSON.parse, never eval

JSON.parse is the parser. It accepts object, array, string, number, boolean, and null. It does not run the text as JavaScript. eval does.

I opened the current MDN JSON.parse page. Use it on a string you already length-capped. A 2 MB body is a memory problem before it is a type problem. Express express.json({ limit: "32kb" }) is the first cap. Then parse the object with a schema. Do not walk the raw object looking for keys to delete.

import { z } from "zod";

const CommentSchema = z.strictObject({
  displayName: z.string().min(1).max(80),
  body: z.string().min(1).max(2000),
});

function readComment(raw) {
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (err) {
    return { ok: false, reason: "json" };
  }
  const result = CommentSchema.safeParse(parsed);
  if (!result.success) return { ok: false, reason: "schema" };
  return { ok: true, value: result.data };
}

I opened the npm registry: Zod 4.4.3 dated 4 May 2026. z.strictObject rejects extra keys. That matters on a price or a role. After a successful parse, pass result.data only. Do not read the raw string again. A later helper that evals a leftover field undoes the door.

Node’s vm module is not a sandbox for user text either. If a product feature is “run this snippet,” that is a different design. It is not a cleaning problem.

validator.escape encodes. It does not parse

I opened the npm page for validator 13.15.35, updated 2 April 2026, and express-validator 7.3.2, updated 1 April 2026. validator.escape rewrites &, ", ', <, and > into HTML entities. That is CWE-116 work for an HTML body. Escape is not a type check. It does not cap length. It does not make a string safe in a SQL literal, a URL, or a shell argument.

Two failure modes I keep seeing in reviews:

  • Store the escaped string in the database, then escape again on the way out. Users see &amp;. Someone “fixes” it with unescape and the next render is raw HTML.
  • Escape, then assign to innerHTML. Entities become markup. The encode was undone by the sink.

Store the parsed Unicode. Encode on the way out, for that sink. If you render with a template that already escapes, do not pre-escape. If you render into a text node, you do not need entities at all.

function encodeHtml(s) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function renderName(displayName) {
  return `<p class="name">${encodeHtml(displayName)}</p>`;
}

Identifiers stay displayName and encodeHtml. Prefer textContent in the browser so you never build the HTML string. The named fallback above is for a server-rendered snippet that cannot use a text node.

import { body, validationResult } from "express-validator";

app.post(
  "/comment",
  body("displayName").isString().isLength({ min: 1, max: 80 }),
  body("body").isString().isLength({ min: 1, max: 2000 }),
  (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      res.status(400).send("Invalid comment");
      return;
    }
    const displayName = req.body.displayName;
    const bodyText = req.body.body;
    res.locals.comment = { displayName, bodyText };
    // bind these. encodeHtml only at an HTML sink.
  }
);

express-validator wraps the same validator library. Use it as a parse. Skip escape() in the chain unless you are about to drop the value into HTML and you have no template escape. A blacklist or stripLow call is the old denylist again.

HTML allowlists still leak

When bold and lists are a real requirement, you are in sanitizer country. That is a last line, not a first one. I opened NVD for CVE-2026-53606. GitHub published it on 12 June 2026. Versions of sanitize-html before 2.17.5 gated dangerous URI schemes on href, src, and cite only. Allow action, formaction, poster, or background and a javascript URI passed through. 2.17.5 is the patch. I opened npm: 2.17.7 dated 13 August 2026. Pin at or above 2.17.5.

NVD also lists CVE-2026-40186 against 2.17.1: entity-encoded markup inside an allowed textarea or option survived as tags. Fixed in 2.17.2. That is entuno’s history of holes in one month of 2026.

Rules if you still need markup:

  • Parse the rest of the body first. A 2000 character cap still applies.
  • Pin sanitize-html at 2.17.5 or later. Read your allowedAttributes. If you add a URI-bearing attribute, you inherited the 2026 miss unless you are on the patch.
  • Do not also call bypassSecurityTrustHtml or assign innerHTML to the raw field. The sanitizer output is the only string that may enter an HTML sink.
  • When tags are not a requirement, skip this library. encodeHtml or a text node is smaller and has no scheme list to get wrong.

Trim and NFC after the parse

Cleaning in the data-science sense is real work. It is not a security control. After CommentSchema accepts the object, you may normalize so two rows compare equal.

function shapeComment(value) {
  return {
    displayName: value.displayName.normalize("NFC").trim(),
    body: value.body.normalize("NFC").trim(),
  };
}

NFC first, then trim. Some code points look like spaces and are not. Length already ran on the raw string, so a trim cannot grow the value. If you must lowercase an email, do it after z.email() succeeds, and keep the original for display if the product needs that.

What shaping must not do:

  • Delete characters you find scary. That is the strip habit.
  • HTML-escape into the stored row. That is a sink leaking into storage.
  • Turn a failed parse into a default role or a default price. Failed parse is 400.
Same field. Three jobs. Only the door and the sink are controls.
CLIENT   raw JSON body
         length-capped by express.json

PARSE    JSON.parse, then CommentSchema
         type, max, strict keys
         400 if it fails

SHAPE    NFC, trim
         product equality, not a sink

SINK     HTML   encodeHtml or textContent
         SQL    $1 bind, not a template
         FILE   token you minted

SQL is the other place people “clean” a quote. That is templating. pg 8.23.0, updated 8 August 2026, sends the statement and the values on separate channels when you pass a values array. Use that. Do not replace ' with '' and add the string into the query text.

import pg from "pg";
const pool = new pg.Pool();

async function insertComment(userId, shaped) {
  await pool.query(
    "INSERT INTO comments (user_id, display_name, body) VALUES ($1, $2, $3)",
    [userId, shaped.displayName, shaped.body]
  );
}

Prove the parse and the sink

You are proving the handler returned 400, and that a legal name still encoded at the sink. You are not walking an exploit.

  1. POST /comment with displayName missing or a number. Expect 400.
  2. POST a 8 KB body. Expect 400 from the schema or from express.json limit.
  3. POST a valid comment whose displayName is <em>Ada</em>. Load the page. Expect a text node or entities, not italics.
  4. POST a quote in displayName. The SQL log should show parameters, not a broken quote in the statement text.
  5. Grep for eval(, innerHTML, validator.escape, and a global replace(/[ denylist.
curl -sS -D - -o /dev/null -X POST "https://your-app.example/comment" \
  -H "Cookie: __Host-session=PASTE_FROM_YOUR_DEVTOOLS" \
  -H "Content-Type: application/json" \
  --data '{"displayName":"Ada","body":1}'
# Expect: HTTP/2 400
rg -n "eval\\(|innerHTML|validator\\.escape|sanitize-html|replace\\(/\\[" \
  --glob '!node_modules'

A 200 on step 1 means the route trusted the client type. Italics on step 3 means the sink used HTML parse. A quote that breaks the statement on step 4 means the insert still concatenates.

Questions we keep getting

Can I strip tags and skip encoding?

No. A denylist of tags is the next encoding away from a miss. When markup is not a requirement, store the parsed string and put it in a text node. When markup is a requirement, pin a maintained sanitizer and still reject over-long input first.

Is validator.isEmail a door?

It is a format check. Use it. It does not replace a length cap, and it does not encode the address when you print it in HTML. Parse, then encode at the sink.

Should I clean data I send to the client?

Shape it so the contract is stable. Encode it for the context the client will use. A JSON API that returns Unicode is fine. An HTML fragment you concatenate on the server is not. The client still must not assign the field to innerHTML.

Aphinya Dechalert

Aphinya Dechalert / About Author

Aphinya is a skilled technical writer with field experiences in software development, agile, and JavaScript full stack with AWS and Google cloud. She is a developer advocate and community builder, helping others navigate their journeys and careers as developers.