From 536205c95d5e68ba7c4c421d391820978ee77787 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 8 Aug 2026 00:28:46 +0200 Subject: [PATCH] sec(frontend): escape payment field in plans and recs tables purchase.payment (plans.ts) and rec.payment (recommendations.ts) were interpolated raw into innerHTML while every sibling field in the same row was escaped with the existing escapeHtml() helper. payment is a bare string in both PlannedPurchase and LocalRecommendation, so an attacker-controlled or provider-echoed value with markup would be parsed and executed in the viewer's session. Wraps both interpolations with escapeHtml() and defaults the plans.ts term cell's payment to an empty string so the unguarded .replace() call no longer throws on a missing value. Closes #1633 Closes #1632 --- .../__tests__/xss-purchase-payment.test.ts | 199 ++++++++++++++++++ .../xss-recommendations-payment.test.ts | 194 +++++++++++++++++ frontend/src/plans.ts | 2 +- frontend/src/recommendations.ts | 2 +- 4 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/xss-purchase-payment.test.ts create mode 100644 frontend/src/__tests__/xss-recommendations-payment.test.ts diff --git a/frontend/src/__tests__/xss-purchase-payment.test.ts b/frontend/src/__tests__/xss-purchase-payment.test.ts new file mode 100644 index 000000000..ba5cba738 --- /dev/null +++ b/frontend/src/__tests__/xss-purchase-payment.test.ts @@ -0,0 +1,199 @@ +/** + * Regression tests for stored XSS via purchase.payment rendered unescaped + * inside the planned-purchases term cell (issue #1633). + * + * plans.ts:renderPlannedPurchaseRow used to emit: + * ${purchase.term}yr ${purchase.payment.replace('-', ' ')} + * + * Every sibling string field in the same row (plan_name, service, + * resource_type, region, status) is escaped with the existing + * escapeHtml() from utils.ts -- see xss-purchase-status.test.ts (#445) -- + * but `payment` was the one field left raw. `payment` is typed as a bare + * `string` in api/types.ts (not a closed union like `status`), so nothing + * on the frontend constrains its content. The fix wraps the interpolation + * with escapeHtml() and defaults a missing value to '' so the unguarded + * .replace() call on a null/undefined payment no longer throws. + */ + +import { loadPlans } from '../plans'; + +// Use the real escapeHtml so the DOM-based escaping is exercised, not a stub. +jest.mock('../utils', () => { + const actual = jest.requireActual('../utils'); + return { + ...actual, + formatDate: jest.fn((val: string) => val ? new Date(val).toLocaleDateString() : ''), + formatTerm: jest.fn((years: number | null | undefined) => + years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`), + formatRampSchedule: jest.fn((val: string) => val || 'Unknown'), + formatCurrency: jest.fn((val: number) => `$${val || 0}`), + populateAccountFilter: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('../api', () => ({ + getPlans: jest.fn(), + getPlannedPurchases: jest.fn(), + getPlan: jest.fn(), + createPlan: jest.fn(), + updatePlan: jest.fn(), + patchPlan: jest.fn(), + deletePlan: jest.fn(), + runPlannedPurchase: jest.fn(), + pausePlannedPurchase: jest.fn(), + resumePlannedPurchase: jest.fn(), + deletePlannedPurchase: jest.fn(), + createPlannedPurchases: jest.fn(), + listPlanAccounts: jest.fn().mockResolvedValue([]), + setPlanAccounts: jest.fn().mockResolvedValue(undefined), + listAccounts: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../state', () => ({ + getRecommendations: jest.fn().mockReturnValue([]), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), +})); + +jest.mock('../history', () => ({ + viewPlanHistory: jest.fn(), +})); + +jest.mock('../commitmentOptions', () => ({ + populateTermSelect: jest.fn(), + populatePaymentSelect: jest.fn(), + isValidCombination: jest.fn().mockReturnValue(true), + normalizePaymentValue: jest.fn((value: unknown) => value), +})); + +jest.mock('../toast', () => ({ + showToast: jest.fn(() => ({ dismiss: jest.fn() })), +})); + +jest.mock('../confirmDialog', () => ({ + confirmDialog: jest.fn(() => Promise.resolve(true)), +})); + +jest.mock('../modal', () => ({ + openModal: jest.fn(), + closeModal: jest.fn(), +})); + +jest.mock('../archera', () => ({ + openArcheraOfferModal: jest.fn(), +})); + +jest.mock('../lib/skeleton', () => ({ + showSkeletonTiles: jest.fn(), + showSkeletonRows: jest.fn(), + teardownSkeleton: jest.fn(), +})); + +jest.mock('../permissions', () => ({ + canAccess: jest.fn().mockReturnValue(true), +})); + +import * as api from '../api'; + +const SCRIPT_PAYLOAD = ''; +const IMG_PAYLOAD = '">'; + +function makePurchase(payment: string | undefined) { + const purchase: Record = { + id: 'pp-1', + plan_id: 'plan-1', + plan_name: 'Test Plan', + scheduled_date: '2024-06-01', + provider: 'aws', + service: 'ec2', + resource_type: 't3.medium', + region: 'us-east-1', + count: 1, + term: 1, + payment, + estimated_savings: 50, + upfront_cost: 0, + status: 'pending', + step_number: 1, + total_steps: 1, + }; + if (payment === undefined) delete purchase['payment']; + return purchase; +} + +describe('XSS regression: purchase.payment rendered as text, not markup (#1633)', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+
+ + `; + jest.clearAllMocks(); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); + }); + + test('script-tag payload in purchase.payment does not create a '; +const IMG_PAYLOAD = '">'; + +function makeRec(payment: string) { + return { + id: 'rec-1', + provider: 'aws', + service: 'ec2', + resource_type: 't3.medium', + region: 'us-east-1', + count: 1, + term: 1, + payment, + savings: 100, + upfront_cost: 500, + }; +} + +describe('XSS regression: rec.payment rendered as text, not markup (#1632)', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+
+
+
+ + `; + jest.clearAllMocks(); + jest.useFakeTimers(); + (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: [], regions: [] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('script-tag payload in rec.payment does not create a