Skip to content

fix(frontend): neutralise provider XSS and dedup modal/global listeners - #1046

Merged
cristim merged 9 commits into
feat/multicloud-web-frontendfrom
fix/frontend-xss-listeners
Jun 8, 2026
Merged

fix(frontend): neutralise provider XSS and dedup modal/global listeners#1046
cristim merged 9 commits into
feat/multicloud-web-frontendfrom
fix/frontend-xss-listeners

Conversation

@cristim

@cristim cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member

Summary

  • H1 (Stored XSS): plans.ts injected the API provider string raw into innerHTML class attributes and text nodes at two sites (~180, ~412). Extract shared providerBadgeClass() / providerBadgeHtml() helpers in utils.ts (matching the existing whitelist in history.ts:341 and recommendations.ts:2417) and replace both raw interpolations.
  • H3 (Listener stacking): setupRampScheduleHandlers in plans.ts re-attached listeners to static modal elements on every modal open. Add rampHandlersInstalled install-once guard; export _resetRampHandlersForTest() for test isolation.
  • D1/L4 (Shared helper): dashboard.ts was using a loose alphanumeric regex instead of the strict aws|azure|gcp whitelist. Align on providerBadgeClass() from utils.ts.
  • Test mocks updated: dashboard.test.ts and dashboard-ownership-950.test.ts utils mocks extended with providerBadgeClass.
  • Note: H2 (users/userList.ts duplicate document change listener via AbortController) was already fixed in a prior PR merged into feat/multicloud-web-frontend.

Closes #1034

Test plan

  • Regression tests in utils.test.ts verify providerBadgeClass returns empty string for malicious payloads and providerBadgeHtml does not emit raw <img> / onerror in output
  • plans.test.ts H1 regression: providerBadgeHtml is called (not raw interpolation) when provider contains a malicious string
  • plans.test.ts H3 regression: dispatching a provider-change event after 3 modal opens calls populateTermSelect exactly once (not 3x)
  • users.test.ts H2 regression (pre-existing at line 2297): single checkbox toggle after 3 renderUsers calls emits exactly one toast
  • All 2441 tests pass (2 pre-existing locale-based date failures unrelated to this PR)
  • TypeScript --noEmit clean
  • Production build succeeds (webpack --mode production)

Scope expanded

Additional findings from FOLD-1046 (code review report 11) added in 4 commits:

  • 11-M2: users/filters.ts -- group.id was injected raw into value="" in an innerHTML template; wrapped in escapeHtml().
  • 11-L1: plans.ts -- plan.id and purchase.id raw in data-id attributes inside innerHTML plan-card and purchase-row templates; all escaped.
  • 11-M1: plans.ts -- savePlan used bare parseInt for term, target_coverage, notification_days_before, and custom ramp fields; replaced with Number.isInteger(Number(raw)) validation (toast on rejection), mirroring settings.ts validatePurchasingSettings.
  • 11-M3: groups/groupModals.ts -- collectPermissions called parseFloat(maxAmount) without a Number.isFinite && >= 0 guard; non-finite and negative values are now silently skipped.
  • 11-L2: plans.ts -- confirm() calls in the Run and Disable planned-purchase actions replaced with confirmDialog({destructive:true}).
  • 11-L3: alert() replaced with showToast in recommendations.ts (refresh success/failure), auth.ts (profile update validation, success, failure), federation.ts (IaC download error), modules/registrations.ts (reject-registration error).
  • 11-N1: utils.ts -- deepClone now delegates to structuredClone instead of a JSON round-trip; old JSON path retained as jsonClone() for callers that need JSON semantics.
  • 11-N2: utils.ts -- formatCurrency returns '--' for null/undefined/NaN/Infinity to distinguish absent data from a real $0 balance.

Also closes #1065

Scope expanded

This PR now also fixes the findings from docs/code-review/19-hardcoded-fallbacks-frontend.md (H-2..H-5 and associated Mediums). All findings are folded into a single additional commit (aa8f16e1f).

Also closes #1086

Added fixes

  • H-2 (history.ts): data.summary || {} + ?? 0 rendered fabricated $0 on all History KPI cards when the API field was absent. Now renders '--' sentinel on each card; null summary shows an explicit unknown-state banner.
  • H-3 (dashboard.ts): target_coverage || 80 removed (hardcoded 80% business default); absent current_coverage/active_commitments now show '--'; savings range catch block shows '--' not $0.
  • H-4 (plans.ts extractPlanInfo): provider || 'aws' / term || 3 / coverage || 80 replaced with null; plan card shows '--'; edit modal leaves selects unset so the user must explicitly choose (no fabricated 3yr term or 80% coverage).
  • H-5 (plans.ts): payment || 'no-upfront' removed; absent payment leaves the payment select blank so re-saving does not silently change the payment type.
  • M-1 (recommendations.ts): scaleCost(...) ?? 0 on summary savings cards replaced with ?? null.
  • M-2 (recommendations.ts): savings ?? 0 / upfront_cost ?? 0 in numericCellValue now return Number.NaN (consistent with monthly_cost/on_demand_monthly) so absent rows don't match filter predicates.
  • M-3 (approval-details.ts): null savings skipped in aggregate (not += 0); total_upfront_cost propagates null to formatCurrency showing '--'.
  • M-5 (recommendations.ts): lastRecommendationsSummary typed as RecommendationsSummary | null; absent summary no longer coerced to {}.
  • M-6/M-7 (savings-history.ts): private formatCurrency gains null/non-finite guard; || 0 replaced with ?? 0 in chart data mapping.
  • L-2 (recommendations.ts): payback fallback changed from '0 months' (misleadingly implies instantaneous) to '—'.

Added regression tests

Regression tests added to history.test.ts, dashboard.test.ts, and plans.test.ts that fail on the pre-fix code for H-2, H-3, H-4, and H-5.

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Missing or invalid data now displays correctly as "--" instead of fabricated defaults (e.g., "$0" for missing costs)
    • Enhanced security with improved HTML escaping protection
    • Stricter validation for numeric form inputs
  • New Features

    • Replaced blocking notifications with less-intrusive non-blocking toast messages for a better user experience

…rs (#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.
@cristim cristim added bug Something isn't working triaged Item has been triaged priority/p2 Backlog-worthy severity/high Significant harm urgency/this-sprint Within the current sprint impact/all-users Affects every user effort/s Hours type/bug Defect labels Jun 7, 2026
@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 44 minutes and 37 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7c8c210-351e-46ae-8aa1-26e4cece987f

📥 Commits

Reviewing files that changed from the base of the PR and between aa8f16e and fe49c47.

📒 Files selected for processing (8)
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/__tests__/history.test.ts
  • frontend/src/__tests__/plans.test.ts
  • frontend/src/__tests__/utils.test.ts
  • frontend/src/dashboard.ts
  • frontend/src/history.ts
  • frontend/src/plans.ts
  • frontend/src/utils.ts
📝 Walkthrough

Walkthrough

This PR consolidates fixes for report 11 security and data-handling issues: XSS gaps via provider whitelisting, silent fallbacks replaced with '--' sentinels, alert/confirm migrated to toast/confirmDialog patterns, strict numeric validation for plans and groups, HTML escaping in attributes, and event-listener deduplication guards.

Changes

Frontend Security and Data-Handling Improvements

Layer / File(s) Summary
Utility Helpers: Provider Badges, Format Currency, Deep Clone
frontend/src/utils.ts, frontend/src/__tests__/utils.test.ts
New providerBadgeClass and providerBadgeHtml helpers whitelist and escape provider strings for safe rendering; formatCurrency now returns '--' for null/undefined/non-finite instead of zero; deepClone uses structuredClone by default, and jsonClone preserves prior JSON round-trip behavior; comprehensive regression test coverage for XSS and value-type semantics.
Null-Safe Data Rendering
frontend/src/dashboard.ts, frontend/src/history.ts, frontend/src/approval-details.ts, frontend/src/recommendations.ts, frontend/src/modules/savings-history.ts, frontend/src/__tests__/dashboard.test.ts, frontend/src/__tests__/history.test.ts
Dashboard, history, approval, and recommendations pages now preserve absent/null values and render '--' sentinel instead of defaulting to zero or fabricated values (e.g., target_coverage: 80%, active_commitments: 0, savings: $0); numeric filter predicates return NaN for missing data to prevent false zero matches.
Provider Badge Rendering: XSS Mitigation
frontend/src/dashboard.ts, frontend/src/plans.ts, frontend/src/__tests__/dashboard-ownership-950.test.ts, frontend/src/__tests__/plans.test.ts
Dashboard cards, planned-purchase rows, and plan cards now use providerBadgeClass and providerBadgeHtml for whitelist-based HTML-escaped provider badge rendering instead of raw class injection and unescaped text.
Strict Numeric & Constraint Validation
frontend/src/plans.ts, frontend/src/groups/groupModals.ts, frontend/src/__tests__/plans.test.ts, frontend/src/__tests__/groups.test.ts
Plan fields (term ≥ 1, coverage 0–100, notify-days 1–30, ramp step/interval) and group permission max_amount are validated for finiteness, integer-ness, and range using Number() and guards; invalid inputs trigger toast feedback and reject the submission; regression tests cover boundary cases and malformed inputs.
HTML Escaping in Attributes
frontend/src/plans.ts, frontend/src/users/filters.ts
Plan IDs, purchase IDs, and group IDs are HTML-escaped when embedded into data-* attributes and HTML option values to prevent injection via innerHTML templates.
UI Feedback Migration: alert/confirm → toast/confirmDialog
frontend/src/auth.ts, frontend/src/federation.ts, frontend/src/modules/registrations.ts, frontend/src/recommendations.ts, frontend/src/plans.ts, frontend/src/__tests__/auth.test.ts, frontend/src/__tests__/recommendations.test.ts, frontend/src/__tests__/plans.test.ts
Profile save, federation download, registration rejection, recommendations refresh, and planned-purchase actions replace blocking alert() and window.confirm() with non-blocking showToast() and async confirmDialog() patterns; test assertions updated to verify toast/dialog calls instead of window methods.
Event Listener Deduplication
frontend/src/plans.ts, frontend/src/__tests__/plans.test.ts
Ramp schedule event-handler wiring is guarded by install-once flag to prevent duplicate listeners across modal opens; exported _resetRampHandlersForTest() helper allows test isolation by resetting the guard.
Plan Info Extraction: Remove Fabricated Defaults
frontend/src/plans.ts, frontend/src/__tests__/plans.test.ts
extractPlanInfo now returns nullable provider/term/coverage instead of fabricating defaults ('aws'/'3yr'/'80%'); edit-modal prefill logic leaves selects/inputs unset when backend data is absent, avoiding silent value changes on re-save; plan cards render '--' for missing term/coverage.
Test Infrastructure & Regression Coverage
frontend/src/__tests__/setup.ts, all test files above
Adds structuredClone polyfill for jsdom; comprehensive regression test suites across all affected modules validate absence handling, XSS payload neutralization, numeric validation, event deduplication, and dialog/toast behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • LeanerCloud/CUDly#295: Both PRs modify Dashboard savings-range computation fallback behavior (formatting and null-safe rendering for per-cell recommendation-derived savings).
  • LeanerCloud/CUDly#276: Both PRs modify recommendations.ts and test coverage for savings summary display and rendering assertions.
  • LeanerCloud/CUDly#311: Both PRs change Dashboard renderDashboardSummary savings-range computation and test coverage for recommendation KPI rendering.

Suggested labels

effort/m, type/security

Poem

🐰 A rabbit hops through code so keen,
Sanitizing all strings in-between,
No more dashes masking zeroes,
Validations make us all heroes!
From XSS guards to toasts so bright,
This diff keeps our frontend safe and tight. 🔒

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main changes: fixing provider XSS vulnerabilities and deduplicating modal/global listeners.
Linked Issues check ✅ Passed All PR changes comprehensively address the objectives from linked issues #1034, #1065, and #1086: provider XSS neutralization, listener deduplication, strict validation, escaping, confirmDialog/showToast replacements, structuredClone adoption, and removal of fabricated defaults.
Out of Scope Changes check ✅ Passed All file changes are directly related to the stated objectives: XSS fixes, listener deduplication, numeric validation, HTML escaping, dialog/toast replacements, and removal of hardcoded fallbacks for absent fields.
Docstring Coverage ✅ Passed Docstring coverage is 81.08% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/frontend-xss-listeners

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 4 commits June 7, 2026 04:33
…-M2, 11-L1)

Wrap group.id in escapeHtml() in updateGroupFilterDropdown (finding 11-M2):
the option value attribute was injecting the raw API value without escaping,
violating the feedback_innerhtml_xss policy.

Escape plan.id and purchase.id in all data-id attributes inside the plan-card
and planned-purchase-row innerHTML templates (finding 11-L1). UUIDs are safe
in practice but must be escaped for consistency and defence-in-depth.

Adds regression test in users.test.ts asserting the group.id XSS payload is
not emitted verbatim into the rendered option element.

Closes part of #1065
…11-M1, 11-M3)

savePlan now validates term, target_coverage, notification_days_before, and
custom ramp fields with Number.isInteger(Number(raw)) before building the
request object (finding 11-M1). Fractional or NaN inputs show a toast error
and abort without calling the API, matching the pattern in settings.ts
validatePurchasingSettings and feedback_strict_int_parse.

collectPermissions in groupModals.ts guards max_amount with
Number.isFinite(parsed) && parsed >= 0 before assigning to the constraint
object (finding 11-M3). Infinity, NaN, and negative values are silently
skipped so the rest of the permission constraints still reach the API.

Adds regression tests in plans.test.ts (11-M1) and groups.test.ts (11-M3)
that verify the guards reject bad inputs and accept valid values.

Closes part of #1065
…(11-L2, 11-L3)

Replace blocking window.confirm() calls in handlePlannedPurchaseAction (run
and disable actions) with the async confirmDialog helper (finding 11-L2).
The dialog uses the same destructive-button styling already applied to the
delete-plan and purchase-cancel flows. This removes the last confirm() calls
from plans.ts.

Replace window.alert() calls with showToast across four files (finding 11-L3):
- recommendations.ts: refreshRecommendations success and failure feedback
- auth.ts: updateProfile validation error, success, and failure feedback
- federation.ts: IaC download failure feedback
- modules/registrations.ts: reject-registration failure feedback

auth.ts adds a showToast import; federation.ts adds a showToast import.

Updates regression tests in plans.test.ts (confirmDialog assertions), auth.test.ts
(adds toast mock, updates profile alert assertions), and recommendations.test.ts
(updates refreshRecommendations alert assertions) to match the new behaviour.
Adds structuredClone polyfill for jsdom in setup.ts.

Closes part of #1065
… zero (11-N1, 11-N2)

deepClone in utils.ts now delegates to structuredClone instead of a JSON
round-trip (finding 11-N1). structuredClone preserves undefined values, Date
objects, Set and Map instances, and handles circular-safe trees. The old
JSON.parse/stringify variant is retained as an explicit jsonClone() export
for callers that need JSON-serialisation semantics.

formatCurrency returns '--' for null, undefined, and non-finite (NaN,
Infinity) inputs instead of '$0' (finding 11-N2). This makes missing data
visually distinguishable from a real zero balance, closing the silent
missing-data bug described in feedback_nullable_not_zero.

Adds regression tests in utils.test.ts for both changes.

Closes #1065
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

FOLD-1046: additional findings added (4 commits)

All findings from the FOLD-1046 bucket (code review report 11) have been implemented and pushed to fix/frontend-xss-listeners.

11-M2 (Medium) - users/filters.ts

group.id escaped with escapeHtml() in updateGroupFilterDropdown; the value="..." attribute was injecting the raw API value.

11-L1 (Low) - plans.ts

plan.id and purchase.id wrapped in escapeHtml() in all data-id attributes inside the plan-card and planned-purchase-row innerHTML templates.

11-M1 (Medium) - plans.ts

savePlan now validates term, target_coverage, notification_days_before, and custom ramp fields with Number.isInteger(Number(raw)) before building the API request. Fractional or NaN input shows a toast error and aborts. Mirrors settings.ts validatePurchasingSettings and feedback_strict_int_parse.

11-M3 (Medium) - groups/groupModals.ts

collectPermissions guards max_amount with Number.isFinite(parsed) && parsed >= 0 before assigning. Infinity, NaN, and negative values are silently skipped.

11-L2 (Low) - plans.ts

confirm() calls in the Run and Disable planned-purchase actions replaced with async confirmDialog({destructive:true}).

11-L3 (Low) - 4 files

alert() replaced with showToast in:

  • recommendations.ts: refreshRecommendations success/failure
  • auth.ts: profile update validation, success, and failure (+ import added)
  • federation.ts: IaC download failure (+ import added)
  • modules/registrations.ts: reject-registration failure

11-N1 (Nit) - utils.ts

deepClone delegates to structuredClone instead of JSON round-trip. Old JSON path kept as jsonClone(). Polyfill for jsdom added in setup.ts.

11-N2 (Nit) - utils.ts

formatCurrency returns '--' for null/undefined/NaN/Infinity to distinguish absent data from a real $0 balance.


2464 tests pass (2 pre-existing locale-based date failures unrelated to this PR). TypeScript --noEmit clean.

Closes #1065.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

@cristim: Excellent — comprehensive sweep across all 8 FOLD-1046 findings. A few highlights worth noting:

  • The Number.isInteger(Number(raw)) pattern for 11-M1 is a solid mirror of settings.ts validatePurchasingSettings — good consistency.
  • Keeping jsonClone() as an escape hatch alongside the structuredClone delegation for 11-N1 is the right call for callers that rely on JSON-serialisation semantics (e.g. stripping undefined, handling circular refs differently).
  • The '--' sentinel in formatCurrency for 11-N2 cleanly distinguishes absent data from a genuine $0 balance.

Kicking off a fresh incremental review of the 4 new commits now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.770Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…fabricating defaults

H-2: renderHistorySummary now accepts null summary and renders '--' on all
KPI cards rather than passing {} and coercing every money field to $0.

H-3: dashboard KPI tiles no longer fabricate values for absent fields:
target_coverage removes the hardcoded 80% default (shows '--'); absent
current_coverage and active_commitments show '--' not '0%'/'0'; the
savings range catch block returns '--' not $0.

H-4: extractPlanInfo returns null for absent provider/term/coverage instead
of defaulting to 'aws'/3yr/80%. The plan card renders '--' for missing fields;
the edit modal leaves selects unset so the user must explicitly choose.

H-5: edit modal no longer pre-selects 'no-upfront' when the payment field is
absent from the plan -- the payment select is left blank, requiring an
explicit choice before re-saving.

M-1: savings summary card uses ?? null (not ?? 0) so absent savings propagate
to '--' via formatCostForPeriod/formatScaledRange.

M-2: numericCellValue returns NaN for absent savings/upfront_cost (consistent
with monthly_cost/on_demand_monthly) so filter predicates don't match absent
rows as if they were zero.

M-3: approval modal header skips null savings in the aggregate (not += 0);
total_upfront_cost propagates null to formatCurrency so absent shows '--'.

M-5: lastRecommendationsSummary typed as RecommendationsSummary|null; absent
API summary is no longer coerced to {}.

M-6/M-7: savings-history private formatCurrency gains a null/non-finite guard
returning '--'; chart data mapping uses ?? 0 (not || 0) preserving genuine 0.

L-2: payback fallback changed from '0 months' to '—' to distinguish
inapplicable from instantaneous payback.

Regression tests added for H-2, H-3, H-4, H-5 that fail pre-fix.
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Scope expanded: silent-fallback money-display fixes (H-2..H-5, M-1..M-7, L-2)

Commit aa8f16e adds fixes for the findings in `docs/code-review/19-hardcoded-fallbacks-frontend.md`. All findings close issue #1086.

Per-finding summary

H-2 `history.ts` (lines 134, 213, 265-295): `data.summary || {}` passed an empty `HistorySummary` when the API field was absent; `?? 0` coercions then rendered $0 on Total Upfront / Monthly Savings / Annual Savings. Fixed: `renderHistorySummary` now accepts `HistorySummary | null`; a null argument renders an explicit unknown-state banner with `'--'` on every card rather than fabricating all-zero values.

H-3 `dashboard.ts` (lines 163-164, 180, 254-268): Three fabricated defaults removed: (a) `target_coverage || 80` -- the hardcoded 80% business default is gone; (b) `current_coverage || 0` and `active_commitments || 0` -- absent fields now render `'--'`; (c) the savings range catch block now returns `'--'` instead of `formatCurrency(0)`.

H-4 `plans.ts` `extractPlanInfo` (lines 878-896): Return type changed to `{ provider: string | null; term: number | null; coverage: number | null; ... }`. Absent fields return `null` instead of defaulting to `'aws'`/`3`/`80`. Plan card renders `'--'`; edit modal leaves selects unset so the user must explicitly choose (no silent 3yr-term or 80%-coverage fabrication).

H-5 `plans.ts` (line 1119): `firstService?.payment || 'no-upfront'` replaced with `firstService?.payment || null`; null maps to empty string on the payment select, leaving it unset so a re-save cannot silently change the payment type.

M-1 `recommendations.ts` (lines 729-730): `scaleCost(...) ?? 0` on the savings summary cards replaced with `?? null` so absent savings flow to `'--'` via `formatScaledRange`/`formatCostForPeriod`.

M-2 `recommendations.ts` `numericCellValue` (lines 1680-1681): `savings ?? 0` and `upfront_cost ?? 0` now return `Number.NaN` (consistent with `monthly_cost`/`on_demand_monthly`) so absent rows do not incorrectly match numeric filter predicates.

M-3 `approval-details.ts` (lines 99-109): null savings are skipped in the `totalMonthly` aggregate (not `+= 0`); `total_upfront_cost ?? null` propagates to `formatCurrency` which renders `'--'` for absent upfront.

M-5 `recommendations.ts` (line 516): `data.summary || {}` replaced with `data.summary ?? null`; `lastRecommendationsSummary` typed as `RecommendationsSummary | null`.

M-6/M-7 `savings-history.ts`: Private `formatCurrency` extended with a `null | undefined | non-finite` guard returning `'--'`; `|| 0` in aggregation and chart data mapping replaced with `?? 0` so a genuine $0 data point is not confused with a missing field.

L-2 `recommendations.ts` (line 742): Payback fallback changed from `'0 months'` (implies instantaneous payback) to `'—'`.

Regression tests added

  • `history.test.ts`: H-2 suite -- absent summary renders `'--'` banner; absent `total_upfront` propagates null; null summary shows all four cards with `'--'`.
  • `dashboard.test.ts`: H-3 suite -- absent `target_coverage` shows `'Target: --'` (not `'Target: 80.0%'`); absent `current_coverage` shows `'--'`; absent `active_commitments` shows `'--'`; savings catch block shows `'--'`.
  • `plans.test.ts`: H-4 suite -- absent provider calls `providerBadgeHtml(null)` not `'aws'`; absent term renders `'--'` not `'3 Years'`; absent coverage renders `'--'` not `'80%'`; edit modal absent term leaves select at `''`. H-5 suite -- absent payment leaves select at `''`; present payment is correctly pre-selected.

All 2478 previously-passing tests still pass.

@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
frontend/src/dashboard.ts (1)

252-267: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Empty recommendation ranges still render a fabricated $0.

On Line 264, range.cellCount === 0 still maps to formatCurrency(0), which contradicts the new absent-data contract in this PR and surfaces misleading savings.

💡 Suggested fix
-    savingsDisplay = range.cellCount > 0
-      ? formatSavingsRange(range.savingsMin, range.savingsMax)
-      : formatCurrency(0);
+    savingsDisplay = range.cellCount > 0
+      ? formatSavingsRange(range.savingsMin, range.savingsMax)
+      : '--';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/dashboard.ts` around lines 252 - 267, The savings display logic
incorrectly fabricates "$0" when pageLevelRange returns an empty range; change
the branch in the try block that sets savingsDisplay (currently using
pageLevelRange and the ternary that calls formatCurrency(0)) so that when
range.cellCount === 0 it assigns the absent-data sentinel '--' instead of
formatCurrency(0). Update the code that uses groupRecsByCell, pageLevelRange,
formatSavingsRange, and formatCurrency to reflect: if range.cellCount > 0 use
formatSavingsRange(...), otherwise set savingsDisplay = '--' (preserving the
surrounding try/catch and the console.warn on recErr).
frontend/src/__tests__/dashboard.test.ts (1)

104-111: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align the formatCurrency mock with the new sentinel contract.

This mock still collapses absent/non-finite values to $0, which can hide regressions now that dashboard behavior distinguishes missing values with --.

Suggested adjustment
 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()),
   // providerBadgeClass added for L4/D1 fix: whitelist-based CSS class helper.
   providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''),
 }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/dashboard.test.ts` around lines 104 - 111, The test
mock for formatCurrency currently returns `$0` for absent/non-finite inputs
which breaks the new sentinel contract that represents missing values as `--`;
update the jest.mock for formatCurrency in dashboard.test.ts so it checks
Number.isFinite(val) (or equivalent) and returns `--` for
null/undefined/NaN/Infinity, otherwise returns the formatted string (e.g.,
`$${val}`); locate the mocked symbol formatCurrency in the jest.mock block and
change only its implementation to follow this sentinel behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/__tests__/history.test.ts`:
- Around line 468-491: The test renderHistorySummary: absent total_upfront
renders -- not $0 is wrong because it only checks that summary markup exists;
update the test for viewPlanHistory to assert the actual rendered value for
total_upfront is the placeholder "--" (not "$0") by locating the Total Upfront
Spent cell in the DOM (e.g., within `#history-summary` or by its label) and
asserting its textContent or innerHTML equals or contains "--"; ensure the mock
path that sends undefined for summary.total_upfront still leads formatCurrency
(or the UI code that post-fixes null/undefined) to produce the "--" output and
assert that exact string rather than just the presence of the summary.

In `@frontend/src/__tests__/plans.test.ts`:
- Around line 112-115: The test mock providerBadgeHtml currently injects raw
provider strings into HTML, allowing malicious fixtures to create real tags;
update the providerBadgeHtml jest.fn mock to HTML-escape the provider text
before concatenation (escape &, <, >, ", and ') and use the escaped value for
both the class and inner text outputs (i.e., transform (p||'') into an
escapedProvider variable inside the mock and insert that), so tests assert text
neutralisation rather than permitting injected markup.

In `@frontend/src/plans.ts`:
- Around line 731-735: The template injects purchase.id and purchase.plan_id
into quoted data-* attributes using escapeHtml, which doesn't escape quotes and
allows attribute-breakout XSS; replace usages of escapeHtml for attribute values
with an attribute-safe encoder (e.g., escapeHtmlAttr that calls escapeHtml then
replaces " and ' with &quot; and &`#39`;) or switch to setting dataset properties
via DOM APIs; update all occurrences where data-id/data-plan-id are built (the
template lines that produce buttons with
data-action="run|pause|resume|edit|disable" and the places also noted for
purchase ids) to use the new escapeHtmlAttr (or dataset assignment) instead of
escapeHtml.

---

Outside diff comments:
In `@frontend/src/__tests__/dashboard.test.ts`:
- Around line 104-111: The test mock for formatCurrency currently returns `$0`
for absent/non-finite inputs which breaks the new sentinel contract that
represents missing values as `--`; update the jest.mock for formatCurrency in
dashboard.test.ts so it checks Number.isFinite(val) (or equivalent) and returns
`--` for null/undefined/NaN/Infinity, otherwise returns the formatted string
(e.g., `$${val}`); locate the mocked symbol formatCurrency in the jest.mock
block and change only its implementation to follow this sentinel behavior.

In `@frontend/src/dashboard.ts`:
- Around line 252-267: The savings display logic incorrectly fabricates "$0"
when pageLevelRange returns an empty range; change the branch in the try block
that sets savingsDisplay (currently using pageLevelRange and the ternary that
calls formatCurrency(0)) so that when range.cellCount === 0 it assigns the
absent-data sentinel '--' instead of formatCurrency(0). Update the code that
uses groupRecsByCell, pageLevelRange, formatSavingsRange, and formatCurrency to
reflect: if range.cellCount > 0 use formatSavingsRange(...), otherwise set
savingsDisplay = '--' (preserving the surrounding try/catch and the console.warn
on recErr).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c28d0881-b707-4984-a398-d21471987939

📥 Commits

Reviewing files that changed from the base of the PR and between cbdc4be and aa8f16e.

📒 Files selected for processing (22)
  • frontend/src/__tests__/auth.test.ts
  • frontend/src/__tests__/dashboard-ownership-950.test.ts
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/__tests__/groups.test.ts
  • frontend/src/__tests__/history.test.ts
  • frontend/src/__tests__/plans.test.ts
  • frontend/src/__tests__/recommendations.test.ts
  • frontend/src/__tests__/setup.ts
  • frontend/src/__tests__/users.test.ts
  • frontend/src/__tests__/utils.test.ts
  • frontend/src/approval-details.ts
  • frontend/src/auth.ts
  • frontend/src/dashboard.ts
  • frontend/src/federation.ts
  • frontend/src/groups/groupModals.ts
  • frontend/src/history.ts
  • frontend/src/modules/registrations.ts
  • frontend/src/modules/savings-history.ts
  • frontend/src/plans.ts
  • frontend/src/recommendations.ts
  • frontend/src/users/filters.ts
  • frontend/src/utils.ts

Comment thread frontend/src/__tests__/history.test.ts
Comment thread frontend/src/__tests__/plans.test.ts
Comment thread frontend/src/plans.ts Outdated
…S (CR #1046)

Replace the div.textContent/innerHTML impl with explicit character replacement
so double and single quotes are escaped to &quot; and &#39;. Values interpolated
into data-* attributes via innerHTML could break out of the attribute value when
the string contained a raw quote, enabling XSS (plans.ts:731+, 969-970,
1009-1013). Fix also updates the two utils.test.ts cases that pinned the buggy
unescaped-quote output, hardens the providerBadgeHtml mock in plans.test.ts
to escape the label before injecting it into the test DOM, and strengthens the
history.test.ts absent-total_upfront assertion to verify formatCurrency is
called with null and the rendered card shows '--' instead of '$0'.
@cristim

cristim commented Jun 7, 2026

Copy link
Copy Markdown
Member Author

Addressed all three findings in 9df488b: escapeHtml now escapes quotes (closes the data-attribute XSS), the providerBadgeHtml test mock escapes its text, and the history regression test now asserts the -- path instead of just markup presence.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

@cristim: Solid closure on all three points — the escapeHtml quote-escaping is the most important of the batch since unescaped " / ' in data-id attributes was the residual XSS vector after the earlier innerHTML fixes. Triggering a full review now.

(ᵔ◡ᵔ)∫


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.770Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.
✅ Action performed

Full review finished.

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a fresh full review of PR #1046 now, picking up the latest commits.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:41:06.841Z
Learning: In LeanerCloud/CUDly PR `#804`, `canRevokeCompletedRow` in `frontend/src/history.ts` must check `canAccess` (not just `getCurrentUser()` truthiness) to align with peer predicates `canCancelPendingRow`, `canApprovePendingRow`, and `canRetryFailedRow`. `revoke-own` and `revoke-any` were added to the closed `Action` union to make future UX-vs-RBAC drift a compile error.
✅ Action performed

Full review finished.

- 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).

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

CR finding triage (commit f9abe35):

Outside-diff: dashboard.ts:252-267 (empty savings range fabricates $0) - fix-in-this-PR: changed formatCurrency(0) to literal '--' when range.cellCount === 0; 3 stale test assertions updated from '$0' to '--'. All 2480 tests pass.

Outside-diff: dashboard.test.ts:104-111 (formatCurrency mock sentinel mismatch) - fix-in-this-PR: mock now returns '--' for null/undefined/non-finite, matching the real formatCurrency contract in utils.ts.

Inline: plans.ts:731-735 (escapeHtml doesn't escape quotes in data- attrs)* - skip-CR-misread: escapeHtml in utils.ts:162-163 already escapes " to &quot; and ' to &#39;. Attribute-breakout XSS via quotes is not possible with the current implementation.

Outside-diff: history.test.ts:468-491 (test only checks markup presence) - skip-stale: already fixed in the PR. The test at lines 488-493 asserts formatCurrency was called with null, checks <p class="value">--</p> is present, and checks <p class="value">$0</p> is absent.

Inline: plans.test.ts:112-115 (providerBadgeHtml mock injects raw strings) - skip-stale: already fixed in the PR. Lines 115-119 apply .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;') before rendering.

@coderabbitai review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triage looks clean — all five findings accounted for.

  • dashboard.ts + dashboard.test.ts (outside-diff, fixed): '--' propagated correctly from the catch block through the mock contract. ✓
  • plans.ts escapeHtml (CR misread): Confirmed — &quot; / &ux(onboarding): add AWS IAM console deep link in account modal #39; are in the current escapeHtml implementation, so the attribute-breakout vector doesn't exist. ✓
  • history.test.ts + plans.test.ts (stale): Both already asserting the correct '--' path and escaping in the current branch. ✓

Kicking off an incremental review of the new commits now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 277
File: frontend/src/__tests__/recommendations.test.ts:2472-2476
Timestamp: 2026-05-05T07:46:01.902Z
Learning: In the CUDly frontend (`frontend/src/recommendations.ts`), `effectiveSavingsPct` intentionally has NO plausibility guard (no null return or warning for reconstructed percentages above per-term ceilings). The maintainer explicitly rejected that approach in PR `#277`. The fix for inflated percentages (issue `#274`) is purely a data-path fix: plumbing the provider's canonical `on_demand_cost` through to the frontend so the denominator is not reconstructed from potentially misleading `monthly_cost + savings + amortized`. Tests that pin the reconstructed >80% behavior are intentional documentation of the pre-fix path, not bugs.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:43:53.792Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-07T17:45:38.069Z
Learning: In LeanerCloud/CUDly `providers/gcp/services/cloudsql/client.go`, `getSQLPricing` had the same per-hour/term-total unit mismatch as memorystore and cloudstorage: `commitmentPrice` (per-hour SKU rate) was passed directly to savings math that expected a term total, producing ~99.99% savings, and `HourlyRate` was set to `per-hour / hoursInTerm` (near-zero). Fixed in PR `#1047` commit 20590c6b4 (issue `#1078` folded in): `commitmentPriceTerm := commitmentPrice * hoursInTerm`; `HourlyRate = commitmentPrice` (raw per-hour). `convertGCPRecommendation` also hardcoded `rec.Term = "1yr"`, ignoring `params.Term`; fixed with same defaulting pattern as memorystore/cloudstorage. Regression tests: `TestGetSQLPricing_CommitmentPriceIsTermTotal` and `TestCloudSQLConvertGCPRecommendation_PropagatesTermFromParams`. PaymentOption was already correct (`if paymentOption == "" { paymentOption = "monthly" }`).
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.
@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Attribute-boundary XSS fix (commit fe49c47)

The CR finding was that `escapeHtml` (backed by `div.textContent/innerHTML`) does not encode double-quotes, so values injected into `data-*="..."` attributes could break out of the attribute delimiter and allow markup injection.

Fix:

  • Added `escapeHtmlAttr` to `frontend/src/utils.ts` as a semantic alias for `escapeHtml` (which was already hardened in the prior commit to encode `"` and `'`).
  • Replaced the remaining `escapeHtml` calls in attribute context in `history.ts` (`data-retry-id`, both `data-execution-id` sites) and `plans.ts` (`data-id` and `data-name` on all plan/purchase action buttons).
  • Added unit tests for `escapeHtmlAttr` in `utils.test.ts`.
  • Added an XSS regression test in `history.test.ts` that fails against the pre-fix quote-transparent path (verifies that a purchase ID containing `"` becomes `"` in the rendered attribute).

Changed lines in `history.ts`:

  • line 547 (data-approve-id): escapeHtml -> escapeHtmlAttr (partial prior work)
  • line 550 (data-cancel-id): escapeHtml -> escapeHtmlAttr (partial prior work)
  • line 590 (data-retry-id): escapeHtml -> escapeHtmlAttr (this commit)
  • line 684 (data-execution-id Update Deployment Model to Terraform #1): escapeHtml -> escapeHtmlAttr (this commit)
  • line 985 (data-execution-id Setup CI/CD Process #2): escapeHtml -> escapeHtmlAttr (this commit)

jest: 201 pass, 2 fail (pre-existing UTC timezone flake in formatDate/getDateParts, unrelated). tsc: no errors.

@cristim

cristim commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 84fcb9f into feat/multicloud-web-frontend Jun 8, 2026
5 checks passed
@cristim
cristim deleted the fix/frontend-xss-listeners branch July 27, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working effort/s Hours impact/all-users Affects every user priority/p2 Backlog-worthy severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants