Moderate severityNVD Advisory· Published Mar 26, 2026· Updated Mar 27, 2026
Invoice Ninja Denylist Bypass may Lead to Stored XSS via Invoice Line Items
CVE-2026-33628
Description
Invoice Ninja is a source-available invoice, quote, project and time-tracking app built with Laravel. Invoice line item descriptions in Invoice Ninja v5.13.0 bypass the XSS denylist filter, allowing stored XSS payloads to execute when invoices are rendered in the PDF preview or client portal. The line item description field was not passed through purify::clean() before rendering. This is fixed in v5.13.4 by the vendor by adding purify::clean() to sanitize line item descriptions.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
invoiceninja/invoiceninjaPackagist | < 5.13.4 | 5.13.4 |
Affected products
1- Range: < 5.13.4
Patches
1b81a3fc30257Refactor sanitization of elements
8 files changed · +513 −20
app/Http/Requests/Product/StoreProductRequest.php+6 −0 modified@@ -77,6 +77,12 @@ public function prepareForValidation() $input['tax_name2'] ??= ''; $input['tax_name3'] ??= ''; + foreach (['notes', 'product_key', 'custom_value1', 'custom_value2', 'custom_value3', 'custom_value4'] as $field) { + if (isset($input[$field]) && is_string($input[$field]) && (str_contains($input[$field], '<') || str_contains($input[$field], '<') || str_contains($input[$field], '&#'))) { + $input[$field] = \App\Services\Pdf\Purify::clean($input[$field], true); + } + } + $this->replace($input); } }
app/Http/Requests/Product/UpdateProductRequest.php+6 −0 modified@@ -75,6 +75,12 @@ public function prepareForValidation() unset($input['in_stock_quantity']); } + foreach (['notes', 'product_key', 'custom_value1', 'custom_value2', 'custom_value3', 'custom_value4'] as $field) { + if (isset($input[$field]) && is_string($input[$field]) && (str_contains($input[$field], '<') || str_contains($input[$field], '<') || str_contains($input[$field], '&#'))) { + $input[$field] = \App\Services\Pdf\Purify::clean($input[$field], true); + } + } + $this->replace($input); } }
app/Http/Requests/Request.php+16 −11 modified@@ -218,21 +218,26 @@ public function decodePrimaryKeys($input) } } - if (isset($input['public_notes'])) { - $input['public_notes'] = str_replace("</sc", "<-", $input['public_notes']); + foreach (['public_notes', 'footer', 'terms', 'private_notes'] as $field) { + if (isset($input[$field]) && is_string($input[$field]) && (str_contains($input[$field], '<') || str_contains($input[$field], '<') || str_contains($input[$field], '&#'))) { + $input[$field] = \App\Services\Pdf\Purify::clean($input[$field], true); + } } + // if (isset($input['public_notes'])) { + // $input['public_notes'] = str_replace("</sc", "<-", $input['public_notes']); + // } - if (isset($input['footer'])) { - $input['footer'] = str_replace("</sc", "<-", $input['footer']); - } + // if (isset($input['footer'])) { + // $input['footer'] = str_replace("</sc", "<-", $input['footer']); + // } - if (isset($input['terms'])) { - $input['terms'] = str_replace("</sc", "<-", $input['terms']); - } + // if (isset($input['terms'])) { + // $input['terms'] = str_replace("</sc", "<-", $input['terms']); + // } - if (isset($input['private_notes'])) { - $input['private_notes'] = str_replace("</sc", "<-", $input['private_notes']); - } + // if (isset($input['private_notes'])) { + // $input['private_notes'] = str_replace("</sc", "<-", $input['private_notes']); + // } return $input; }
app/Models/Product.php+3 −1 modified@@ -230,7 +230,9 @@ public static function markdownHelp(?string $notes = '') ], ]); - return $converter->convert($notes ?? ''); + $markdown_to_html = $converter->convert($notes ?? ''); + + return \App\Services\Pdf\Purify::clean($markdown_to_html, true); }
app/Services/Pdf/Purify.php+23 −3 modified@@ -18,7 +18,8 @@ class Purify // Text Elements 'span', 'strong', 'em', 'b', 'i', 'u', 'small', - 'sub', 'sup', 'del', 'ins', + 'sub', 'sup', 'del', 'ins', 'code', 's', 'mark', + 'abbr', 'q', 'cite', // Line Breaks 'br', 'hr', @@ -28,10 +29,17 @@ class Purify // Tables 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', + 'caption', 'colgroup', 'col', // Media & Links 'img', 'a', + // Figures + 'figure', 'figcaption', + + // Address + 'address', + // Template specific 'ninja', @@ -248,7 +256,7 @@ private static function isDangerousSvgElement(string $tagName): bool return in_array(strtolower($tagName), self::$dangerous_svg_elements); } - public static function clean(string $html): string + public static function clean(string $html, bool $is_fragment = false): string { if (config('ninja.disable_purify_html') || strlen($html) <= 1) { @@ -428,7 +436,19 @@ public static function clean(string $html): string $cleanNodes($document->documentElement); - $html = str_replace('%24', '$', $document->saveHTML()); + if ($is_fragment) { + $body = $document->getElementsByTagName('body')->item(0); + $html = ''; + if ($body) { + foreach ($body->childNodes as $child) { + $html .= $document->saveHTML($child); + } + } + } else { + $html = $document->saveHTML(); + } + + $html = str_replace('%24', '$', $html); // nlog("post purify => {$html}"); return $html;
app/Services/Template/TemplateService.php+2 −2 modified@@ -101,7 +101,7 @@ private function init(): self $loader = new \Twig\Loader\FilesystemLoader(storage_path()); $this->twig = new \Twig\Environment($loader, [ - 'debug' => true, + 'debug' => config('ninja.debug_enabled'), ]); $string_extension = new \Twig\Extension\StringLoaderExtension(); @@ -162,7 +162,7 @@ public function load($class) $allowedTags = ['if', 'for', 'set', 'filter']; $allowedFilters = ['url_encode','default', 'groupBy','capitalize', 'abs', 'date_modify', 'keys', 'join', 'reduce', 'format_date','json_decode','date_modify','trim','round','format_spellout_number','split', 'reduce','replace', 'escape', 'e', 'reverse', 'shuffle', 'slice', 'batch', 'title', 'sort', 'split', 'upper', 'lower', 'capitalize', 'filter', 'length', 'merge','format_currency', 'format_number','format_percent_number','map', 'join', 'first', 'date', 'sum', 'number_format','nl2br','striptags','markdown_to_html']; - $allowedFunctions = ['range', 'cycle', 'constant', 'date','img','t']; + $allowedFunctions = ['range', 'cycle', 'date', 'img', 't']; $allowedProperties = ['type_id']; // $allowedMethods = ['img','t']; $allowedMethods = [
app/Utils/Traits/CleanLineItems.php+3 −3 modified@@ -74,10 +74,10 @@ private function cleanLineItem($item) $item['tax_id'] = ($item['type_id'] === '2') ? '2' : '1'; } - $xss_patterns = ["</sc", "onerror", "prompt(", "alert("]; foreach (['notes', 'product_key', 'custom_value1', 'custom_value2', 'custom_value3', 'custom_value4'] as $field) { - if (!empty($item[$field]) && is_string($item[$field])) { - $item[$field] = str_replace($xss_patterns, "<-", $item[$field]); + if (isset($item[$field]) && is_string($item[$field]) && (str_contains($item[$field], '<') || str_contains($item[$field], '<') || str_contains($item[$field], '&#'))) { + $item[$field] = \App\Services\Pdf\Purify::clean($item[$field], true); + nlog($item[$field]); } }
tests/Unit/XssSanitizationTest.php+454 −0 added@@ -0,0 +1,454 @@ +<?php + +/** + * Invoice Ninja (https://invoiceninja.com). + * + * @link https://github.com/invoiceninja/invoiceninja source repository + * + * @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com) + * + * @license https://www.elastic.co/licensing/elastic-license + */ + +namespace Tests\Unit; + +use App\Models\Product; +use App\Services\Pdf\Purify; +use App\Utils\Traits\CleanLineItems; +use Tests\TestCase; + +class XssSanitizationTest extends TestCase +{ + use CleanLineItems; + + // ========================================================================= + // Vulnerability #2 — CleanLineItems: denylist bypass payloads + // Each test uses an exact payload from the security report + // ========================================================================= + + public function test_svg_onload_is_stripped() + { + $result = $this->cleanItems([['notes' => '<svg onload=confirm(document.domain)>']]); + + $this->assertStringNotContainsString('onload', $result[0]['notes']); + $this->assertStringNotContainsString('confirm(', $result[0]['notes']); + } + + public function test_details_ontoggle_is_stripped() + { + $result = $this->cleanItems([['notes' => '<details open ontoggle=confirm(1)>']]); + + $this->assertStringNotContainsString('ontoggle', $result[0]['notes']); + $this->assertStringNotContainsString('confirm(', $result[0]['notes']); + } + + public function test_input_onfocus_is_stripped() + { + $result = $this->cleanItems([['notes' => '<input onfocus=confirm(1) autofocus>']]); + + $this->assertStringNotContainsString('onfocus', $result[0]['notes']); + $this->assertStringNotContainsString('confirm(', $result[0]['notes']); + } + + public function test_javascript_uri_is_stripped() + { + $result = $this->cleanItems([['notes' => '<a href="javascript:confirm(1)">Click</a>']]); + + $this->assertStringNotContainsString('javascript:', $result[0]['notes']); + } + + public function test_onmouseover_is_stripped() + { + $result = $this->cleanItems([['notes' => '<img src=x onmouseover=alert(1)>']]); + + $this->assertStringNotContainsString('onmouseover', $result[0]['notes']); + $this->assertStringNotContainsString('alert(', $result[0]['notes']); + } + + public function test_marquee_onstart_is_stripped() + { + $result = $this->cleanItems([['notes' => '<marquee onstart=alert(1)>']]); + + $this->assertStringNotContainsString('onstart', $result[0]['notes']); + $this->assertStringNotContainsString('alert(', $result[0]['notes']); + } + + public function test_body_onpageshow_is_stripped() + { + $result = $this->cleanItems([['notes' => '<body onpageshow=alert(1)>']]); + + $this->assertStringNotContainsString('onpageshow', $result[0]['notes']); + $this->assertStringNotContainsString('alert(', $result[0]['notes']); + } + + public function test_all_report_bypass_payloads_are_neutralized() + { + $payloads = [ + '<svg onload=confirm(document.domain)>', + '<details open ontoggle=confirm(1)>', + '<input onfocus=confirm(1) autofocus>', + '<a href="javascript:confirm(1)">Click</a>', + '<img src=x onmouseover=alert(1)>', + ]; + + foreach ($payloads as $payload) { + $result = $this->cleanItems([['notes' => $payload]]); + $this->assertDoesNotMatchRegularExpression('/\bon\w+\s*=/i', $result[0]['notes'], "Event handler survived in: {$payload}"); + $this->assertStringNotContainsString('javascript:', $result[0]['notes'], "javascript: URI survived in: {$payload}"); + } + } + + public function test_encoded_html_entity_xss_is_caught() + { + $result = $this->cleanItems([['notes' => '<script>alert(1)</script>']]); + + $this->assertStringNotContainsString('<script>', $result[0]['notes']); + } + + public function test_numeric_entity_xss_is_caught() + { + $result = $this->cleanItems([['notes' => '<script>alert(1)</script>']]); + + $this->assertStringNotContainsString('<script>', $result[0]['notes']); + } + + public function test_hex_entity_xss_is_caught() + { + $result = $this->cleanItems([['notes' => '<script>alert(1)</script>']]); + + $this->assertStringNotContainsString('<script>', $result[0]['notes']); + } + + public function test_safe_html_is_preserved_in_line_items() + { + $result = $this->cleanItems([['notes' => '<b>Bold</b> and <em>emphasis</em>']]); + + $this->assertStringContainsString('<b>', $result[0]['notes']); + $this->assertStringContainsString('<em>', $result[0]['notes']); + $this->assertStringContainsString('Bold', $result[0]['notes']); + $this->assertStringContainsString('emphasis', $result[0]['notes']); + } + + public function test_plain_text_is_untouched_in_line_items() + { + $text = 'This is a normal product description. Price: $49.99 (10% off)'; + $result = $this->cleanItems([['notes' => $text]]); + + $this->assertEquals($text, $result[0]['notes']); + } + + public function test_all_six_fields_are_sanitized() + { + $xss = '<script>alert(1)</script>'; + $item = [ + 'notes' => $xss, + 'product_key' => $xss, + 'custom_value1' => $xss, + 'custom_value2' => $xss, + 'custom_value3' => $xss, + 'custom_value4' => $xss, + ]; + + $result = $this->cleanItems([$item]); + + foreach (['notes', 'product_key', 'custom_value1', 'custom_value2', 'custom_value3', 'custom_value4'] as $field) { + $this->assertStringNotContainsString('<script>', $result[0][$field], "Field {$field} was not sanitized"); + $this->assertStringNotContainsString('alert(', $result[0][$field], "Field {$field} still contains alert"); + } + } + + public function test_mixed_safe_and_unsafe_html_in_line_items() + { + $result = $this->cleanItems([['notes' => '<b>Bold</b><script>alert(1)</script><em>Safe</em>']]); + + $this->assertStringContainsString('<b>', $result[0]['notes']); + $this->assertStringContainsString('<em>', $result[0]['notes']); + $this->assertStringNotContainsString('<script>', $result[0]['notes']); + $this->assertStringNotContainsString('alert(', $result[0]['notes']); + } + + // ========================================================================= + // Vulnerability #3 — Markdown HTML injection in Product notes + // ========================================================================= + + public function test_markdown_strips_script_tags() + { + $result = Product::markdownHelp("Features:\n\n<script>alert(document.cookie)</script>\n\n- Feature 1"); + + $this->assertStringNotContainsString('<script>', (string) $result); + $this->assertStringNotContainsString('alert(', (string) $result); + } + + public function test_markdown_strips_img_onerror() + { + $result = Product::markdownHelp("Features:\n\n<img src=x onerror=alert(document.cookie)>\n\n- Feature 1"); + + $this->assertStringNotContainsString('onerror', (string) $result); + } + + public function test_markdown_strips_svg_onload() + { + $result = Product::markdownHelp("Notes:\n\n<svg onload=alert(1)></svg>"); + + $this->assertStringNotContainsString('onload', (string) $result); + $this->assertStringNotContainsString('alert(', (string) $result); + } + + public function test_markdown_strips_event_handlers() + { + $payloads = [ + '<details open ontoggle=alert(1)>test</details>', + '<input onfocus=alert(1) autofocus>', + '<marquee onstart=alert(1)>', + '<body onpageshow=alert(1)>', + '<div onmouseover=alert(1)>hover</div>', + ]; + + foreach ($payloads as $payload) { + $result = (string) Product::markdownHelp("Text\n\n{$payload}\n\nMore text"); + + $this->assertDoesNotMatchRegularExpression('/\bon\w+\s*=/i', $result, "Event handler survived in: {$payload}"); + } + } + + public function test_markdown_strips_javascript_uri() + { + $result = Product::markdownHelp('[Click me](javascript:alert(1))'); + + $this->assertStringNotContainsString('javascript:', (string) $result); + } + + public function test_markdown_preserves_valid_markdown() + { + $markdown = "## Heading\n\n**Bold text** and *italic*\n\n- Item 1\n- Item 2\n\nA [link](https://example.com)"; + $result = (string) Product::markdownHelp($markdown); + + $this->assertStringContainsString('Heading', $result); + $this->assertStringContainsString('<strong>', $result); + $this->assertStringContainsString('<em>', $result); + $this->assertStringContainsString('<li>', $result); + $this->assertStringContainsString('href', $result); + $this->assertStringContainsString('example.com', $result); + } + + public function test_markdown_handles_null_notes() + { + $result = Product::markdownHelp(null); + + $this->assertNotNull($result); + } + + public function test_markdown_handles_empty_notes() + { + $result = Product::markdownHelp(''); + + $this->assertNotNull($result); + } + + // ========================================================================= + // Vulnerability #4 — Twig TemplateService hardening + // ========================================================================= + + public function test_twig_constant_not_in_allowlist() + { + $reflection = new \ReflectionClass(\App\Services\Template\TemplateService::class); + $source = file_get_contents($reflection->getFileName()); + + // Find the allowedFunctions array assignment + preg_match('/\$allowedFunctions\s*=\s*\[([^\]]+)\]/', $source, $matches); + $this->assertNotEmpty($matches, 'Could not find $allowedFunctions in TemplateService'); + + $this->assertStringNotContainsString("'constant'", $matches[1], 'constant() should not be in the Twig sandbox allowlist'); + } + + public function test_twig_debug_not_hardcoded_true() + { + $reflection = new \ReflectionClass(\App\Services\Template\TemplateService::class); + $source = file_get_contents($reflection->getFileName()); + + // Ensure debug is not hardcoded to true + $this->assertDoesNotMatchRegularExpression("/'debug'\s*=>\s*true/", $source, 'Twig debug should not be hardcoded to true'); + } + + // ========================================================================= + // Purify::clean() — core sanitizer tests + // ========================================================================= + + public function test_purify_strips_script_elements() + { + $result = Purify::clean('<p>Safe</p><script>alert(1)</script><p>Also safe</p>'); + + $this->assertStringNotContainsString('<script>', $result); + $this->assertStringNotContainsString('alert(', $result); + $this->assertStringContainsString('Safe', $result); + $this->assertStringContainsString('Also safe', $result); + } + + public function test_purify_strips_onerror_from_img() + { + $result = Purify::clean('<img src="https://example.com/img.jpg" onerror="alert(1)" style="width:100px">'); + + $this->assertStringNotContainsString('onerror', $result); + $this->assertStringNotContainsString('alert(', $result); + } + + public function test_purify_strips_all_event_handler_attributes() + { + $handlers = [ + '<div onclick="alert(1)">click</div>', + '<div onmouseover="alert(1)">hover</div>', + '<div onmouseenter="alert(1)">enter</div>', + '<div onload="alert(1)">load</div>', + '<div onfocus="alert(1)">focus</div>', + '<div onblur="alert(1)">blur</div>', + '<div onkeydown="alert(1)">key</div>', + '<div onsubmit="alert(1)">submit</div>', + '<div onanimationend="alert(1)">anim</div>', + '<div onpointerdown="alert(1)">pointer</div>', + ]; + + foreach ($handlers as $payload) { + $result = Purify::clean($payload); + $this->assertDoesNotMatchRegularExpression('/\bon\w+\s*=/i', $result, "Event handler survived in: {$payload}"); + } + } + + public function test_purify_preserves_safe_elements() + { + $html = '<div><p>Text</p><b>Bold</b><em>Italic</em><strong>Strong</strong><span>Span</span></div>'; + $result = Purify::clean($html); + + $this->assertStringContainsString('<div>', $result); + $this->assertStringContainsString('<p>', $result); + $this->assertStringContainsString('<b>', $result); + $this->assertStringContainsString('<em>', $result); + $this->assertStringContainsString('<strong>', $result); + $this->assertStringContainsString('<span>', $result); + } + + public function test_purify_preserves_safe_table_elements() + { + $html = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>'; + $result = Purify::clean($html); + + $this->assertStringContainsString('<table>', $result); + $this->assertStringContainsString('<th>', $result); + $this->assertStringContainsString('<td>', $result); + } + + public function test_purify_preserves_safe_list_elements() + { + $html = '<ul><li>Item 1</li></ul><ol><li>Item 2</li></ol>'; + $result = Purify::clean($html); + + $this->assertStringContainsString('<ul>', $result); + $this->assertStringContainsString('<ol>', $result); + $this->assertStringContainsString('<li>', $result); + } + + public function test_purify_preserves_safe_link_with_https() + { + $result = Purify::clean('<a href="https://example.com" target="_blank">Link</a>'); + + $this->assertStringContainsString('href', $result); + $this->assertStringContainsString('https://example.com', $result); + $this->assertStringContainsString('Link', $result); + } + + public function test_purify_strips_javascript_hrefs() + { + $result = Purify::clean('<a href="javascript:alert(1)">Click</a>'); + + $this->assertStringNotContainsString('javascript:', $result); + } + + public function test_purify_preserves_safe_img_with_https() + { + $result = Purify::clean('<img src="https://example.com/image.jpg" alt="Photo" style="width:100px">'); + + $this->assertStringContainsString('<img', $result); + $this->assertStringContainsString('https://example.com/image.jpg', $result); + } + + public function test_purify_strips_iframe() + { + $result = Purify::clean('<iframe src="https://evil.com"></iframe>'); + + $this->assertStringNotContainsString('<iframe', $result); + } + + public function test_purify_strips_object_and_embed() + { + $result = Purify::clean('<object data="evil.swf"></object><embed src="evil.swf">'); + + $this->assertStringNotContainsString('<object', $result); + $this->assertStringNotContainsString('<embed', $result); + } + + public function test_purify_strips_form_elements() + { + $result = Purify::clean('<form action="https://evil.com"><input type="text"><button>Submit</button></form>'); + + $this->assertStringNotContainsString('<form', $result); + $this->assertStringNotContainsString('<input', $result); + $this->assertStringNotContainsString('<button', $result); + } + + public function test_purify_handles_nested_xss_attempts() + { + $payloads = [ + '<div><script>alert(1)</script></div>', + '<p><img src=x onerror=alert(1)></p>', + '<table><tr><td><script>alert(1)</script></td></tr></table>', + '<b><a href="javascript:alert(1)">click</a></b>', + ]; + + foreach ($payloads as $payload) { + $result = Purify::clean($payload); + $this->assertStringNotContainsString('<script>', $result, "Script tag survived in: {$payload}"); + $this->assertStringNotContainsString('javascript:', $result, "javascript: URI survived in: {$payload}"); + $this->assertDoesNotMatchRegularExpression('/\bon\w+\s*=/i', $result, "Event handler survived in: {$payload}"); + } + } + + public function test_purify_strips_css_expression() + { + $result = Purify::clean('<div style="width: expression(alert(1))">test</div>'); + + $this->assertStringNotContainsString('expression', $result); + $this->assertStringNotContainsString('alert(', $result); + } + + public function test_purify_strips_css_javascript_url() + { + $result = Purify::clean('<div style="background: url(javascript:alert(1))">test</div>'); + + $this->assertStringNotContainsString('javascript:', $result); + } + + public function test_purify_short_string_skips_sanitization() + { + // Purify::clean() returns early for strings with length <= 1 + $result = Purify::clean('x'); + $this->assertEquals('x', $result); + + $result = Purify::clean(''); + $this->assertEquals('', $result); + } + + public function test_purify_preserves_safe_inline_styles() + { + $result = Purify::clean('<div style="color: red; font-size: 14px; margin: 10px">Styled</div>'); + + $this->assertStringContainsString('color:', $result); + $this->assertStringContainsString('Styled', $result); + } + + public function test_purify_preserves_data_attributes() + { + $result = Purify::clean('<div data-ref="invoice-table" data-element="product">Content</div>'); + + $this->assertStringContainsString('data-ref', $result); + $this->assertStringContainsString('data-element', $result); + } +}
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- github.com/advisories/GHSA-98wm-cxpw-847pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33628ghsaADVISORY
- github.com/invoiceninja/invoiceninja/commit/b81a3fc302573fc4a53d61e8537dd19154ce1091ghsax_refsource_MISCWEB
- github.com/invoiceninja/invoiceninja/releases/tag/v5.13.4ghsax_refsource_MISCWEB
- github.com/invoiceninja/invoiceninja/security/advisories/GHSA-98wm-cxpw-847pghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.