Skip to content

fix(cost-management): move data fetching server-side to eliminate token exposure and RBAC bypass#2616

Open
hardengl wants to merge 8 commits intoredhat-developer:mainfrom
hardengl:fix/cost-plugin-server-side-proxy
Open

fix(cost-management): move data fetching server-side to eliminate token exposure and RBAC bypass#2616
hardengl wants to merge 8 commits intoredhat-developer:mainfrom
hardengl:fix/cost-plugin-server-side-proxy

Conversation

@hardengl
Copy link
Copy Markdown

@hardengl hardengl commented Mar 26, 2026

Summary

Addresses three high-severity security findings from the RHDH Cost Plugin threat model.

Jira Tickets

Ticket Description Severity Status
FLPATH-3503 Epic: Cost Management Plugin - Threat Model Remediation - Parent
FLPATH-3487 Move Cost Management data fetching server-side to eliminate token exposure and RBAC bypass Critical Fixed in this PR
FLPATH-3504 Fix 401 retry path dropping RBAC filters in OptimizationsClient Critical Fixed in this PR
FLPATH-3505 Add namespace prefix to cache keys Normal Closed (not a bug — the CacheService is already scoped per-plugin in Backstage)

Additional tickets under the same epic (addressed in stacked PRs):

PR Ticket Description Severity
#2618 FLPATH-3488 Add authorization for Apply Recommendation Major
#2618 FLPATH-3491 Audit logging for Apply Recommendation Major
#2618 FLPATH-3492 Validate workloadType allowlist Major
#2619 FLPATH-3490 User identity in audit logs Major
#2620 FLPATH-3489 Permission name dot-separator ambiguity Major

Security Findings Fixed

1. Token Exposure (FLPATH-3487, Finding #1)

The GET /token endpoint returned a full SSO access token (with api.console scope) directly to the browser, allowing any authenticated Backstage user to call the Cost Management API externally, bypassing all RBAC controls.

Fix: Deleted the /token endpoint entirely. All data fetching now goes through a new server-side secure proxy (/api/cost-management/proxy/*) that:

  • Keeps the SSO token on the backend (never exposed to the browser)
  • Authenticates users via Backstage httpAuth
  • Evaluates RBAC permissions server-side before forwarding
  • Strips client-supplied filter parameters and injects server-authorized filters
  • Returns only data the user is permitted to see

2. RBAC Bypass via Query Parameter Manipulation (FLPATH-3487, Finding #2)

The old frontend-side RBAC enforcement allowed users to modify query parameters in browser DevTools to access unauthorized cluster/project data.

Fix: The secure proxy strips all RBAC-controlled filter parameters from the client request and injects the server-determined authorized filters. Additionally, percent-encoded variants of RBAC keys (e.g., filter%5Bexact%3Acluster%5D) are decoded before comparison, preventing encoded bypass.

3. Path Traversal (FLPATH-3487, Finding #3)

The proxy path was not validated, allowing ../ segments or absolute paths.

Fix: Pre-construction validation rejects paths containing ../ segments or leading /. Post-construction validation confirms the final URL stays within the expected base path.

4. 401 Retry Dropping Filters (FLPATH-3504)

The OptimizationsClient.fetchWithTokenAndRetry method's 401-retry code path dropped RBAC filters when re-fetching data after token refresh.

Fix: This is now a non-issue because all RBAC filtering happens server-side in the secure proxy. The frontend client no longer handles tokens or RBAC filters at all — it simply calls the backend proxy which handles everything. The fetchWithTokenAndRetry method has been replaced with fetchViaBackendProxy.

Architecture change

BEFORE:                           AFTER:
┌────────┐  GET /token   ┌───┐   ┌────────┐  GET /proxy/*  ┌───┐  ┌─────┐
│Frontend│──────────────▶│ BE│   │Frontend│───────────────▶│ BE│──▶│ HCC │
│        │  raw SSO token│   │   │        │  no token/     │   │  │ API │
│        │◀──────────────│   │   │        │  no filters    │   │  │     │
│        │               │   │   │        │◀───────────────│   │◀─│     │
│        │  GET /proxy/* │   │   └────────┘  filtered data └───┘  └─────┘
│        │  +token+filter│   │                 RBAC enforced
│        │──────────────▶│   │                 server-side
└────────┘               └───┘

How it works

When a frontend request arrives at /api/cost-management/proxy/*, the backend:

  1. Authenticates the user via Backstage httpAuth
  2. Evaluates the user's permissions against the ros.* or cost.* policy
  3. Determines the authorized list of clusters and projects
  4. Strips any client-supplied cluster/project filter parameters
  5. Injects the server-authorized filters before forwarding to the upstream API
  6. Returns only the data the user is permitted to see

This means granting ros.demolab only allows seeing data for the demolab cluster — the user cannot modify query parameters to access other clusters.

Test plan

Unit tests

  • yarn tsc -b — clean compilation, zero errors
  • Backend tests: 12 passing (secureProxy, router, auditLog)
  • Common tests: 16 passing (permissions, OptimizationsClient)
  • ESLint: zero errors

CI

  • SonarQube Quality Gate: Passed
  • API reports regenerated and committed

Deployment verification

Qodo bot findings (addressed)

  • decodeURIComponent try/catch for malformed percent-encoding (returns 400)
  • Path traversal defense (pre- and post-construction validation)
  • Hardcoded GET method on proxy route (prevents body forwarding for non-GET)
  • Config schema added for costManagementProxyBaseUrl

…en exposure and RBAC bypass

Addresses security findings from the RHDH Cost Plugin threat model:
- Removed /token endpoint that exposed SSO credentials to the browser
- Added secure backend proxy (/api/cost-management/proxy/*) with server-side
  RBAC enforcement and internal SSO token management
- Removed dangerously-allow-unauthenticated proxy configuration
- Updated frontend clients to route through the secure backend proxy

FLPATH-3487

Made-with: Cursor
@rhdh-gh-app
Copy link
Copy Markdown

rhdh-gh-app bot commented Mar 26, 2026

Important

This PR includes changes that affect public-facing API. Please ensure you are adding/updating documentation for new features or behavior.

Changed Packages

Package Name Package Path Changeset Bump Current Version
@red-hat-developer-hub/plugin-cost-management-backend workspaces/cost-management/plugins/cost-management-backend minor v2.0.2
@red-hat-developer-hub/plugin-cost-management-common workspaces/cost-management/plugins/cost-management-common minor v2.0.1
@red-hat-developer-hub/plugin-cost-management workspaces/cost-management/plugins/cost-management minor v2.0.1

@rhdh-qodo-merge
Copy link
Copy Markdown

Review Summary by Qodo

Move Cost Management data fetching server-side to eliminate token exposure and RBAC bypass

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Moved data fetching server-side via new secure backend proxy (/api/cost-management/proxy/*) to
  eliminate token exposure
• Removed /token endpoint that exposed SSO credentials directly to browser clients
• Enforced RBAC server-side before forwarding requests to Cost Management API
• Removed dangerously-allow-unauthenticated proxy configuration from app-config
• Updated frontend clients to route through secure backend proxy instead of direct API calls
Diagram
flowchart LR
  Browser["Browser Client"]
  BackendProxy["Secure Backend Proxy<br/>/api/cost-management/proxy/*"]
  RBAC["RBAC Permission<br/>Check"]
  TokenMgmt["Internal SSO Token<br/>Management"]
  CostAPI["Cost Management API"]
  
  Browser -->|Backstage Cookie| BackendProxy
  BackendProxy -->|Check Permissions| RBAC
  BackendProxy -->|Fetch Token| TokenMgmt
  BackendProxy -->|Inject Filters<br/>+ Bearer Token| CostAPI
  CostAPI -->|Response| BackendProxy
  BackendProxy -->|Filtered Data| Browser
Loading

Grey Divider

File Changes

1. workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts ✨ Enhancement +377/-0

New server-side proxy handler with RBAC enforcement

workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts


2. workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts 🐞 Bug fix +3/-3

Updated auth policies to remove token endpoint

workspaces/cost-management/plugins/cost-management-backend/src/plugin.ts


3. workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts 🐞 Bug fix +3/-2

Removed token endpoint, added secure proxy route

workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts


View more (7)
4. workspaces/cost-management/plugins/cost-management-backend/app-config.dynamic.yaml 🐞 Bug fix +0/-6

Removed unauthenticated proxy configuration

workspaces/cost-management/plugins/cost-management-backend/app-config.dynamic.yaml


5. workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts 🐞 Bug fix +37/-200

Removed client-side token management and RBAC logic

workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts


6. workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.ts ✨ Enhancement +39/-128

Refactored to use dual API clients for direct and proxy routes

workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.ts


7. workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.test.ts 🧪 Tests +9/-21

Updated tests for new proxy URL patterns

workspaces/cost-management/plugins/cost-management-common/src/clients/optimizations/OptimizationsClient.test.ts


8. workspaces/cost-management/.changeset/secure-proxy-server-side.md 📝 Documentation +13/-0

Changeset documenting security fixes and API changes

workspaces/cost-management/.changeset/secure-proxy-server-side.md


9. workspaces/cost-management/plugins/cost-management-common/report-clients.api.md 📝 Documentation +1/-1

Updated API documentation for CostManagementSlimClient

workspaces/cost-management/plugins/cost-management-common/report-clients.api.md


10. workspaces/cost-management/plugins/cost-management-common/report.api.md 📝 Documentation +1/-1

Updated API documentation for CostManagementSlimClient

workspaces/cost-management/plugins/cost-management-common/report.api.md


Grey Divider

Qodo Logo

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Mar 26, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📐 Spec deviations (0)

Grey Divider


Action required

1. Encoded RBAC bypass🐞 Bug ⛨ Security
Description
secureProxy strips RBAC-controlled query parameters by matching regexes against the raw (still
percent-encoded) query string, so encoded keys like filter%5Bexact%3Acluster%5D are not removed
and can be forwarded alongside server-injected filters, enabling RBAC bypass.
Code

workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[R299-320]

+      const rbacControlledPatterns =
+        access.filterStyle === 'ros'
+          ? [/^cluster=/m, /^project=/m]
+          : [/^filter\[exact:cluster\]=/m, /^filter\[exact:project\]=/m];
+
+      const rawQuery = req.originalUrl.split('?')[1] || '';
+      const rawParams = rawQuery.split('&').filter(p => p.length > 0);
+
+      for (const param of rawParams) {
+        const shouldStrip = rbacControlledPatterns.some(pattern =>
+          pattern.test(param),
+        );
+        if (!shouldStrip) {
+          const eqIdx = param.indexOf('=');
+          const key = eqIdx >= 0 ? param.substring(0, eqIdx) : param;
+          const val = eqIdx >= 0 ? param.substring(eqIdx + 1) : '';
+          targetUrl.searchParams.append(
+            decodeURIComponent(key),
+            decodeURIComponent(val),
+          );
+        }
+      }
Evidence
The proxy decides whether to strip RBAC-controlled parameters by testing regexes against the raw
query param string (before decoding), but only decodes after the strip decision. Meanwhile, the
frontend CostManagementSlimClient builds query strings using URLSearchParams with bracketed keys
like filter[exact:cluster], which are commonly percent-encoded in URLs, making the current strip
patterns easy to bypass.

workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[299-320]
workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts[48-57]
workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts[220-281]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`secureProxy` strips RBAC-controlled params by regex against the *raw* query string. Because it checks *before* decoding, percent-encoded versions of the same keys can slip through and be forwarded upstream, allowing users to inject their own cluster/project filters.

### Issue Context
You already parse the raw query string to preserve bracketed keys; keep that approach, but decode *before* deciding whether to strip.

### Fix Focus Areas
- workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[299-320]

### Suggested approach
- Parse `rawQuery` using `new URLSearchParams(rawQuery)` (this decodes for you).
- Strip by decoded key name (e.g., `cluster`, `project`, `filter[exact:cluster]`, `filter[exact:project]`) rather than regex on the undecoded string.
- Append only non-RBAC-controlled keys to `targetUrl.searchParams`.
- Add a regression test case (if tests exist for backend) proving that encoded `filter%5Bexact%3Acluster%5D=...` is removed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Backend URL composition broken 🐞 Bug ✓ Correctness
Description
CostManagementSlimClient now builds resource-type URLs under /proxy/resource-types/... even when
options.token is provided for backend use, causing backend calls (base URL
https://console.redhat.com/api) to hit a non-existent /api/proxy/... path and breaking RBAC
scope discovery.
Code

↗ workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts

  ): Promise<
Evidence
When a backend token is provided, searchOpenShiftClusters/Projects correctly chooses
fetchWithExternalToken, but the URL it uses is generated by buildResourceTypeUrl, which now
hardcodes /proxy/resource-types/.... The backend service factory configures the discovery base URL
to RHCC (https://console.redhat.com/api), so the resulting request becomes
https://console.redhat.com/api/proxy/... instead of .../api/cost-management/v1/.... secureProxy
depends on these backend calls to enumerate clusters/projects for RBAC filtering, so this breaks
secure proxy access resolution.

workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts[355-403]
workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts[481-502]
workspaces/cost-management/plugins/cost-management-backend/src/service/costManagementService.ts[36-47]
workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[201-205]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`CostManagementSlimClient` now routes resource-type requests via `${baseUrl}/proxy/...` unconditionally, but backend usage passes a RHCC base URL (`https://console.redhat.com/api`). With `options.token` set, the client attempts to call RHCC directly but uses a `/proxy/...` path that RHCC does not provide.

### Issue Context
Backend RBAC resolution (`secureProxy.resolveCostManagementAccess`) calls `costManagementApi.searchOpenShiftClusters/Projects` with a token to build the universe of clusters/projects.

### Fix Focus Areas
- workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts[355-403]
- workspaces/cost-management/plugins/cost-management-common/src/clients/cost-management/CostManagementSlimClient.ts[481-502]
- workspaces/cost-management/plugins/cost-management-backend/src/service/costManagementService.ts[36-47]

### Suggested approach
- For methods that accept `options?: { token?: string; ... }`:
 - If `options.token` is present, build the *direct* RHCC URL: `${baseUrl}/cost-management/v1/resource-types/${resourceType}/...` (matching the comment in `costManagementService.ts`).
 - Otherwise (frontend), build the *proxy* URL: `${pluginBaseUrl}/proxy/resource-types/...`.
- Consider following the same pattern as `OptimizationsClient` (two internal DefaultApiClient instances: `directClient` and `proxyClient`) to make the split explicit and avoid mixing semantics in one URL builder.
- Add/adjust a backend unit test (if present) that asserts `searchOpenShiftClusters('', { token })` calls `/cost-management/v1/resource-types/...` and not `/proxy/resource-types/...` when the discovery base URL is `https://console.redhat.com/api`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Proxy path traversal🐞 Bug ⛨ Security
Description
secureProxy interpolates user-controlled proxyPath directly into the upstream URL without
validating dot-segments, allowing ../ paths to escape /cost-management/v1 and reach other
console.redhat.com API endpoints using the backend-held token.
Code

workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[R274-295]

+    const proxyPath = req.params[0];
+
+    if (!proxyPath) {
+      return res.status(400).json({ error: 'Missing proxy path' });
+    }
+
+    try {
+      const access = await resolveAccess(req, proxyPath, options);
+
+      if (access.decision !== 'ALLOW') {
+        return res.status(403).json({ error: 'Access denied by RBAC policy' });
+      }
+
+      const token = await getTokenFromApi(options);
+
+      const targetBase =
+        config?.getOptionalString('costManagementProxyBaseUrl') ??
+        DEFAULT_COST_MANAGEMENT_PROXY_BASE_URL;
+      const targetUrl = new URL(
+        `${targetBase}/cost-management/v1/${proxyPath}`,
+      );
+
Evidence
proxyPath is taken from the wildcard route and concatenated into the upstream URL. Without
validation, paths containing .. (or absolute-path forms) can change the effective upstream
pathname after URL normalization, turning this into a high-privilege open proxy beyond the intended
Cost Management API surface.

workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[274-295]
workspaces/cost-management/plugins/cost-management-backend/src/service/router.ts[42-48]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`secureProxy` builds `targetUrl` using user-controlled `proxyPath` without constraining it to remain under `/cost-management/v1/`, enabling path traversal via `..` segments.

### Issue Context
This route injects an SSO bearer token; escaping the intended path would allow access to other RHCC endpoints.

### Fix Focus Areas
- workspaces/cost-management/plugins/cost-management-backend/src/routes/secureProxy.ts[274-295]

### Suggested approach
- Build a base URL with a guaranteed trailing slash, e.g. `const base = new URL(`${targetBase}/cost-management/v1/`);`
- Construct with `const targetUrl = new URL(proxyPath, base);`
- Reject if `!targetUrl.pathname.startsWith(base.pathname)` (covers `..` normalization and absolute-path inputs).
- Optionally also reject `proxyPath` containing `..` or starting with `/` as a defense-in-depth guard.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines +299 to +320
const rbacControlledPatterns =
access.filterStyle === 'ros'
? [/^cluster=/m, /^project=/m]
: [/^filter\[exact:cluster\]=/m, /^filter\[exact:project\]=/m];

const rawQuery = req.originalUrl.split('?')[1] || '';
const rawParams = rawQuery.split('&').filter(p => p.length > 0);

for (const param of rawParams) {
const shouldStrip = rbacControlledPatterns.some(pattern =>
pattern.test(param),
);
if (!shouldStrip) {
const eqIdx = param.indexOf('=');
const key = eqIdx >= 0 ? param.substring(0, eqIdx) : param;
const val = eqIdx >= 0 ? param.substring(eqIdx + 1) : '';
targetUrl.searchParams.append(
decodeURIComponent(key),
decodeURIComponent(val),
);
}
}

This comment was marked as resolved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. Fixed in c092f79.

The approach was changed from regex-based matching on the raw (still percent-encoded) query string to Set-based matching on decoded keys:

  • Each raw query parameter is now decoded via decodeURIComponent(rawKey) before checking against the RBAC-controlled set
  • The controlled keys are stored in a Set<string> (cluster, project, filter[exact:cluster], filter[exact:project]) instead of regex patterns
  • This means filter%5Bexact%3Acluster%5D, filter%5bexact%3acluster%5d, or any other encoded variant will be decoded to filter[exact:cluster] and correctly stripped

The stripping logic now looks like:

const rbacControlledKeys = new Set(
  access.filterStyle === 'ros'
    ? ['cluster', 'project']
    : ['filter[exact:cluster]', 'filter[exact:project]'],
);

const decodedKey = decodeURIComponent(rawKey);
if (!rbacControlledKeys.has(decodedKey)) {
  targetUrl.searchParams.append(decodedKey, decodeURIComponent(rawVal));
}

…hitecture

- Remove all references to dangerously-allow-unauthenticated proxy configuration
- Document new secure backend proxy architecture and endpoints
- Update static and dynamic plugin setup instructions
- Add server-side RBAC enforcement explanation to rbac.md

Made-with: Cursor
…C bypass

- Reject proxyPath containing dot-segments (../) or leading slashes to prevent
  path traversal beyond /cost-management/v1/
- Post-construction check ensures resolved pathname stays under the base path
- Decode query parameter keys before RBAC matching so percent-encoded variants
  (e.g. filter%5Bexact%3Acluster%5D) are correctly stripped
- Replace regex-based stripping with Set-based decoded key lookup

Made-with: Cursor
…uplication

Extract resolveAccessForSection() to eliminate structural duplication between
resolveOptimizationsAccess and resolveCostManagementAccess. Each section now
provides only a data-fetching callback while the common authorize-cache-filter
logic lives in one place.

Made-with: Cursor
@hardengl
Copy link
Copy Markdown
Author

Addressed in 75d3be7 — extracted the common authorize-cache-filter-decide pattern into resolveAccessForSection(), reducing secureProxy.ts from ~390 lines to ~350 lines and eliminating the structural duplication between resolveOptimizationsAccess and resolveCostManagementAccess. Each section now provides only a data-fetching callback while the shared RBAC resolution logic lives in one place.

Net change: -144 lines, +111 lines (33 fewer lines total).

hardengl and others added 4 commits March 26, 2026 20:09
…eclare config schema

Defense-in-depth: hardcode fetch method to 'GET' instead of passing req.method,
preventing SSRF amplification if the route registration ever changes from router.get
to router.all. Add costManagementProxyBaseUrl to config.d.ts with @visibility backend
so the config key is schema-validated and documented.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract three helper functions from the secureProxy handler to bring
cognitive complexity from 24 down below the SonarQube threshold of 15:

- isPathTraversal: path validation guard
- parseClientQueryParams: query string parsing with RBAC key stripping
- injectRbacFilters: server-side RBAC filter injection

Made-with: Cursor
…indings

Add missing OpenShift route, menu hierarchy, and explain why the
proxy.endpoints config was removed (replaced by server-side secure proxy).

Made-with: Cursor
…nt config

Update the ConfigMap example to exactly match the working configuration
deployed on ocp-edge73: icon reference name costManagementIcon and
dot-separated menuItems keys (cost-management.optimizations).

Made-with: Cursor
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant