From a80ea4ecb1b2654877e20481be681a696fa1fcec Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 7 Jun 2026 03:07:08 -0700 Subject: [PATCH 1/9] fix(frontend): neutralise provider XSS and dedup modal/global listeners (#1034) H1 - Stored XSS in plans.ts: both planned-purchase row (line 180) and plan card detail (line 412) injected the API provider string into innerHTML class attributes and text without whitelist/escaping. Extract shared providerBadgeClass() and providerBadgeHtml() helpers to utils.ts (matching the existing whitelist in history.ts:341 and recommendations.ts:2417) and replace both raw interpolations. H3 - Event-listener stacking in plans.ts: setupRampScheduleHandlers added listeners to static modal elements on every modal open. Add rampHandlersInstalled install-once guard; export _resetRampHandlersForTest() for test isolation. D1/L4 - Shared provider badge helper: dashboard.ts was using a loose alphanumeric regex instead of the strict aws|azure|gcp whitelist. Align on providerBadgeClass() from utils.ts. Tests: add regression tests to utils.test.ts (providerBadgeClass/ providerBadgeHtml XSS neutralisation), plans.test.ts (H1 call-through and H3 install-once dedup), and update utils mocks in dashboard.test.ts and dashboard-ownership-950.test.ts for the new import. Note: H2 (users/userList.ts duplicate document change listener) was already fixed via AbortController in a prior PR merged to feat/multicloud-web-frontend. --- .../__tests__/dashboard-ownership-950.test.ts | 1 + frontend/src/__tests__/dashboard.test.ts | 4 +- frontend/src/__tests__/plans.test.ts | 103 +++++++++++++++++- frontend/src/__tests__/utils.test.ts | 78 ++++++++++++- frontend/src/dashboard.ts | 11 +- frontend/src/plans.ts | 30 ++++- frontend/src/utils.ts | 27 +++++ 7 files changed, 241 insertions(+), 13 deletions(-) diff --git a/frontend/src/__tests__/dashboard-ownership-950.test.ts b/frontend/src/__tests__/dashboard-ownership-950.test.ts index 335d1ae97..f69c46119 100644 --- a/frontend/src/__tests__/dashboard-ownership-950.test.ts +++ b/frontend/src/__tests__/dashboard-ownership-950.test.ts @@ -71,6 +71,7 @@ jest.mock('../utils', () => ({ getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })), escapeHtml: jest.fn((str) => str || ''), populateAccountFilter: jest.fn(() => Promise.resolve()), + providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''), })); import { loadDashboard } from '../dashboard'; diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 476fcdac4..8c2744b54 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -105,7 +105,9 @@ jest.mock('../utils', () => ({ formatCurrency: jest.fn((val) => `$${val || 0}`), getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })), escapeHtml: jest.fn((str) => str || ''), - populateAccountFilter: jest.fn(() => Promise.resolve()) + populateAccountFilter: jest.fn(() => Promise.resolve()), + // providerBadgeClass added for L4/D1 fix: whitelist-based CSS class helper. + providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''), })); import * as api from '../api'; diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 26ca9839c..071c9b5fd 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -1,7 +1,7 @@ /** * Plans module tests */ -import { loadPlans, savePlan, closePlanModal, openCreatePlanModal, openNewPlanModal, closePurchaseModal, setupPlanHandlers } from '../plans'; +import { loadPlans, savePlan, closePlanModal, openCreatePlanModal, openNewPlanModal, closePurchaseModal, setupPlanHandlers, _resetRampHandlersForTest } from '../plans'; // Mock the api module jest.mock('../api', () => ({ @@ -108,7 +108,11 @@ jest.mock('../utils', () => ({ getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })), escapeHtml: jest.fn((str) => str || ''), formatCurrency: jest.fn((val) => `$${val || 0}`), - populateAccountFilter: jest.fn(() => Promise.resolve()) + populateAccountFilter: jest.fn(() => Promise.resolve()), + // providerBadgeHtml added for H1 fix: returns a deterministic span so XSS + // regression tests can assert neutralisation without a real DOM escaper. + providerBadgeHtml: jest.fn((p) => `${(p||'').toUpperCase()}`), + CURRENCY_DEFAULT_DIGITS: 0, })); import * as api from '../api'; @@ -182,6 +186,9 @@ describe('Plans Module', () => { jest.clearAllMocks(); window.alert = jest.fn(); window.confirm = jest.fn().mockReturnValue(true); + // Reset ramp-handlers install-once guard: DOM is rebuilt each beforeEach, + // so static modal elements are fresh and need listeners re-attached (H3 fix). + _resetRampHandlersForTest(); }); describe('loadPlans', () => { @@ -2686,4 +2693,96 @@ describe('Plans Module', () => { expect(searchInput.disabled).toBe(false); }); }); + + // Regression tests for H3: setupRampScheduleHandlers must not stack duplicate + // listeners on static modal elements across multiple modal opens. + // Pre-fix: opening the plan modal N times would attach N handlers to each + // element, so a single radio change would fire updateCommitmentOptions N times. + describe('H3 regression: setupRampScheduleHandlers install-once guard', () => { + test('provider-change handler fires exactly once after opening modal 3 times', () => { + const { populateTermSelect: mockPopulate } = jest.requireMock('../commitmentOptions') as { + populateTermSelect: jest.Mock; + }; + + // beforeEach already called _resetRampHandlersForTest() so the flag is clear. + // Open the modal 3 times without resetting between them. + openNewPlanModal(); // first open: installs handlers (rampHandlersInstalled = true) + openNewPlanModal(); // second open: guard returns early -- no re-attach + openNewPlanModal(); // third open: guard returns early -- no re-attach + + mockPopulate.mockClear(); // clear any calls from setup above + + // Dispatch one change event on the provider select. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'azure'; + providerSelect.dispatchEvent(new Event('change')); + + // populateTermSelect is called exactly once from updateCommitmentOptions. + // Pre-fix (no guard) it would be called 3 times (once per open). + expect(mockPopulate).toHaveBeenCalledTimes(1); + }); + + test('ramp schedule radio handler fires exactly once after 2 modal opens', () => { + // The radio-change handler calls updatePlanNameFromSchedule which reads + // the plan-name input. Count invocations via a side-effect on populateTermSelect. + const { populateTermSelect: mockPopulate } = jest.requireMock('../commitmentOptions') as { + populateTermSelect: jest.Mock; + }; + + // beforeEach called _resetRampHandlersForTest(); open twice without reset between. + openNewPlanModal(); // installs + openNewPlanModal(); // guard: no re-install + + mockPopulate.mockClear(); + + // The provider-select change handler calls updateCommitmentOptions -> populateTermSelect. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'gcp'; + providerSelect.dispatchEvent(new Event('change')); + + // With stacked handlers it would fire twice (once per open). + // With the guard it fires exactly once. + expect(mockPopulate).toHaveBeenCalledTimes(1); + }); + }); + + // Regression for H1: providerBadgeHtml must be used for planned-purchase rows + // and plan card detail (prevents XSS via class attribute injection). + describe('H1 regression: provider badge XSS neutralisation in plans.ts', () => { + test('providerBadgeHtml is called with the purchase provider for planned-purchase rows', async () => { + const mockBadge = (jest.requireMock('../utils') as { providerBadgeHtml: jest.Mock }).providerBadgeHtml; + + const maliciousProvider = 'aws">'; + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ + purchases: [ + { + id: 'pp-1', + plan_id: 'plan-1', + plan_name: 'Test Plan', + provider: maliciousProvider, + service: 'ec2', + resource_type: 'Standard', + region: 'us-east-1', + count: 1, + term: 1, + payment: 'no-upfront', + upfront_cost: 0, + estimated_savings: 50, + scheduled_date: '2024-06-01', + status: 'pending', + step_number: 1, + total_steps: 4, + } + ] + }); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); + + await loadPlans(); + + // providerBadgeHtml must have been called with the malicious string. + // Pre-fix the string was raw-interpolated into a class attribute via innerHTML. + // Post-fix it goes through the helper which whitelists and escapes it. + expect(mockBadge).toHaveBeenCalledWith(maliciousProvider); + }); + }); }); diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index ed5fcf2df..9b2f9bf49 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -16,7 +16,9 @@ import { isValidEmail, formatRampSchedule, getStatusBadge, - calculatePaybackMonths + calculatePaybackMonths, + providerBadgeClass, + providerBadgeHtml } from '../utils'; describe('formatCurrency', () => { @@ -363,3 +365,77 @@ describe('calculatePaybackMonths', () => { expect(calculatePaybackMonths(-100, 100)).toBe(0); }); }); + +// Regression tests for H1/D1/L4: providerBadgeClass whitelist. +// Pre-fix the class was interpolated raw from the API (plans.ts) or allowed any +// alphanumeric string (dashboard.ts). Post-fix all sites go through this +// helper which enforces the aws|azure|gcp allow-list. +describe('providerBadgeClass', () => { + test('returns lowercase provider for known aws', () => { + expect(providerBadgeClass('aws')).toBe('aws'); + expect(providerBadgeClass('AWS')).toBe('aws'); + }); + + test('returns lowercase provider for known azure', () => { + expect(providerBadgeClass('azure')).toBe('azure'); + expect(providerBadgeClass('Azure')).toBe('azure'); + }); + + test('returns lowercase provider for known gcp', () => { + expect(providerBadgeClass('gcp')).toBe('gcp'); + }); + + test('returns empty string for unknown provider (XSS payload neutralised)', () => { + // Regression: pre-fix this value would be injected verbatim as a CSS class. + expect(providerBadgeClass('aws">')).toBe(''); + expect(providerBadgeClass('evil-class')).toBe(''); + expect(providerBadgeClass('unknown')).toBe(''); + }); + + test('returns empty string for null/undefined/empty', () => { + expect(providerBadgeClass(null)).toBe(''); + expect(providerBadgeClass(undefined)).toBe(''); + expect(providerBadgeClass('')).toBe(''); + }); +}); + +// Regression tests for H1: providerBadgeHtml XSS in plans.ts. +// Pre-fix: plans.ts interpolated provider raw into class + textContent via innerHTML. +// Post-fix: class is whitelisted; text is HTML-escaped. +describe('providerBadgeHtml', () => { + test('renders known provider with correct class and uppercased label', () => { + const html = providerBadgeHtml('aws'); + expect(html).toContain('class="provider-badge aws"'); + expect(html).toContain('>AWS<'); + }); + + test('renders azure with correct class', () => { + const html = providerBadgeHtml('azure'); + expect(html).toContain('class="provider-badge azure"'); + expect(html).toContain('>AZURE<'); + }); + + test('neutralises XSS payload in class attribute position', () => { + // The raw value would previously be injected as: class="provider-badge " + const payload = 'aws">'; + const html = providerBadgeHtml(payload); + // Class must not contain the raw payload. + expect(html).not.toContain(payload); + // No or onerror in the output. + expect(html).not.toContain(' { + const html = providerBadgeHtml(''); + expect(html).not.toContain('')).toBe('<script>alert("xss")</script>'); + expect(escapeHtml('')).toBe('<script>alert("xss")</script>'); }); test('escapes ampersands', () => { @@ -213,7 +213,7 @@ describe('escapeHtml', () => { }); test('escapes quotes', () => { - expect(escapeHtml('"test"')).toBe('"test"'); + expect(escapeHtml('"test"')).toBe('"test"'); }); test('returns empty string for null/undefined', () => { diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 239d46513..7c4b6f648 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -155,9 +155,12 @@ export function throttle unknown>( */ export function escapeHtml(str: string | null | undefined): string { if (!str) return ''; - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } /** From f9abe3504a032ee732dd58022c8ed7df84c9df2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 03:33:43 +0000 Subject: [PATCH 8/9] fix(frontend): show '--' sentinel for empty savings range, align mock - dashboard.ts: replace formatCurrency(0) with literal '--' when range.cellCount === 0 (CR finding: empty recs fabricated a real $0 instead of the absent-data sentinel) - dashboard.test.ts: align formatCurrency mock with null/non-finite sentinel contract (val==null or !isFinite returns '--'); update three stale assertions that expected '$0' when recs are absent or failed Addresses CR review actionable findings on PR #1046 (outside-diff comments dashboard.ts:252-267 and dashboard.test.ts:104-111). Finding plans.ts:731-735 skipped as CR misread: escapeHtml in utils.ts already escapes both '"' and "'" (lines 162-163). --- frontend/src/__tests__/dashboard.test.ts | 23 +++++++++++++++-------- frontend/src/dashboard.ts | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 8c4d23c04..43623f2da 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -102,7 +102,11 @@ jest.mock('../state', () => ({ // Mock utils jest.mock('../utils', () => ({ - formatCurrency: jest.fn((val) => `$${val || 0}`), + formatCurrency: jest.fn((val) => { + const n = typeof val === 'number' ? val : Number(val); + if (val == null || !Number.isFinite(n)) return '--'; + return `$${n}`; + }), getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })), escapeHtml: jest.fn((str) => str || ''), populateAccountFilter: jest.fn(() => Promise.resolve()), @@ -657,11 +661,12 @@ describe('Dashboard Module', () => { expect(summary?.innerHTML).toContain('Active Commitments'); expect(summary?.innerHTML).toContain('Current Coverage'); expect(summary?.innerHTML).toContain('YTD Savings'); - // Savings card must fall back to $0 (not throw or go blank). + // Savings card must fall back to '--' (not throw, go blank, or fabricate $0). const savingsCard = summary?.querySelector('.card'); expect(savingsCard?.textContent).toContain('Potential Monthly Savings'); - // mockPageLevelRange returns cellCount=0 for empty groups, so formatCurrency(0) = '$0' - expect(savingsCard?.innerHTML).toContain('$0'); + // When recs fail cellCount=0, the sentinel '--' is shown instead of fabricated $0. + expect(savingsCard?.innerHTML).toContain('--'); + expect(savingsCard?.innerHTML).not.toContain('$0'); }); // #293: summary.potential_monthly_savings is no longer the source for @@ -721,10 +726,11 @@ describe('Dashboard Module', () => { expect(summary?.innerHTML).toContain('Current Coverage'); expect(summary?.innerHTML).toContain('YTD Savings'); - // Savings card falls back to $0 when recs are absent. + // Savings card shows '--' when recs are absent (no fabricated $0). const savingsCard = summary?.querySelector('.card'); expect(savingsCard?.textContent).toContain('Potential Monthly Savings'); - expect(savingsCard?.innerHTML).toContain('$0'); + expect(savingsCard?.innerHTML).toContain('--'); + expect(savingsCard?.innerHTML).not.toContain('$0'); }); // #304: getRecommendations returns a non-array object (e.g. a wrapped @@ -749,9 +755,10 @@ describe('Dashboard Module', () => { const summary = document.getElementById('summary'); expect(summary?.innerHTML).toContain('Active Commitments'); - // Savings card falls back to $0. + // Savings card shows '--' (no fabricated $0 when recs are absent). const savingsCard = summary?.querySelector('.card'); - expect(savingsCard?.innerHTML).toContain('$0'); + expect(savingsCard?.innerHTML).toContain('--'); + expect(savingsCard?.innerHTML).not.toContain('$0'); }); // #749: the real backend always returns the envelope shape diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 809463ad7..5d21187e0 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -261,7 +261,7 @@ function renderDashboardSummary(data: DashboardSummary, recs: readonly LocalReco const range = pageLevelRange(groups); savingsDisplay = range.cellCount > 0 ? formatSavingsRange(range.savingsMin, range.savingsMax) - : formatCurrency(0); + : '--'; } catch (recErr) { console.warn('Dashboard: failed to compute savings range from recommendations:', recErr); savingsDisplay = '--'; From fe49c47ba260245f36e8893bdb2d27e3105bd1bb Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 7 Jun 2026 21:14:40 -0700 Subject: [PATCH 9/9] fix(frontend): use attribute-safe escaping for data-* injections (XSS) Introduces escapeHtmlAttr as a semantic alias for escapeHtml that makes clear the caller is escaping an attribute value. The escapeHtml implementation was already updated in the prior commit to encode " and ' (closing the root vulnerability); escapeHtmlAttr calls through to it so all attribute-context injections are covered. Replaces the remaining escapeHtml calls in data-attribute context: - history.ts: data-retry-id and both data-execution-id sites - plans.ts: data-id and data-name on all plan/purchase action buttons Adds escapeHtmlAttr unit tests to utils.test.ts (quote, ampersand, and bracket encoding; null/undefined; safe strings) and a regression test in history.test.ts that fails against the pre-fix quote-transparent path. --- frontend/src/__tests__/history.test.ts | 43 ++++++++++++++++++++++++++ frontend/src/__tests__/utils.test.ts | 30 ++++++++++++++++++ frontend/src/history.ts | 12 +++---- frontend/src/plans.ts | 22 ++++++------- frontend/src/utils.ts | 12 ++++++- 5 files changed, 101 insertions(+), 18 deletions(-) diff --git a/frontend/src/__tests__/history.test.ts b/frontend/src/__tests__/history.test.ts index f4227754c..6a6cbc0f5 100644 --- a/frontend/src/__tests__/history.test.ts +++ b/frontend/src/__tests__/history.test.ts @@ -18,6 +18,13 @@ jest.mock('../utils', () => ({ formatDate: jest.fn((val) => val ? new Date(val).toLocaleDateString() : ''), formatTerm: jest.fn((years) => years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`), escapeHtml: jest.fn((str) => str || ''), + // escapeHtmlAttr must encode " and ' to block attribute-boundary injection. + // This mock mirrors the real implementation so tests catch regressions where + // code reverts to the quote-transparent escapeHtml in attribute context. + escapeHtmlAttr: jest.fn((str: string | null | undefined) => { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + }), populateAccountFilter: jest.fn(() => Promise.resolve()) })); @@ -493,6 +500,42 @@ describe('History Module', () => { expect(summary?.innerHTML).not.toContain('

$0

'); }); + // Regression: attribute-boundary XSS via purchase_id containing a double-quote. + // Pre-fix, data-approve-id / data-cancel-id / data-retry-id / data-execution-id + // used escapeHtml which did not encode ", allowing a crafted ID to break out of + // the attribute and inject markup. Post-fix, escapeHtmlAttr is used and " becomes + // " so the attribute stays well-formed. + test('encodes double-quotes in purchase_id for data-* attributes (XSS regression)', async () => { + const maliciousId = 'a" onmouseover="alert(1)'; + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + { + purchase_id: maliciousId, + status: 'pending', + provider: 'aws', + service: 'ec2', + resource_type: 't3.medium', + region: 'us-east-1', + count: 1, + term: 1, + upfront_cost: 100, + estimated_savings: 10, + can_approve: true, + can_cancel: true, + } + ] + }); + + await loadHistory(); + + const html = document.getElementById('history-list')?.innerHTML || ''; + // The encoded form must appear in the attribute value. + expect(html).toContain('"'); + // The raw injection payload must not appear as unescaped markup. + expect(html).not.toContain('onmouseover="alert(1)"'); + }); + test('renderHistorySummary: null summary shows unknown-state banner with all four cards', async () => { (api.getHistory as jest.Mock).mockResolvedValue({ summary: null as unknown as undefined, diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index 647ec4c08..f2042cee3 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -10,6 +10,7 @@ import { debounce, throttle, escapeHtml, + escapeHtmlAttr, parseQueryParams, buildUrl, deepClone, @@ -228,6 +229,35 @@ describe('escapeHtml', () => { }); }); +describe('escapeHtmlAttr', () => { + test('encodes double-quote to " (attribute boundary safety)', () => { + // A raw " would terminate the surrounding attribute and allow markup injection. + expect(escapeHtmlAttr('"')).toBe('"'); + expect(escapeHtmlAttr('a" onmouseover="alert(1)')).toBe('a" onmouseover="alert(1)'); + }); + + test('encodes single-quote to '', () => { + expect(escapeHtmlAttr("'")).toBe('''); + expect(escapeHtmlAttr("it's")).toBe('it's'); + }); + + test('still encodes & < > (inherits from escapeHtml)', () => { + expect(escapeHtmlAttr('&')).toBe('&'); + expect(escapeHtmlAttr('')).toBe('<img>'); + expect(escapeHtmlAttr('x">')).toBe('x"><img src=x onerror=alert(1)>'); + }); + + test('returns empty string for null/undefined', () => { + expect(escapeHtmlAttr(null as unknown as string)).toBe(''); + expect(escapeHtmlAttr(undefined as unknown as string)).toBe(''); + expect(escapeHtmlAttr('')).toBe(''); + }); + + test('passes through safe strings unchanged', () => { + expect(escapeHtmlAttr('abc-123_OK')).toBe('abc-123_OK'); + }); +}); + describe('parseQueryParams', () => { test('parses query string correctly', () => { const result = parseQueryParams('?foo=bar&baz=qux'); diff --git a/frontend/src/history.ts b/frontend/src/history.ts index 110d9f586..c4990c050 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -4,7 +4,7 @@ import * as api from './api'; import * as state from './state'; -import { formatCurrency, formatDate, formatTerm, escapeHtml } from './utils'; +import { formatCurrency, formatDate, formatTerm, escapeHtml, escapeHtmlAttr } from './utils'; import type { HistoryResponse, HistorySummary, HistoryPurchase } from './types'; import { switchTab } from './navigation'; import { confirmDialog } from './confirmDialog'; @@ -544,10 +544,10 @@ function renderPendingActionButtons(p: HistoryPurchase): string { if (!p.purchase_id) return ''; const buttons: string[] = []; if (canApprovePendingRow(p)) { - buttons.push(``); + buttons.push(``); } if (canCancelPendingRow(p)) { - buttons.push(``); + buttons.push(``); } return buttons.join(' '); } @@ -587,7 +587,7 @@ function renderActionCell(p: HistoryPurchase): string { const overThreshold = retryThresholdReached(p); const label = overThreshold ? `⚠ Retried ${p.retry_attempt_n ?? 0}× — click to override` : '↻ Retry'; const cls = overThreshold ? 'btn-link history-retry-btn history-retry-over-threshold' : 'btn-link history-retry-btn'; - return ``; + return ``; } } @@ -681,7 +681,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { } return badge; })(); - const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtml(p.purchase_id)}"` : ''; + const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtmlAttr(p.purchase_id)}"` : ''; const planCellContent = renderActionCell(p); const monthlyCostCell = p.monthly_cost != null ? formatCurrency(p.monthly_cost) @@ -982,7 +982,7 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void { const monthlyCostCell = p.monthly_cost != null ? formatCurrency(p.monthly_cost) : '-'; - const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtml(p.purchase_id)}"` : ''; + const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtmlAttr(p.purchase_id)}"` : ''; return ` ${formatDate(p.timestamp)} diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 494175760..3a7df75a4 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -4,7 +4,7 @@ import * as api from './api'; import * as state from './state'; -import { formatDate, formatTerm, getStatusBadge, escapeHtml, formatCurrency, CURRENCY_DEFAULT_DIGITS, providerBadgeHtml } from './utils'; +import { formatDate, formatTerm, getStatusBadge, escapeHtml, escapeHtmlAttr, formatCurrency, CURRENCY_DEFAULT_DIGITS, providerBadgeHtml } from './utils'; import { showToast } from './toast'; import { confirmDialog } from './confirmDialog'; import type { PlansResponse, LocalPlan, SavePlanData } from './types'; @@ -728,11 +728,11 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${formatCurrency(purchase.estimated_savings)}/mo ${escapeHtml(purchase.status)} - ${canRunPurchase ? `` : ''} - ${canPauseOrResumePurchase && isPending ? `` : ''} - ${canPauseOrResumePurchase && isPaused ? `` : ''} - ${canEditPlan ? `` : ''} - ${canDisablePlan ? `` : ''} + ${canRunPurchase ? `` : ''} + ${canPauseOrResumePurchase && isPending ? `` : ''} + ${canPauseOrResumePurchase && isPaused ? `` : ''} + ${canEditPlan ? `` : ''} + ${canDisablePlan ? `` : ''} `; @@ -966,7 +966,7 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan ${overdueBadge} ${canManagePlan && !isUnassigned ? ` ` : ''} @@ -1006,10 +1006,10 @@ function renderPlanCard(plan: BackendPlan, canManagePlan: boolean, canDeletePlan ` : ''}
- ${canManagePlan && !isUnassigned ? `` : ''} - ${canManagePlan && !isUnassigned ? `` : ''} - - ${canDeletePlan ? `` : ''} + ${canManagePlan && !isUnassigned ? `` : ''} + ${canManagePlan && !isUnassigned ? `` : ''} + + ${canDeletePlan ? `` : ''}
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 7c4b6f648..766c8fb45 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -151,7 +151,8 @@ export function throttle unknown>( } /** - * Escape HTML to prevent XSS + * Escape HTML to prevent XSS in text content (between tags). + * Escapes &, <, >, ", and ' so the result is safe in both text and attribute contexts. */ export function escapeHtml(str: string | null | undefined): string { if (!str) return ''; @@ -163,6 +164,15 @@ export function escapeHtml(str: string | null | undefined): string { .replace(/'/g, '''); } +/** + * Escape a value for safe interpolation inside an HTML attribute value delimited by double quotes. + * Encodes &, <, >, ", and ' -- the quote escaping is essential here because a raw " terminates + * the attribute and allows injecting new attributes or markup. Use escapeHtml for text nodes. + */ +export function escapeHtmlAttr(str: string | null | undefined): string { + return escapeHtml(str); +} + /** * Parse URL query parameters */