@nodable/entities
Advanced tools
+4
-1
| { | ||
| "name": "@nodable/entities", | ||
| "version": "2.1.1", | ||
| "version": "2.2.0", | ||
| "description": "Entity parser for XML, HTML, External entites with security and NCR control", | ||
@@ -28,2 +28,4 @@ "main": "./src/index.js", | ||
| "entity", | ||
| "entities", | ||
| "stringify", | ||
| "encode", | ||
@@ -33,2 +35,3 @@ "decode", | ||
| "security", | ||
| "safe", | ||
| "performance" | ||
@@ -35,0 +38,0 @@ ], |
+104
-3
@@ -9,2 +9,26 @@ // --------------------------------------------------------------------------- | ||
| // --------------------------------------------------------------------------- | ||
| // Entity hook action constants | ||
| // --------------------------------------------------------------------------- | ||
| /** | ||
| * Action constants for `onExternalEntity` and `onInputEntity` hooks. | ||
| * | ||
| * Use these instead of raw strings to avoid typos: | ||
| * | ||
| * @example | ||
| * import EntityDecoder, { ENTITY_ACTION } from './EntityDecoder.js'; | ||
| * const dec = new EntityDecoder({ | ||
| * onInputEntity: (name, value) => ENTITY_ACTION.BLOCK, | ||
| * }); | ||
| */ | ||
| export const ENTITY_ACTION = Object.freeze({ | ||
| /** Resolve and expand the entity normally. */ | ||
| ALLOW: 'allow', | ||
| /** Silently skip this entity — it will not be registered. */ | ||
| BLOCK: 'block', | ||
| /** Throw an error, aborting entity registration entirely. */ | ||
| THROW: 'throw', | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
@@ -173,2 +197,10 @@ // --------------------------------------------------------------------------- | ||
| * Action for U+0000 (null). 'allow' and 'leave' are clamped to 'remove' since null is never safe. | ||
| * @param {((name: string, value: string) => 'allow'|'block'|'throw')|null} [options.onExternalEntity=null] | ||
| * Hook called when an external entity is registered via `setExternalEntities()` or | ||
| * `addExternalEntity()`. Return `ENTITY_ACTION.ALLOW` to accept the entity, | ||
| * `ENTITY_ACTION.BLOCK` to silently skip it, or `ENTITY_ACTION.THROW` to abort with an error. | ||
| * @param {((name: string, value: string) => 'allow'|'block'|'throw')|null} [options.onInputEntity=null] | ||
| * Hook called when an input entity is registered via `addInputEntities()`. Return | ||
| * `ENTITY_ACTION.ALLOW` to accept, `ENTITY_ACTION.BLOCK` to silently skip, or | ||
| * `ENTITY_ACTION.THROW` to abort with an error. | ||
| */ | ||
@@ -209,5 +241,42 @@ constructor(options = {}) { | ||
| this._ncrNullLevel = ncrCfg.nullLevel; | ||
| // --- Registration hooks --- | ||
| /** @type {((name: string, value: string) => 'allow'|'block'|'throw')|null} */ | ||
| this._onExternalEntity = typeof options.onExternalEntity === 'function' | ||
| ? options.onExternalEntity | ||
| : null; | ||
| /** @type {((name: string, value: string) => 'allow'|'block'|'throw')|null} */ | ||
| this._onInputEntity = typeof options.onInputEntity === 'function' | ||
| ? options.onInputEntity | ||
| : null; | ||
| } | ||
| // ------------------------------------------------------------------------- | ||
| // Private: registration hook dispatch | ||
| // ------------------------------------------------------------------------- | ||
| /** | ||
| * Invoke a registration hook for a single entity name/value pair. | ||
| * Returns true when the entity should be accepted, false when it should be | ||
| * silently skipped (BLOCK), and throws when the hook returns THROW. | ||
| * | ||
| * @param {((name: string, value: string) => 'allow'|'block'|'throw')|null} hook | ||
| * @param {string} name | ||
| * @param {string} value | ||
| * @param {string} context — used in error messages ('external' | 'input') | ||
| * @returns {boolean} true = accept, false = skip | ||
| */ | ||
| _applyRegistrationHook(hook, name, value, context) { | ||
| if (!hook) return true; // no hook → always accept | ||
| const action = hook(name, value); | ||
| if (action === ENTITY_ACTION.BLOCK) return false; | ||
| if (action === ENTITY_ACTION.THROW) { | ||
| throw new Error( | ||
| `[EntityDecoder] Registration of ${context} entity "&${name};" was rejected by hook` | ||
| ); | ||
| } | ||
| return true; // ALLOW or any unknown return value → accept | ||
| } | ||
| // ------------------------------------------------------------------------- | ||
| // Persistent external entity registration | ||
@@ -219,2 +288,5 @@ // ------------------------------------------------------------------------- | ||
| * All keys are validated — throws on invalid characters. | ||
| * If `onExternalEntity` is set, it is called once per entry; entries that | ||
| * return `ENTITY_ACTION.BLOCK` are silently omitted, `ENTITY_ACTION.THROW` | ||
| * aborts the whole call. | ||
| * @param {Record<string, string | { regex?: RegExp, val: string }>} map | ||
@@ -228,3 +300,15 @@ */ | ||
| } | ||
| this._externalMap = mergeEntityMaps(map); | ||
| if (!this._onExternalEntity) { | ||
| this._externalMap = mergeEntityMaps(map); | ||
| return; | ||
| } | ||
| // Hook present — resolve values first, then filter | ||
| const flat = mergeEntityMaps(map); | ||
| const filtered = Object.create(null); | ||
| for (const [name, value] of Object.entries(flat)) { | ||
| if (this._applyRegistrationHook(this._onExternalEntity, name, value, 'external')) { | ||
| filtered[name] = value; | ||
| } | ||
| } | ||
| this._externalMap = filtered; | ||
| } | ||
@@ -234,2 +318,4 @@ | ||
| * Add a single persistent external entity. | ||
| * If `onExternalEntity` is set it is called before the entity is stored; | ||
| * `ENTITY_ACTION.BLOCK` silently skips storage, `ENTITY_ACTION.THROW` raises. | ||
| * @param {string} key | ||
@@ -241,3 +327,5 @@ * @param {string} value | ||
| if (typeof value === 'string' && value.indexOf('&') === -1) { | ||
| this._externalMap[key] = value; | ||
| if (this._applyRegistrationHook(this._onExternalEntity, key, value, 'external')) { | ||
| this._externalMap[key] = value; | ||
| } | ||
| } | ||
@@ -253,2 +341,4 @@ } | ||
| * Also resets per-document expansion counters. | ||
| * If `onInputEntity` is set it is called once per entry; entries returning | ||
| * `ENTITY_ACTION.BLOCK` are silently omitted, `ENTITY_ACTION.THROW` aborts. | ||
| * @param {Record<string, string | { regx?: RegExp, regex?: RegExp, val: string }>} map | ||
@@ -259,3 +349,14 @@ */ | ||
| this._expandedLength = 0; | ||
| this._inputMap = mergeEntityMaps(map); | ||
| if (!this._onInputEntity) { | ||
| this._inputMap = mergeEntityMaps(map); | ||
| return; | ||
| } | ||
| const flat = mergeEntityMaps(map); | ||
| const filtered = Object.create(null); | ||
| for (const [name, value] of Object.entries(flat)) { | ||
| if (this._applyRegistrationHook(this._onInputEntity, name, value, 'input')) { | ||
| filtered[name] = value; | ||
| } | ||
| } | ||
| this._inputMap = filtered; | ||
| } | ||
@@ -262,0 +363,0 @@ |
+77
-0
@@ -9,2 +9,46 @@ // --------------------------------------------------------------------------- | ||
| // --------------------------------------------------------------------------- | ||
| // Entity registration hook | ||
| // --------------------------------------------------------------------------- | ||
| /** | ||
| * Actions returned by `onExternalEntity` / `onInputEntity` hooks. | ||
| * Use the `ENTITY_ACTION` constant object instead of raw strings to avoid typos. | ||
| * | ||
| * - `'allow'` — register and expand the entity normally | ||
| * - `'block'` — silently skip the entity (not registered, treated as unknown) | ||
| * - `'throw'` — abort registration with an error | ||
| */ | ||
| export type EntityHookAction = 'allow' | 'block' | 'throw'; | ||
| /** | ||
| * Immutable constant bag for entity registration hook return values. | ||
| * | ||
| * @example | ||
| * import { ENTITY_ACTION } from '@nodable/entities'; | ||
| * const dec = new EntityDecoder({ | ||
| * onInputEntity: (_name, _value) => ENTITY_ACTION.BLOCK, | ||
| * }); | ||
| */ | ||
| export const ENTITY_ACTION: Readonly<{ | ||
| /** Register and expand the entity normally. */ | ||
| ALLOW: 'allow'; | ||
| /** Silently skip this entity — it will not be registered. */ | ||
| BLOCK: 'block'; | ||
| /** Throw an error, aborting entity registration. */ | ||
| THROW: 'throw'; | ||
| }>; | ||
| /** | ||
| * Callback signature for `onExternalEntity` and `onInputEntity` hooks. | ||
| * | ||
| * Called once per entity **at registration time** (not at decode time). | ||
| * Return `ENTITY_ACTION.ALLOW` (or `'allow'`) to accept, `ENTITY_ACTION.BLOCK` | ||
| * to silently skip, or `ENTITY_ACTION.THROW` to raise an error. | ||
| * | ||
| * @param name — the entity name without `&` / `;`, e.g. `"brand"` | ||
| * @param value — the resolved string value after any `{regex,val}` unwrapping | ||
| */ | ||
| export type EntityRegistrationHook = (name: string, value: string) => EntityHookAction; | ||
| // --------------------------------------------------------------------------- | ||
| // Encoder options | ||
@@ -195,2 +239,35 @@ // --------------------------------------------------------------------------- | ||
| ncr?: EntityDecoderNCROptions; | ||
| /** | ||
| * Hook called once **at registration time** for each entity passed to | ||
| * `setExternalEntities()` or `addExternalEntity()`. | ||
| * | ||
| * - `'allow'` (or `ENTITY_ACTION.ALLOW`) — register the entity normally (default) | ||
| * - `'block'` (or `ENTITY_ACTION.BLOCK`) — silently skip; the entity is not stored | ||
| * - `'throw'` (or `ENTITY_ACTION.THROW`) — abort registration with an `Error` | ||
| * | ||
| * The hook receives the entity name (without `&`/`;`) and the resolved string | ||
| * value. It is **not** called during `decode()` — only when entities are added. | ||
| * | ||
| * @example | ||
| * const dec = new EntityDecoder({ | ||
| * onExternalEntity: (name, value) => | ||
| * DANGEROUS_NAMES.has(name) ? ENTITY_ACTION.BLOCK : ENTITY_ACTION.ALLOW, | ||
| * }); | ||
| */ | ||
| onExternalEntity?: EntityRegistrationHook | null; | ||
| /** | ||
| * Hook called once **at registration time** for each entity passed to | ||
| * `addInputEntities()`. | ||
| * | ||
| * Follows the same `'allow' | 'block' | 'throw'` contract as `onExternalEntity`. | ||
| * | ||
| * @example | ||
| * const dec = new EntityDecoder({ | ||
| * // Block all input / DOCTYPE entities unconditionally | ||
| * onInputEntity: () => ENTITY_ACTION.BLOCK, | ||
| * }); | ||
| */ | ||
| onInputEntity?: EntityRegistrationHook | null; | ||
| } | ||
@@ -197,0 +274,0 @@ |
+1
-1
@@ -9,3 +9,3 @@ /** | ||
| export { default as EntityDecoder } from './EntityDecoder.js'; | ||
| export { default as EntityDecoder, ENTITY_ACTION } from './EntityDecoder.js'; | ||
| export { | ||
@@ -12,0 +12,0 @@ COMMON_HTML, |
67378
12.38%2255
8%