Skip to content

sec(frontend): escape payment field in plans and recs tables - #1727

Merged
cristim merged 1 commit into
mainfrom
sec/escape-payment-fields
Aug 8, 2026
Merged

cristim merged 1 commit into
mainfrom
sec/escape-payment-fields

Conversation

@cristim

@cristim cristim commented Aug 7, 2026 •

Copy link
Copy Markdown
Member

Summary

Both issues are the same defect: payment interpolated raw into an innerHTML template while every sibling field in the same table row was already escaped with escapeHtml(). Fixed together.

  • frontend/src/plans.ts:690 (renderPlannedPurchaseRow, term cell) - purchase.payment.replace('-', ' ') was unescaped. The very next cell, escapeHtml(purchase.status), is the sec(frontend): purchase.status rendered unescaped in plans.ts:173 — text-node XSS #445 fix; payment is the field that was left raw.
  • frontend/src/recommendations.ts:2954 (renderColumnCell, case 'payment') - formatPayment(rec.payment) was unescaped, the one exception in a switch where every other string arm (provider, service, resource_type, capacity, region) calls escapeHtml().

What payment can contain

payment is typed as a bare string on both PlannedPurchase and LocalRecommendation (frontend/src/api/types.ts), not a closed union like status. Nothing on the frontend constrains its content before render. formatPayment() passes any value that isn't a key of PAYMENT_DISPLAY_LABELS straight through unchanged, so an unrecognised value reaches innerHTML verbatim. The backend and any provider-echoed or partially-validated write path are the realistic sources; this is a stored-XSS path, not merely theoretical, since the type system provides zero protection.

Fix

// plans.ts
const termCell = purchase.term > 0
  ? `${purchase.term}yr ${escapeHtml((purchase.payment ?? '').replace('-', ' '))}`
  : '—';
// recommendations.ts
case 'payment':
  return `<td>${escapeHtml(formatPayment(rec.payment))}</td>`;

Escaping only, using the existing escapeHtml() helper - no new escaper, no rendering-layer refactor. The plans.ts line also defaults a missing payment to '' so the previously-unguarded .replace() call no longer throws on null/undefined.

Sibling sweep

Searched every file with an innerHTML sink in frontend/src for other unescaped API-sourced fields in the same row-builder pattern, including every other payment occurrence in the codebase (history.ts:1760, approval-details.ts:256 both already escape it correctly). No other unescaped field was found; the two fixed sites were the only gaps.

Regression tests

New files, following the existing xss-purchase-status.test.ts pattern (real escapeHtml, not a stub):

Each asserts the strongest form: after rendering a payment value containing "><img src=x onerror="alert(1)">, list.querySelectorAll('img').length === 0 (no live element parsed out of the cell), plus a <script> payload produces zero <script> elements. Valid/unrecognised payment values are also covered so the fix doesn't regress display.

Verified against the pre-fix source (stashed the two source edits, kept the new tests) - all 4 payload tests failed as expected:

1. script-tag payload in purchase.payment does not create a <script> element
   Expected: 0 / Received: 1
2. img-onerror payload in purchase.payment does not inject a live <img> element
   Expected: 0 / Received: 1
3. script-tag payload in rec.payment does not create a <script> element
   Expected: 0 / Received: 1
4. img-onerror payload in rec.payment does not inject a live <img> element
   Expected: 0 / Received: 1

Restored the fix - all 8 tests (4 payload + 4 valid-value) pass:

PASS (8) FAIL (0)

Test plan

  • npx tsc --noEmit - clean
  • npx eslint on changed files - clean
  • New regression tests fail pre-fix, pass post-fix (pasted above)
  • npx jest full suite - no new failures (8 pre-existing failures in riexchange.test.ts/utils.test.ts are a toLocaleString() thousands-separator locale mismatch on this machine, reproduced identically on a clean origin/main checkout before any of this PR's changes; unrelated to this diff)
  • npm run build - clean production build

Closes #1633
Closes #1632

Summary by CodeRabbit

  • Bug Fixes

    • Improved security when displaying payment information in planned purchases and recommendations.
    • Prevented potentially harmful HTML or script content from executing in payment fields.
    • Ensured missing payment values display safely without causing errors.
    • Preserved readable formatting for valid and unrecognized payment labels.
  • Tests

    • Added regression coverage for unsafe, missing, and standard payment values.

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/all-users Affects every user effort/xs Trivial / one-liner type/security Security finding labels Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec6c5244-0fc7-495d-8fe1-8279a90f8678

📥 Commits

Reviewing files that changed from the base of the PR and between ff808b2 and 4647153.

📒 Files selected for processing (4)
  • frontend/src/__tests__/xss-purchase-payment.test.ts
  • frontend/src/__tests__/xss-recommendations-payment.test.ts
  • frontend/src/plans.ts
  • frontend/src/recommendations.ts

📝 Walkthrough

Walkthrough

The change escapes payment values in planned-purchase and recommendation tables. It also handles missing planned-purchase payments and adds regression tests for malicious, recognized, unrecognized, and absent payment values.

Changes

Payment rendering security

Layer / File(s) Summary
Payment rendering and regression tests
frontend/src/plans.ts, frontend/src/recommendations.ts, frontend/src/__tests__/xss-purchase-payment.test.ts, frontend/src/__tests__/xss-recommendations-payment.test.ts
Planned-purchase payment formatting now handles missing values. Recommendation payment labels are HTML-escaped before insertion. Regression tests verify that malicious values remain text and that valid payment labels render correctly.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the security fix for payment fields in the plans and recommendations tables.
Linked Issues check ✅ Passed The changes address both linked XSS issues, including escaping payment values, handling missing planned-purchase payments, and adding regression tests.
Out of Scope Changes check ✅ Passed All code and test changes directly support the linked XSS fixes and their required regression coverage.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/escape-payment-fields

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

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
@cristim
cristim force-pushed the sec/escape-payment-fields branch from 4647153 to 536205c Compare August 7, 2026 23:38
@cristim

cristim commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current origin/main (post-#1729 npm advisory fix) to clear the merge-blocked state. Mechanical rebase, no conflicts. Diff vs merge-base is byte-identical pre/post rebase (git diff <old-base> <old-head> == git diff <new-base> <new-head>). Re-verified post-rebase: the 8 XSS regression tests pass, including the querySelectorAll('img').length === 0 assertions; tsc --noEmit, eslint, and npm run build clean. Full suite shows the same known 8 pre-existing #1728 locale failures (unrelated, unfixed on main until #1732 merges), no new failures. New head: 536205c.

cristim added a commit that referenced this pull request Aug 7, 2026
Adversarial review of this PR confirmed the escaping in
buildActionOptions/buildResourceOptions is correct today, but nothing
guarded it going forward. Both functions interpolate a stored
permission's raw action/resource string into an HTML attribute
(value="...") and a text label ("not recognized by this form...")
whenever the value falls outside ALL_ACTIONS/ALL_RESOURCES -- the same
"API string reaching innerHTML" pattern #1727 fixed twice elsewhere in
this codebase a few hours earlier.

Adds a test.each over five breakout payloads (element injection,
attribute injection onto the <option> tag itself, a raw <script>, a
single-quote-context breakout, and a closing-tag option injection).
Each case asserts the strong properties rather than that the string
appears somewhere: no element was parsed out of the payload, the
selected <option> gained no attributes beyond value/selected, its
label has zero child elements, and the value survives byte-identical
through the DOM and through an actual save.

Verified the tests have teeth: temporarily stripped escapeHtml from
the action-side fallback option and reran -- 4 of 5 cases failed with
the exact predicted symptoms (extra attributes leaking through on the
attribute-injection payload, an extra <option> from the closing-tag
payload, etc). The fifth (single-quote breakout) correctly does not
fail either way, since the surrounding attribute is double-quoted and
a lone "'" cannot close it regardless of escaping -- kept as a
defense-in-depth case rather than dropped, since it still asserts the
escaping holds under that payload shape. Reverted the temporary
change; all 10 tests in this file pass against the real code.
@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Merging on green CI with zero unresolved threads. CodeRabbit has posted no verdict; its quota is shared across the open PRs and throttled.

Proportionate judgement rather than a waiver: this is a two-line change using the existing escapeHtml helper, and what stands in place of a bot verdict is stronger than a bot verdict would be:

  • Fail-then-pass verified by execution — 4 payload tests fail against pre-fix code with the exact predicted symptoms (a live <script> element, a live <img>), pass after.
  • The assertion form proves the browser did not parse the payload (querySelectorAll('img').length === 0), rather than merely checking an escaped string appears somewhere — which would have passed against a partially-broken fix.
  • The sibling sweep produced a negative result with evidence: all 21 files with innerHTML sinks were checked, and every other payment occurrence (history.ts:1760, approval-details.ts:256) already escapes correctly. These two were the only gap. That is the first single-location finding this session that genuinely was single-location.
  • It also fixed an unguarded dereference neither issue mentioned: purchase.payment would throw on .replace for a null value.

The same author's sibling PR #1730 received a full independent adversarial review which specifically confirmed the escaping pattern holds under five breakout payloads, including verifying that the ../users/utils import is a bare re-export of ../utils rather than a weaker local variant.

@cristim
cristim merged commit c93724d into main Aug 8, 2026
22 checks passed
cristim added a commit that referenced this pull request Aug 8, 2026
)

* sec(frontend): stop group edit from dropping/widening permissions

The group-edit form's action/resource <select> lists were hardcoded to
7 of the backend's 20 actions and 9 of its 11 resources. A stored
permission whose action or resource matched no <option> fell back to
the browser's index-0 default: an unmatched action defaulted to the
empty "Select Action" placeholder, which collectPermissions() treats
as "skip this row" (the permission is dropped); an unmatched resource
defaulted to "All (*)" (the permission is silently widened to the
wildcard). Editing any group holding one of the missing values and
clicking Save re-submitted a materially different permission list with
no error, e.g. the seeded Purchaser group's approve-any/retry-any
verbs vanish and its view:history grant widens to view:*.

Adds ALL_ACTIONS / ALL_RESOURCES to permissions.ts, derived from the
existing hand-maintained Action/Resource closed unions (which already
mirror internal/auth/types.go) via a compile-time exhaustiveness
check, so there is exactly one place left to update when the backend
vocabulary changes. groupModals.ts now builds both <select> lists from
these instead of a third, independently drifting hardcoded copy.

As defense in depth beyond the currently-known vocabulary, a stored
permission whose value still isn't recognised gets an extra option
appended for that exact value, selected and visibly flagged ("not
recognized by this form") rather than silently coerced to a different
one, so the select always round-trips the real stored permission.

This closes the frontend half of #1629. The issue also requires a
grant-ceiling and system_managed check on UpdateGroupAPI/CreateGroupAPI
(internal/api, tracked with #1550) so the same permission list can't
be widened by a direct API call bypassing this form; that backend half
is out of scope here.

* test(frontend): pin the unrecognised-permission-value escaping (F2)

Adversarial review of this PR confirmed the escaping in
buildActionOptions/buildResourceOptions is correct today, but nothing
guarded it going forward. Both functions interpolate a stored
permission's raw action/resource string into an HTML attribute
(value="...") and a text label ("not recognized by this form...")
whenever the value falls outside ALL_ACTIONS/ALL_RESOURCES -- the same
"API string reaching innerHTML" pattern #1727 fixed twice elsewhere in
this codebase a few hours earlier.

Adds a test.each over five breakout payloads (element injection,
attribute injection onto the <option> tag itself, a raw <script>, a
single-quote-context breakout, and a closing-tag option injection).
Each case asserts the strong properties rather than that the string
appears somewhere: no element was parsed out of the payload, the
selected <option> gained no attributes beyond value/selected, its
label has zero child elements, and the value survives byte-identical
through the DOM and through an actual save.

Verified the tests have teeth: temporarily stripped escapeHtml from
the action-side fallback option and reran -- 4 of 5 cases failed with
the exact predicted symptoms (extra attributes leaking through on the
attribute-injection payload, an extra <option> from the closing-tag
payload, etc). The fifth (single-quote breakout) correctly does not
fail either way, since the surrounding attribute is double-quoted and
a lone "'" cannot close it regardless of escaping -- kept as a
defense-in-depth case rather than dropped, since it still asserts the
escaping holds under that payload shape. Reverted the temporary
change; all 10 tests in this file pass against the real code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

1 participant