EntityDecoder

June 12, 2026 · View on GitHub

Decodes HTML/XML entity references back to plain text in a single pass. Supports named entities, numeric decimal (<), and numeric hex (<) references. Includes security controls for untrusted input and fine-grained NCR (Numeric Character Reference) policy enforcement per XML version.

Install

npm install @nodable/entities

Usage

import { EntityDecoder, ALL_ENTITIES } from '@nodable/entities';

const dec = new EntityDecoder({ namedEntities: ALL_ENTITIES });
dec.decode('Hello © 2024 & <stuff>');
// → 'Hello © 2024 & <stuff>'

Constructor options

OptionTypeDefaultDescription
namedEntitiesobjectnullExtra named entity map merged into the base XML set (amp gt lt quot apos)
limitobjectsee belowSecurity limits — see Limit options
postCheck(resolved, original) => stringidentityHook called with the final decoded string — return original to reject a decode
numericAllowedbooleantrueAllow numeric entity decoding. When false, safe NCRs are left as-is; prohibited codepoints (surrogates, XML 1.0 C0, null) are still enforced per the ncr policy
removestring[][]Entity names to remove (replace with empty string)
leavestring[][]Entity names to keep as literal (unchanged)
ncrobjectsee belowNumeric Character Reference policy — see NCR options

Limit options (options.limit)

OptionTypeDefaultDescription
maxTotalExpansionsnumber0Max entity expansions per document. 0 = unlimited
maxExpandedLengthnumber0Max net character growth from expansions. 0 = unlimited
applyLimitsTo'external' | 'base' | 'all' | string[]'external'Which entity tiers count against the limits

NCR options (options.ncr)

OptionTypeDefaultDescription
xmlVersion1.0 | 1.11.0XML version governing restricted codepoint ranges
onNCR'allow' | 'leave' | 'remove' | 'throw''allow'Base action for all numeric references. Acts as a floor — for ranges that carry a minimum level the effective action is max(onNCR, rangeMinimum)
nullNCR'remove' | 'throw''remove'Action for U+0000 (null). 'allow' and 'leave' are clamped to 'remove' since null is never safe

Action severity order: allow < leave < remove < throw

Codepoint range minimums

RangeAlways/VersionMinimum action
U+0000alwaysnullNCR (≥ remove)
U+D800–U+DFFF (surrogates)alwaysremove
U+0001–U+001F except U+0009/000A/000DXML 1.0 onlyremove

In XML 1.1, C0 controls (U+0001–U+001F) are permitted when written as NCRs and are decoded normally. C1 controls (U+007F–U+009F) are decoded as-is in both versions.

API

decode(str)

Returns the decoded string. Returns str unchanged if no & is present (fast path).

dec.decode('&lt;b&gt;')          // → '<b>'
dec.decode('&#60;b&#x3E;')       // → '<b>'
dec.decode('no entities here')   // → same reference, no allocation

Throws if a security limit or a throw-level NCR policy is exceeded.

setXmlVersion(version)

Update the XML version used for NCR classification at runtime. Call this as soon as the document's <?xml version="..."> declaration is parsed — the version is not always known at construction time.

const dec = new EntityDecoder({ ncr: { onNCR: 'remove' } });

// later, once <?xml version="1.1"?> is parsed:
dec.setXmlVersion(1.1);

setExternalEntities(map)

Registers persistent named entities. These survive reset() and are intended for long-lived document-set context (e.g. a DTD shared across files).

dec.setExternalEntities({ brand: 'Acme Corp' });
dec.decode('&brand; Ltd');  // → 'Acme Corp Ltd'

addExternalEntity(key, value)

Adds a single persistent external entity.

addInputEntities(map)

Registers per-document entities (e.g. from a <!DOCTYPE> block). Also resets the per-document expansion counters. Wiped by reset().

dec.addInputEntities({ version: '1.0' });
dec.decode('v&version;');  // → 'v1.0'

reset()

Clears input entities and resets expansion counters. Call between documents. Does not clear external entities or the XML version.

dec.reset();

Entity registration hooks

EntityDecoder exposes two hooks that let you inspect and gate every entity at registration time — before anything is stored internally and before any decode() call can reach it. No opinion is baked in: the decoder calls your hook and you decide.

HookFires when
onExternalEntitysetExternalEntities() or addExternalEntity()
onInputEntityaddInputEntities()

Each hook receives (name, value) and must return one of the three ENTITY_ACTION constants:

ConstantStringEffect
ENTITY_ACTION.ALLOW'allow'Register and expand normally
ENTITY_ACTION.BLOCK'block'Silently skip — entity is never stored
ENTITY_ACTION.THROW'throw'Abort registration with an Error

Use the constants instead of the raw strings to avoid typos.

import EntityDecoder, { ENTITY_ACTION, ALL_ENTITIES } from '@nodable/entities';

const dec = new EntityDecoder({
  namedEntities: ALL_ENTITIES,

  // Only allow external entities whose value is plain text
  onExternalEntity: (name, value) => {
    if (/<[a-z]/i.test(value)) return ENTITY_ACTION.BLOCK; // drop anything HTML-like
    return ENTITY_ACTION.ALLOW;
  },

  // Reject all DOCTYPE / input entities outright
  onInputEntity: (_name, _value) => ENTITY_ACTION.BLOCK,
});

dec.setExternalEntities({ brand: 'Acme', evil: '<script>alert(1)</script>' });
dec.decode('&brand;');  // → 'Acme'
dec.decode('&evil;');   // → '&evil;'  (blocked at registration, treated as unknown)

Selective allow/block per name

const ALLOWLIST = new Set(['brand', 'version']);

const dec = new EntityDecoder({
  onExternalEntity: (name) =>
    ALLOWLIST.has(name) ? ENTITY_ACTION.ALLOW : ENTITY_ACTION.BLOCK,
});

Hard rejection with THROW

const dec = new EntityDecoder({
  onInputEntity: (name) => {
    if (name === 'SYSTEM') throw ENTITY_ACTION.THROW; // via constant
    return ENTITY_ACTION.ALLOW;
  },
});
// Or let the decoder throw for you:
const dec2 = new EntityDecoder({
  onInputEntity: () => ENTITY_ACTION.THROW, // every input entity is forbidden
});
dec2.addInputEntities({ x: 'X' }); // → Error: Registration of input entity "&x;" was rejected by hook

Notes

  • Hooks fire once per entity at registration time, not on every decode() call — no per-call overhead.
  • A BLOCKed entity is simply never stored; at decode time it is treated as an unknown reference and left as-is (e.g. &blocked; stays &blocked;).
  • onExternalEntity and onInputEntity are completely independent: blocking input entities does not affect external ones, and vice versa.
  • Base-map entities (namedEntities, the built-in XML set) are never passed through hooks — hooks only apply to runtime-injected entities.

Entity lookup priority

  1. Input / runtime entities (addInputEntities)
  2. Persistent external entities (setExternalEntities)
  3. Base named map (namedEntities constructor option + built-in XML entities)

Security limits

Limits guard against entity-expansion attacks (XML bomb / billion-laughs). By default only external and input tier entities are counted — base XML entities are trusted.

const dec = new EntityDecoder({
  maxTotalExpansions: 1000,
  maxExpandedLength: 50_000,
  applyLimitsTo: 'external',   // only count runtime-injected entities
});
// Limit all tiers — even built-in entities count
const strict = new EntityDecoder({
  maxTotalExpansions: 200,
  applyLimitsTo: 'all',
});

NCR policy examples

// Strict XML 1.0 — throw on any prohibited codepoint, remove null
const strict = new EntityDecoder({
  ncr: {
    xmlVersion: 1.0,
    onNCR: 'throw',   // C0 controls and surrogates → throw (max of throw and remove = throw)
    nullNCR: 'throw',
  },
});

// Lenient XML 1.1 — decode everything, silently remove null
const lenient = new EntityDecoder({
  ncr: {
    xmlVersion: 1.1,
    onNCR: 'allow',
    nullNCR: 'remove',
  },
});

// Leave all NCRs as-is except prohibited ones (those get removed)
const passThrough = new EntityDecoder({
  ncr: { onNCR: 'leave' },   // 'leave' < 'remove', so prohibited ranges still get removed
});

// Version set at parse time (common in document parsers)
const dec = new EntityDecoder({ ncr: { onNCR: 'remove' } });
// ... parser reads <?xml version="1.1"?> ...
dec.setXmlVersion(1.1);

Read More about NCR here.

Named entity sets

Pre-built sets are exported from the package:

ExportContents
XMLamp gt lt quot apos
COMMON_HTMLnbsp copy reg trade mdash ndash hellip
CURRENCYcent pound yen euro inr
MATHtimes divide plusmn minus ne le ge
ARROWSrarr larr harr uarr darr rArr
ALL_ENTITIESAll 2000+ entities
import { EntityDecoder, COMMON_HTML, CURRENCY } from '@nodable/entities';

const dec = new EntityDecoder({ namedEntities: { ...COMMON_HTML, ...CURRENCY } });

Performance

~5.2 million decodes/second on a commodity laptop — ~3× faster than the entities npm package (see benchmark in repo root).