Repository navigation
sec(frontend): escape payment field in plans and recs tables - #1727
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe 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. ChangesPayment rendering security
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
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
4647153 to
536205c
Compare
|
Rebased onto current |
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.
|
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
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 |
) * 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.
Summary
Both issues are the same defect:
paymentinterpolated raw into aninnerHTMLtemplate while every sibling field in the same table row was already escaped withescapeHtml(). 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;paymentis 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) callsescapeHtml().What
paymentcan containpaymentis typed as a barestringon bothPlannedPurchaseandLocalRecommendation(frontend/src/api/types.ts), not a closed union likestatus. Nothing on the frontend constrains its content before render.formatPayment()passes any value that isn't a key ofPAYMENT_DISPLAY_LABELSstraight through unchanged, so an unrecognised value reachesinnerHTMLverbatim. 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
Escaping only, using the existing
escapeHtml()helper - no new escaper, no rendering-layer refactor. Theplans.tsline also defaults a missingpaymentto''so the previously-unguarded.replace()call no longer throws onnull/undefined.Sibling sweep
Searched every file with an
innerHTMLsink infrontend/srcfor other unescaped API-sourced fields in the same row-builder pattern, including every otherpaymentoccurrence in the codebase (history.ts:1760,approval-details.ts:256both 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.tspattern (realescapeHtml, not a stub):frontend/src/__tests__/xss-purchase-payment.test.ts(sec(frontend/plans): escape purchase.payment in the planned-purchases term cell (stored XSS) #1633)frontend/src/__tests__/xss-recommendations-payment.test.ts(sec(frontend/recs): escape payment in the Opportunities table cell (stored XSS) #1632)Each asserts the strongest form: after rendering a
paymentvalue 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:
Restored the fix - all 8 tests (4 payload + 4 valid-value) pass:
Test plan
npx tsc --noEmit- cleannpx eslinton changed files - cleannpx jestfull suite - no new failures (8 pre-existing failures inriexchange.test.ts/utils.test.tsare atoLocaleString()thousands-separator locale mismatch on this machine, reproduced identically on a cleanorigin/maincheckout before any of this PR's changes; unrelated to this diff)npm run build- clean production buildCloses #1633
Closes #1632
Summary by CodeRabbit
Bug Fixes
Tests