|
| 1 | +/** |
| 2 | + * XSS regression for renderHistoryFilterButton (issue #166 follow-up). |
| 3 | + * |
| 4 | + * renderHistoryFilterButton injects `column` and `label` into HTML attribute |
| 5 | + * values (data-column, aria-label, title) via template literal. Any caller |
| 6 | + * passing a user-controlled string without escaping would produce a stored-XSS |
| 7 | + * vector. escapeHtmlAttr must be applied to both values before interpolation. |
| 8 | + */ |
| 9 | + |
| 10 | +import { renderHistoryFilterButton } from '../lib/history-filter-popover'; |
| 11 | + |
| 12 | +// Use the real escapeHtmlAttr so DOM-based escaping is exercised, not a |
| 13 | +// pass-through stub. |
| 14 | +jest.mock('../lib/column-filters', () => ({ |
| 15 | + parseNumericFilter: jest.fn(), |
| 16 | +})); |
| 17 | + |
| 18 | +describe('renderHistoryFilterButton: attribute-injection XSS guard', () => { |
| 19 | + // This payload attempts to break out of the aria-label attribute and inject |
| 20 | + // a script element. In raw form it would produce: |
| 21 | + // aria-label=""><script>alert(1)</script><span x="" |
| 22 | + const SCRIPT_PAYLOAD = '"><script>alert(1)</script><span x="'; |
| 23 | + // This payload injects an event handler attribute. |
| 24 | + const EVENT_PAYLOAD = '" onmouseover="alert(1)" x="'; |
| 25 | + |
| 26 | + test('hostile label does not inject raw <script> tag into attribute context', () => { |
| 27 | + const html = renderHistoryFilterButton('provider', SCRIPT_PAYLOAD, false); |
| 28 | + // The raw < > and " chars must be entity-encoded; no unescaped angle brackets. |
| 29 | + expect(html).not.toContain('"><script>'); |
| 30 | + expect(html).not.toContain('</script>'); |
| 31 | + // The encoded form is present (attribute value is escaped, not stripped). |
| 32 | + expect(html).toContain('<script>'); |
| 33 | + }); |
| 34 | + |
| 35 | + test('hostile label does not inject event handler attribute', () => { |
| 36 | + const html = renderHistoryFilterButton('provider', EVENT_PAYLOAD, false); |
| 37 | + // The injected " must be encoded; no raw onmouseover= outside an attribute value. |
| 38 | + expect(html).not.toContain('" onmouseover='); |
| 39 | + // The encoded form must appear inside the attribute value. |
| 40 | + expect(html).toContain('" onmouseover='); |
| 41 | + }); |
| 42 | + |
| 43 | + test('hostile column id does not inject raw markup into data-column', () => { |
| 44 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 45 | + const html = renderHistoryFilterButton(SCRIPT_PAYLOAD as any, 'Provider', false); |
| 46 | + expect(html).not.toContain('><script>'); |
| 47 | + expect(html).not.toContain('</script>'); |
| 48 | + expect(html).toContain('<script>'); |
| 49 | + }); |
| 50 | + |
| 51 | + test('safe column and label pass through correctly', () => { |
| 52 | + const html = renderHistoryFilterButton('provider', 'Provider', false); |
| 53 | + expect(html).toContain('data-column="provider"'); |
| 54 | + expect(html).toContain('aria-label="Filter Provider"'); |
| 55 | + expect(html).toContain('class="history-column-filter-btn"'); |
| 56 | + }); |
| 57 | + |
| 58 | + test('active flag adds "active" class and updates aria-label', () => { |
| 59 | + const html = renderHistoryFilterButton('savings', 'Monthly Savings', true); |
| 60 | + expect(html).toContain('class="history-column-filter-btn active"'); |
| 61 | + expect(html).toContain('aria-label="Filter Monthly Savings - currently active"'); |
| 62 | + }); |
| 63 | +}); |
0 commit comments