High severityOSV Advisory· Published Jan 28, 2026· Updated Jan 29, 2026
CVE-2026-1513
CVE-2026-1513
Description
billboard.js before 3.18.0 allows an attacker to execute malicious JavaScript due to improper sanitization during chart option binding.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
billboard.jsnpm | < 3.18.0 | 3.18.0 |
Affected products
1- Range: 1.10.0, 1.10.1, 1.9.0, …
Patches
149e079cdd466fix(util): update sanitization function (#4085)
7 files changed · +379 −78
src/ChartInternal/internals/legend.ts+1 −5 modified@@ -12,7 +12,6 @@ import { isDefined, isEmpty, isFunction, - notEmpty, sanitize, toMap, tplProcess @@ -872,12 +871,9 @@ export default { if (usePoint) { const ids: string[] = []; + const pattern = $$.getValidPointPattern(); l.append(d => { - const pattern = notEmpty(config.point_pattern) ? - config.point_pattern : - [config.point_type]; - ids.indexOf(d) === -1 && ids.push(d); let point = pattern[ids.indexOf(d) % pattern.length];
src/ChartInternal/shape/point.common.ts+19 −1 modified@@ -67,6 +67,8 @@ export default { * @private */ hasValidPointType(type?: string): boolean { + // For point.pattern, allow additional SVG shape tags (polygon, ellipse, use) + // These will be sanitized before use return /^(circle|rect(angle)?|polygon|ellipse|use)$/i.test(type || this.config.point_type); }, @@ -87,6 +89,22 @@ export default { return `${datetimeId}-point${id}`; }, + /** + * Get validated point pattern array + * @returns {Array} Array of point types + * @private + */ + getValidPointPattern(): string[] { + const {config} = this; + + // Ensure point_type is restricted to 'circle' or 'rectangle' only + const validPointType = /^(circle|rect(angle)?)$/i.test(config.point_type) ? + config.point_type : + "circle"; + + return notEmpty(config.point_pattern) ? config.point_pattern : [validPointType]; + }, + /** * Get generate point function * @returns {function} @@ -96,7 +114,7 @@ export default { const $$ = this; const {$el, config} = $$; const ids: string[] = []; - const pattern = notEmpty(config.point_pattern) ? config.point_pattern : [config.point_type]; + const pattern = $$.getValidPointPattern(); return function(method, context, ...args) { return function(d) {
src/config/Options/common/legend.ts+1 −0 modified@@ -25,6 +25,7 @@ export default { * - title {string}: data's id value * - color {string}: color string * - data {Array}: data array + * - **NOTE:** While basic XSS sanitization is applied, if you're allowing user-provided chart options in a service exposed to other users, you should implement additional security measures to prevent sophisticated XSS attacks. * @property {string} [legend.position=bottom] Change the position of legend.<br> * Available values are: `bottom`, `right` and `inset` are supported. * @property {object} [legend.inset={anchor: 'top-left',x: 10,y: 0,step: undefined}] Change inset legend attributes.<br>
src/config/Options/common/point.ts+1 −0 modified@@ -59,6 +59,7 @@ export default { * - This is an `experimental` feature and can have some unexpected behaviors. * - If chart has 'bubble' type, only circle can be used. * - For IE, non circle point expansions are not supported due to lack of transform support. + * - While basic XSS sanitization is applied, if you're allowing user-provided chart options in a service exposed to other users, you should implement additional security measures to prevent sophisticated XSS attacks. * - **Available Values:** * - circle * - rectangle
src/config/Options/common/tooltip.ts+1 −0 modified@@ -58,6 +58,7 @@ export default { * - **{=COLOR}**: data color. * - **{=NAME}**: data id value. * - **{=VALUE}**: data value. + * - **NOTE:** While basic XSS sanitization is applied, if you're allowing user-provided chart options in a service exposed to other users, you should implement additional security measures to prevent sophisticated XSS attacks. * @property {object} [tooltip.contents.text=undefined] Set additional text content within data loop, using template syntax. * - **NOTE:** It should contain `{ key: Array, ... }` value * - 'key' name is used as substitution within template as '{=KEY}'
src/module/util.ts+162 −72 modified@@ -8,6 +8,148 @@ import {pointer as d3Pointer} from "d3-selection"; import type {d3Selection} from "../../types/types"; import {document, requestAnimationFrame, window} from "./browser"; +// ==================================== +// Internal Helper (Not Exported) +// ==================================== + +/** + * Get boundingClientRect or BBox with caching. + * Internal helper for getBoundingRect() and getBBox() + * @param {boolean} relativeViewport Relative to viewport - true: will use .getBoundingClientRect(), false: will use .getBBox() + * @param {SVGElement} node Target element + * @param {boolean} forceEval Force evaluation + * @returns {object} + * @private + */ +function _getRect( + relativeViewport: boolean, + node: SVGElement & Partial<{rect: DOMRect | SVGRect}>, + forceEval = false +): DOMRect | SVGRect { + const _ = n => n[relativeViewport ? "getBoundingClientRect" : "getBBox"](); + + if (forceEval) { + return _(node); + } else { + // will cache the value if the element is not a SVGElement or the width is not set + const needEvaluate = !("rect" in node) || ( + "rect" in node && node.hasAttribute("width") && + node.rect!.width !== +(node.getAttribute("width") || 0) + ); + + return needEvaluate ? (node.rect = _(node)) : node.rect!; + } +} + +/** + * Internal helper to iterate over array items and invoke a callback for each valid item + * @param {Array} items Array to iterate + * @param {function} callback Callback function (item, index) => void + * @private + */ +function _forEachValidItem<T>(items: T[], callback: (item: T, index: number) => void): void { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + + if (item) { + callback(item, i); + } + } +} + +/** + * Private sanitization utilities + * Encapsulates all XSS prevention patterns and helper functions + * @private + */ +const _sanitize = (() => { + const DANGEROUS_TAGS = + "script|iframe|object|embed|form|input|button|textarea|select|style|link|meta|base|math|isindex"; + + // HTML entity character code map (for decoding) + const ENTITY_MAP = { + quot: 34, + amp: 38, + apos: 39, + lt: 60, + gt: 62, + nbsp: 160, + iexcl: 161, + cent: 162, + pound: 163, + curren: 164, + yen: 165 + }; + + // Angle bracket codes (< and >) - never decode these to prevent tag bypass + const LT_CODE = 60; + const GT_CODE = 62; + + // Maximum sanitization iterations to prevent infinite loops + const MAX_ITERATIONS = 10; + + // Regular expressions (compiled once for performance) + const rx = { + tags: new RegExp( + `<(${DANGEROUS_TAGS})\\b[\\s\\S]*?>([\\s\\S]*?<\\/(${DANGEROUS_TAGS})\\s*>)?`, + "gi" + ), + htmlEntity: /&#x([0-9a-f]+);?|&#([0-9]+);?|&([a-z]+);/gi, + // eslint-disable-next-line no-control-regex + controlChar: /[\x00-\x1F\x7F]/g, + eventHandler: + /\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|`[^`]*`|[^\s>]*)|on\w+\s*=\s*(?:"[^"]*"|'[^']*'|`[^`]*`|[^\s>]*)/gi, + dangerousUri: /(href|src|action|xlink:href)\s*=\s*(['"`]?)([^'"`>\s]*)\2/gi, + dangerousProtocol: /^(javascript|data|vbscript):/ + }; + + return { + ENTITY_MAP, + LT_CODE, + GT_CODE, + MAX_ITERATIONS, + rx, + + /** + * Decode HTML entities to prevent bypass attacks + * @param {string} str String with potential HTML entities + * @returns {string} Decoded string + */ + decodeEntities(str: string): string { + return str.replace(rx.htmlEntity, (match, hex, dec, named) => { + const code = hex ? + parseInt(hex, 16) : + dec ? + parseInt(dec, 10) : + named ? + ENTITY_MAP[named.toLowerCase()] || 0 : + 0; + + // Never decode angle brackets to prevent tag bypass + return code && code !== LT_CODE && code !== GT_CODE ? + String.fromCharCode(code) : + match; + }); + }, + + /** + * Remove dangerous URI protocols from attribute values + * @param {string} str String to sanitize + * @returns {string} Sanitized string + */ + removeDangerousUris(str: string): string { + return str.replace(rx.dangerousUri, (match, attr, quote, value) => { + const normalized = value.toLowerCase().replace(/\s/g, ""); + return rx.dangerousProtocol.test(normalized) ? `${attr}=${quote}${quote}` : match; + }); + } + }; +})(); + +// ==================================== +// Exported +// ==================================== + const isValue = (v: any): boolean => v || v === 0; const isFunction = (v: unknown): v is (...args: any[]) => any => typeof v === "function"; const isString = (v: unknown): v is string => typeof v === "string"; @@ -116,42 +258,43 @@ function endall(transition, cb: Function): void { } } -// Sanitize patterns (blacklist approach with repeated application) -const DANGEROUS_TAGS = - "script|iframe|object|embed|form|input|button|textarea|select|style|link|meta|base|math|isindex"; -const sanitizeRx = { - tags: new RegExp( - `<(${DANGEROUS_TAGS})\\b[\\s\\S]*?>([\\s\\S]*?<\\/(${DANGEROUS_TAGS})\\s*>)?`, - "gi" - ), - // Handles: whitespace, slash, quotes before event handlers (e.g., <img/onerror=...>, <img src="x"onerror=...>) - eventHandlers: /[\s/"']+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, - // Handles: javascript/data/vbscript URIs with optional whitespace/newlines between protocol and colon - dangerousURIs: /(href|src|action|xlink:href)\s*=\s*["']?\s*(javascript|data|vbscript)\s*:/gi -}; - /** * Sanitize HTML string to prevent XSS attacks * Uses blacklist approach with repeated application to prevent nested tag bypass + * Handles encoded characters (HTML entities, Unicode escapes) to prevent bypass attacks * @param {string} str Target string value * @returns {string} Sanitized string with dangerous elements removed * @private */ function sanitize(str: string): string { + // Early return for non-string, empty string, or string without HTML if (!isString(str) || !str || str.indexOf("<") === -1) { return str; } let result = str; let prev: string; + let iterations = 0; // Repeat until no more changes (prevents nested tag attacks like <scri<script>pt>) do { prev = result; - result = result - .replace(sanitizeRx.tags, "") - .replace(sanitizeRx.eventHandlers, "") - .replace(sanitizeRx.dangerousURIs, "$1=\"\""); + + // 1. Decode HTML entities to prevent bypass (e.g., jav
ascript:) + // 2. Remove control characters (NULL, tab, newline, etc.) + // 3. Remove dangerous tags (script, iframe, etc.) + result = _sanitize.decodeEntities(result) + .replace(_sanitize.rx.controlChar, "") + .replace(_sanitize.rx.tags, "") + .replace(_sanitize.rx.eventHandler, ""); // 4. Remove event handlers + + // 5. Remove dangerous URIs (javascript:, data:, vbscript:) + result = _sanitize.removeDangerousUris(result); + + // Safety check to prevent infinite loops + if (++iterations >= _sanitize.MAX_ITERATIONS) { + break; + } } while (result !== prev); return result; @@ -250,7 +393,7 @@ function getPathBox( * @returns {Array} [x, y] Coordinates x, y array * @private */ -function getPointer(event, element?: SVGElement): number[] { +function getPointer(event, element?: HTMLElement | SVGElement): number[] { const touches = event && (event.touches || (event.sourceEvent && event.sourceEvent.touches))?.[0]; let pointer = [0, 0]; @@ -284,59 +427,6 @@ function getBrushSelection(ctx) { return selection; } -// ==================================== -// Internal Helper (Not Exported) -// ==================================== - -/** - * Get boundingClientRect or BBox with caching. - * Internal helper for getBoundingRect() and getBBox() - * @param {boolean} relativeViewport Relative to viewport - true: will use .getBoundingClientRect(), false: will use .getBBox() - * @param {SVGElement} node Target element - * @param {boolean} forceEval Force evaluation - * @returns {object} - * @private - */ -function _getRect( - relativeViewport: boolean, - node: SVGElement & Partial<{rect: DOMRect | SVGRect}>, - forceEval = false -): DOMRect | SVGRect { - const _ = n => n[relativeViewport ? "getBoundingClientRect" : "getBBox"](); - - if (forceEval) { - return _(node); - } else { - // will cache the value if the element is not a SVGElement or the width is not set - const needEvaluate = !("rect" in node) || ( - "rect" in node && node.hasAttribute("width") && - node.rect!.width !== +(node.getAttribute("width") || 0) - ); - - return needEvaluate ? (node.rect = _(node)) : node.rect!; - } -} - -/** - * Internal helper to iterate over array items and invoke a callback for each valid item - * @param {Array} items Array to iterate - * @param {function} callback Callback function (item, index) => void - * @private - */ -function _forEachValidItem<T>(items: T[], callback: (item: T, index: number) => void): void { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - if (item) { - callback(item, i); - } - } -} - -// ==================================== -// Exported -// ==================================== - /** * Get boundingClientRect. * @param {SVGElement} node Target element
test/internals/util-spec.ts+194 −0 modified@@ -230,6 +230,200 @@ describe("UTIL", function() { // Multiple levels of nesting expect(sanitize("<scr<scr<script></script>ipt></script>ipt>alert(3)</script>")).to.not.include("script"); }); + + it("should handle HTML entity encoded attacks", () => { + // Newline in javascript: URL + expect(sanitize("<a href='jav
ascript:alert(1)'>click</a>")).to.not.include("javascript:"); + + // NULL byte in javascript: URL + expect(sanitize("<a href='java�script:alert(1)'>click</a>")).to.not.include("javascript:"); + + // Tab character + expect(sanitize("<a href='java	script:alert(1)'>click</a>")).to.not.include("javascript:"); + + // Fully encoded javascript: + expect(sanitize("<a href='javascript:alert(1)'>click</a>")).to.not.include("javascript:"); + + // Hex encoded entities + expect(sanitize("<a href='javascript:alert(1)'>click</a>")).to.not.include("javascript:"); + + // Mixed encoding + expect(sanitize("<a href='javascript:alert(1)'>click</a>")).to.not.include("javascript:"); + + // Hex encoding with single character (v = 'v') + expect(sanitize("<a href='javascript:alert(123)'>click</a>")).to.not.include("javascript:"); + + // Decimal encoding with padding zeros (v = 'v') + expect(sanitize("<a href='javascript:alert(123)'>click</a>")).to.not.include("javascript:"); + + // Decimal encoding (v = 'v') + expect(sanitize("<a href='javascript:alert(123)'>click</a>")).to.not.include("javascript:"); + }); + + it("should handle control character injection", () => { + // NULL byte + expect(sanitize("<script\x00>alert(1)</script>")).to.not.include("script"); + + // Tab character in tag + expect(sanitize("<script\t>alert(1)</script>")).to.not.include("script"); + + // Newline in tag + expect(sanitize("<script\n>alert(1)</script>")).to.not.include("script"); + + // Carriage return + expect(sanitize("<script\r>alert(1)</script>")).to.not.include("script"); + + // Carriage return + newline in URL (testing \r\n in javascript:) + expect(sanitize("<a href='jav\r\nascript:alert(123)'>click</a>")).to.not.include("javascript:"); + }); + + it("should handle various quote styles in event handlers", () => { + // Double quotes + expect(sanitize("<img src=x onerror=\"alert(1)\">")).to.not.include("onerror"); + + // Single quotes + expect(sanitize("<img src=x onerror='alert(1)'>")).to.not.include("onerror"); + + // Backticks + expect(sanitize("<img src=x onerror=`alert(1)`>")).to.not.include("onerror"); + + // No quotes + expect(sanitize("<img src=x onerror=alert(1)>")).to.not.include("onerror"); + + // Space before attribute + expect(sanitize("<img src=x onerror=alert(1)>")).to.not.include("onerror"); + }); + + it("should handle data: URI attacks", () => { + // HTML in data URI - the script tag is removed + const result1 = sanitize("<a href='data:text/html,<script>alert(1)</script>'>click</a>"); + expect(result1).to.not.include("script"); + expect(result1).to.not.include("data:"); + + // Base64 encoded data URI - data: protocol is removed + expect(sanitize("<a href='data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='>click</a>")).to.not.include("data:"); + + // Data URI with SVG - the onload event handler is removed, dangerous tags are removed + const svgResult = sanitize("<img src='data:image/svg+xml,<svg onload=alert(1)>'>"); + expect(svgResult).to.not.include("onload"); + // Note: data: in attribute values that don't contain dangerous content may remain + }); + + it("should handle vbscript: protocol attacks", () => { + expect(sanitize("<a href='vbscript:msgbox(1)'>click</a>")).to.not.include("vbscript:"); + expect(sanitize("<a href='VBScript:MsgBox(1)'>click</a>")).to.not.include("vbscript:"); + expect(sanitize("<img src='vbscript:msgbox(1)'>")).to.not.include("vbscript:"); + }); + + it("should handle SVG-based XSS attacks", () => { + // SVG with onload + expect(sanitize("<svg onload='alert(1)'></svg>")).to.not.include("onload"); + + // SVG with nested script + expect(sanitize("<svg><script>alert(1)</script></svg>")).to.not.include("script"); + + // SVG with animate and set + const result = sanitize("<svg><animate onbegin='alert(1)'/></svg>"); + expect(result).to.not.include("onbegin"); + }); + + it("should handle case variations", () => { + // Mixed case script tag + expect(sanitize("<ScRiPt>alert(1)</sCrIpT>")).to.not.include("script"); + + // Mixed case event handler + expect(sanitize("<img src=x OnErRoR=alert(1)>")).to.not.include("onerror"); + + // Mixed case protocol + expect(sanitize("<a href='JaVaScRiPt:alert(1)'>click</a>")).to.not.include("javascript:"); + }); + + it("should handle whitespace variations", () => { + // Space in tag name - "scr ipt" is not recognized as "script" tag + // The regex only matches <script> as complete tag name, so this passes through + // But the closing </script> tag is recognized and removed + const result = sanitize("<scr ipt>alert(1)</script>"); + // Since the opening tag isn't recognized as script, content may remain + expect(result).to.include("alert(1)"); + + // Newlines around equals + expect(sanitize("<img src=x\nonerror\n=\nalert(1)>")).to.not.include("onerror"); + + // Multiple spaces + expect(sanitize("<img src=x onerror=alert(1)>")).to.not.include("onerror"); + }); + + it("should handle obfuscated attacks", () => { + // Nested encoding + expect(sanitize("<scri<script>pt>alert(1)</scri</script>pt>")).to.not.include("script"); + + // Multiple nested levels + expect(sanitize("<scr<scr<script>x</script>ipt>y</script>ipt>alert(1)</script>")).to.not.include("script"); + + // Mixed nesting with different tags + expect(sanitize("<scr<iframe></iframe>ipt>alert(1)</script>")).to.not.include("script"); + }); + + it("should preserve safe content", () => { + // Normal URLs + expect(sanitize("<a href='https://example.com'>link</a>")).to.include("https://example.com"); + expect(sanitize("<a href='http://example.com'>link</a>")).to.include("http://example.com"); + expect(sanitize("<a href='/path/to/page'>link</a>")).to.include("/path/to/page"); + expect(sanitize("<a href='#section'>link</a>")).to.include("#section"); + + // Safe protocols + expect(sanitize("<a href='mailto:test@example.com'>email</a>")).to.include("mailto:"); + expect(sanitize("<a href='tel:+1234567890'>call</a>")).to.include("tel:"); + + // Normal HTML with classes and attributes + expect(sanitize("<div class='container' id='main'>content</div>")).to.be.equal("<div class='container' id='main'>content</div>"); + expect(sanitize("<span style='color:red'>text</span>")).to.be.equal("<span style='color:red'>text</span>"); + }); + + it("should handle edge cases", () => { + // Empty attributes + expect(sanitize("<a href=''>empty link</a>")).to.be.equal("<a href=''>empty link</a>"); + + // Multiple dangerous elements + const input = "<script>x</script><iframe></iframe><object></object>"; + const result = sanitize(input); + expect(result).to.not.include("script"); + expect(result).to.not.include("iframe"); + expect(result).to.not.include("object"); + + // Mixed safe and dangerous + const mixed = "<div>safe</div><script>dangerous</script><p>also safe</p>"; + const cleaned = sanitize(mixed); + expect(cleaned).to.include("<div>safe</div>"); + expect(cleaned).to.include("<p>also safe</p>"); + expect(cleaned).to.not.include("script"); + }); + + it("should handle real-world attack patterns", () => { + // PortSwigger XSS patterns + expect(sanitize("<img src=1 onerror=alert(1)>")).to.not.include("onerror"); + expect(sanitize("<svg><animatetransform onbegin=alert(1)>")).to.not.include("onbegin"); + + // OWASP patterns with entity encoding + expect(sanitize("<IMG SRC=jAvascript:alert('test')>")).to.not.include("javascript:"); + + // Backtick quoted attributes - known limitation + // Current implementation handles quotes and double-quotes but backticks in attributes are edge case + const backtickResult = sanitize("<IMG SRC=`javascript:alert('XSS')`>"); + // We verify that at minimum the structure is preserved (not broken) + expect(backtickResult).to.include("IMG"); + + // Mutation XSS (mXSS) - event handlers are removed + expect(sanitize("<noscript><p title=\"</noscript><img src=x onerror=alert(1)>\">")).to.not.include("onerror"); + + // Polyglot XSS - complex attack string with multiple vectors + const polyglotResult = sanitize("javascript:/*--></title></style></textarea></script></xmp><svg/onload='+/\"/+/onmouseover=1/+/[*/[]/+alert(1)//'>"); + // Event handlers should be removed + expect(polyglotResult).to.not.include("onload="); + expect(polyglotResult).to.not.include("onmouseover="); + // The main dangerous content (event handlers) is removed, making the payload harmless + // Note: Closing tags without opening tags may remain but are harmless in HTML + }); }); describe("parseDate", () => {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- cve.naver.com/detail/cve-2026-1513.htmlghsavendor-advisoryWEB
- github.com/advisories/GHSA-rpc5-pm7q-hjmpghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-1513ghsaADVISORY
- github.com/naver/billboard.js/commit/49e079cdd466fc8ba7ab208988181e5b7a5f336bghsaWEB
- github.com/naver/billboard.js/issues/4078ghsaWEB
News mentions
0No linked articles in our index yet.