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