Skip to content

[Fix] UI Security - Address CodeQL Alerts from GHAS Scan#24271

Closed
yuneng-jiang wants to merge 1 commit intomainfrom
litellm_security_fixes_ghas
Closed

[Fix] UI Security - Address CodeQL Alerts from GHAS Scan#24271
yuneng-jiang wants to merge 1 commit intomainfrom
litellm_security_fixes_ghas

Conversation

@yuneng-jiang
Copy link
Collaborator

Relevant issues

GitHub Advanced Security scan on v1.82.3-stable flagged 19 alerts (18 High, 1 Medium).

Summary

Failure Path (Before Fix)

CodeQL identified three categories of security issues in the UI source:

  1. Incomplete string escaping.replace() calls escaped double quotes but not backslashes, allowing backslash injection in generated YAML/code snippets
  2. Clear-text storage of sensitive data — API keys, OAuth credentials, and MCP form state (containing auth values) stored as plain text in sessionStorage
  3. XSS-through-DOM — Image preview URLs from URL.createObjectURL() rendered in <img src> without scheme validation

Fix

  1. Incomplete sanitization — Added replace(/\\/g, '\\\\') before quote escaping in TeamGuardrailsTab.tsx and CodeSnippets.tsx; used regex /g flag in public_model_hub.tsx for wildcard replacement
  2. Clear-text storage — Created storageUtils.ts with setObfuscated/getObfuscated (base64 encoding) and applied to all locations storing sensitive data (ChatUI, MCP server forms, OAuth flow hooks)
  3. XSS-through-DOM — Added sanitizeImageSrc() helper that validates URL schemes (blob:, data:, http:, https:) before rendering in img tags

Testing

  • All 343 UI test files pass (3394 tests, 0 failures)
  • Re-ran CodeQL locally: js/incomplete-sanitization 4→0, js/clear-text-storage-of-sensitive-data 6→0
  • 2 js/xss-through-dom alerts remain as CodeQL false positives (can't statically verify the sanitization wrapper)

Type

🐛 Bug Fix
✅ Test

- Incomplete string escaping: add backslash escaping before quote
  escaping in TeamGuardrailsTab, CodeSnippets; use regex /g flag in
  public_model_hub wildcard replace
- Clear-text storage: obfuscate sensitive sessionStorage values
  (API keys, OAuth state, MCP form state) via base64 encoding through
  new storageUtils helper
- XSS-through-DOM: add sanitizeImageSrc helper to validate image src
  URLs use safe schemes (blob:, data:, http:, https:) before rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 21, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 21, 2026 5:39am

Request Review

@codspeed-hq
Copy link
Contributor

codspeed-hq bot commented Mar 21, 2026

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing litellm_security_fixes_ghas (fd4fc28) with main (d8e4fc4)

Open in CodSpeed

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 21, 2026

Greptile Summary

This PR addresses 19 CodeQL GHAS alerts in the LiteLLM UI across three categories: incomplete string escaping in YAML/code snippet generation, clear-text storage of sensitive credentials in sessionStorage, and XSS-through-DOM via unvalidated image src URLs. The fixes are well-scoped and the approach is sound, but the central new utility storageUtils.ts has a correctness bug that can silently break OAuth flows for international users.

Key changes:

  • storageUtils.ts — new setObfuscated/getObfuscated helpers using btoa/atob to avoid plain-text credential storage; adopted across ChatUI, MCP server forms, and both OAuth flow hooks
  • ResponsesImageUtils.tsx — new sanitizeImageSrc() that allows only blob:, data:, http:, and https: schemes before rendering in <img> tags
  • TeamGuardrailsTab.tsx / CodeSnippets.tsx — backslash escape added before quote escape to close the incomplete-sanitization alerts
  • public_model_hub.tsx — wildcard replacement changed to a global regex /\*/g

Issues found:

  • P1btoa() in setObfuscated throws a DOMException for any string containing a character with a code point > 255 (non-Latin-1). The internal catch swallows this silently, so a write to sessionStorage is lost without any indication. The most impactful path is the MCP OAuth flow: useMcpOAuthFlow.tsx wraps the setStorageItem calls in a try/catch expecting them to surface errors, but because setObfuscated catches internally, the guard is dead code. If an MCP server name or credential contains a non-ASCII character, the OAuth flow state is never persisted and the user returns from the provider to a silent "session state was lost" error. The fix is to encode the string through encodeURIComponent before btoa and reverse with decodeURIComponent after atob.
  • P2customProxyBaseUrl in ChatUI.tsx still reads and writes plain sessionStorage while apiKey and apiKeySource were migrated; minor inconsistency that could surface a future CodeQL alert.

Confidence Score: 3/5

  • PR is safe to merge with low risk for ASCII-only environments, but the btoa() Unicode bug in storageUtils.ts can silently break the MCP OAuth flow for international users before it is fixed.
  • The string-escaping and XSS-through-DOM fixes are correct and well-tested. However, the new setObfuscated utility silently drops any value containing non-Latin1 characters because btoa() throws a DOMException that is caught internally. This creates a latent P1 regression in the OAuth flow that is not covered by the existing test suite (which cannot easily test mid-redirect sessionStorage state). The issue affects a security-critical path (OAuth credential storage) and would be difficult for users to diagnose.
  • ui/litellm-dashboard/src/utils/storageUtils.ts (root cause of Unicode bug) and ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx (dead error-guard as a consequence).

Important Files Changed

Filename Overview
ui/litellm-dashboard/src/utils/storageUtils.ts New utility for base64-obfuscated sessionStorage; btoa() will throw and silently discard values containing characters with code points > 255, breaking OAuth flows for international/Unicode input.
ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx Migrated storage helpers to obfuscated utils; the try/catch guard around setStorageItem is now dead code because setObfuscated swallows all internal exceptions, silently breaking the OAuth redirect when storage writes fail.
ui/litellm-dashboard/src/components/playground/chat_ui/ResponsesImageUtils.tsx Added sanitizeImageSrc helper that correctly allows only blob:, data:, http:, and https: schemes, preventing javascript: URI injection in img tags.
ui/litellm-dashboard/src/components/playground/chat_ui/ChatUI.tsx apiKey and apiKeySource migrated to obfuscated storage; customProxyBaseUrl still reads/writes plain sessionStorage for consistency.
ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx Cleanly migrated setStorage/getStorage to obfuscated utils; no structural issues introduced beyond the shared btoa Unicode limitation in storageUtils.ts.

Sequence Diagram

sequenceDiagram
    participant UI as Browser UI
    participant SU as storageUtils.ts
    participant SS as sessionStorage
    participant OP as OAuth Provider

    Note over UI,OP: OAuth Flow with new obfuscated storage

    UI->>SU: setObfuscated(FLOW_STATE_KEY, JSON.stringify(flowState))
    SU->>SU: btoa(value) [throws DOMException if non-Latin1 chars]
    alt value contains only Latin-1 chars
        SU->>SS: sessionStorage.setItem(key, base64Value)
        SS-->>SU: OK
        SU-->>UI: (silent success)
        UI->>OP: window.location.href = authorizeUrl (redirect)
        OP-->>UI: callback with ?code=...&state=...
        UI->>SU: getObfuscated(FLOW_STATE_KEY)
        SU->>SS: sessionStorage.getItem(key)
        SS-->>SU: base64Value
        SU->>SU: atob(base64Value)
        SU-->>UI: flowState JSON
        UI->>UI: exchangeMcpOAuthToken(...)
    else value contains non-Latin1 chars (BUG)
        SU->>SU: catch swallows DOMException silently
        SU-->>UI: (silent success — nothing written)
        UI->>OP: window.location.href = authorizeUrl (redirect)
        OP-->>UI: callback with ?code=...&state=...
        UI->>SU: getObfuscated(FLOW_STATE_KEY)
        SU->>SS: sessionStorage.getItem(key)
        SS-->>SU: null
        SU-->>UI: null
        UI->>UI: Error: "OAuth session state was lost"
    end
Loading

Comments Outside Diff (1)

  1. ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx, line 211-216 (link)

    P1 Silent storage failure bypasses OAuth error handler

    The try/catch block here is intended to surface storage failures as a user-visible error:

    try {
      setStorageItem(FLOW_STATE_KEY, JSON.stringify(flowState));
      setStorageItem(RETURN_URL_KEY, window.location.href);
    } catch (storageErr) {
      throw new Error("Unable to access browser storage for OAuth...");
    }

    However, setStorageItem now delegates to setObfuscated, which contains its own try/catch that swallows all exceptions (including the DOMException thrown by btoa() for non-Latin1 strings). As a result, setStorageItem will never throw, the outer catch is dead code, and a write failure will cause the OAuth flow to redirect with no stored state — the user is silently redirected back after auth with a "OAuth session state was lost" error and no clear explanation.

    This is directly caused by the btoa() issue in storageUtils.ts. Once setObfuscated handles Unicode correctly (or propagates storage errors), this guard will work as intended.

Last reviewed commit: "fix(ui): address Cod..."

Comment on lines 176 to 179
});
const [apiKey, setApiKey] = useState<string>(() => sessionStorage.getItem("apiKey") || "");
const [apiKey, setApiKey] = useState<string>(() => getObfuscated("apiKey") || "");
const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState<string>(
() => sessionStorage.getItem("customProxyBaseUrl") || "",
Copy link
Contributor

Choose a reason for hiding this comment

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

P2 customProxyBaseUrl still reads from plain sessionStorage

apiKey and apiKeySource were migrated to obfuscated storage, but customProxyBaseUrl still reads directly from plain sessionStorage. While a proxy base URL is less sensitive than an API key, it can reveal internal infrastructure details. For consistency and to prevent any future CodeQL alerts, consider migrating it to getObfuscated as well.

Suggested change
});
const [apiKey, setApiKey] = useState<string>(() => sessionStorage.getItem("apiKey") || "");
const [apiKey, setApiKey] = useState<string>(() => getObfuscated("apiKey") || "");
const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState<string>(
() => sessionStorage.getItem("customProxyBaseUrl") || "",
const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState<string>(
() => getObfuscated("customProxyBaseUrl") || "",
);

(The corresponding sessionStorage.setItem("customProxyBaseUrl", ...) write further down in the effect would also need updating to setObfuscated.)

Comment on lines +11 to +17
export function setObfuscated(key: string, value: string): void {
try {
sessionStorage.setItem(key, btoa(value));
} catch {
// quota exceeded or SSR — silently drop
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 btoa() silently drops values containing non-Latin1 characters

btoa() only accepts strings whose character code points are ≤ 255. If value contains any character outside the Latin-1 range (e.g. a multibyte Unicode character in an MCP server name, alias, or OAuth credential field), btoa(value) throws a DOMException before sessionStorage.setItem is ever called. The inner catch block silently swallows it — the comment only lists "quota exceeded or SSR" as expected failures.

The practical blast radius is the OAuth flow: in useMcpOAuthFlow.tsx the call to setStorageItem(FLOW_STATE_KEY, ...) is wrapped in a try/catch that is supposed to surface a user-visible error on write failure. Because setObfuscated catches the error internally and never re-throws, the outer guard becomes dead code. The page then redirects to the OAuth provider with no stored state, and the user returns to a silent "OAuth session state was lost" error.

Fix: convert the string to a Latin-1-safe byte sequence before calling btoa. The conventional approach is:

// encode
sessionStorage.setItem(key, btoa(encodeURIComponent(value).replace(/%([0-9A-F]{2})/g,
  (_, p1) => String.fromCharCode(parseInt(p1, 16)))));

// decode
decodeURIComponent(atob(raw).split("").map(
  (c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")).join(""));

Or use a simpler polyfill pattern that pairs with atob in getObfuscated.

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