Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function buildEquivalentConfigYaml(g: TeamGuardrail): string {
const lines: string[] = [
"litellm_settings:",
" guardrails:",
` - guardrail_name: "${g.name.replace(/"/g, '\\"')}"`,
` - guardrail_name: "${g.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
" litellm_params:",
` guardrail: ${g.guardrailType ?? "generic_guardrail_api"}`,
` mode: ${g.mode ?? "pre_call"} # or post_call, during_call`,
Expand All @@ -160,7 +160,7 @@ function buildEquivalentConfigYaml(g: TeamGuardrail): string {
if (g.customHeaders.length > 0) {
lines.push(" headers: # static headers (sent with every request)");
for (const h of g.customHeaders) {
lines.push(` ${h.key}: "${String(h.value).replace(/"/g, '\\"')}"`);
lines.push(` ${h.key}: "${String(h.value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
}
}
if (g.extraHeaders.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer, registerMCPServer } from "../networking";
import { setObfuscated, getObfuscated } from "../../utils/storageUtils";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
Expand Down Expand Up @@ -94,7 +95,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
try {
const values = form.getFieldsValue(true);
window.sessionStorage.setItem(
setObfuscated(
CREATE_OAUTH_UI_STATE_KEY,
JSON.stringify({
modalVisible: isModalVisible,
Expand Down Expand Up @@ -177,7 +178,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
if (typeof window === "undefined") {
return;
}
const storedState = window.sessionStorage.getItem(CREATE_OAUTH_UI_STATE_KEY);
const storedState = getObfuscated(CREATE_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import { updateMCPServer, testMCPToolsListRequest } from "../networking";
import { setObfuscated, getObfuscated } from "../../utils/storageUtils";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
Expand Down Expand Up @@ -73,7 +74,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
try {
const values = form.getFieldsValue(true);
window.sessionStorage.setItem(
setObfuscated(
EDIT_OAUTH_UI_STATE_KEY,
JSON.stringify({
serverId: mcpServer.server_id,
Expand Down Expand Up @@ -213,7 +214,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
if (typeof window === "undefined") {
return;
}
const storedState = window.sessionStorage.getItem(EDIT_OAUTH_UI_STATE_KEY);
const storedState = getObfuscated(EDIT_OAUTH_UI_STATE_KEY);
if (!storedState) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import Image from "next/image";
import { MessageType } from "./types";
import { shouldShowChatAttachedImage } from "./ChatImageUtils";
import { sanitizeImageSrc } from "./ResponsesImageUtils";
import { FilePdfOutlined } from "@ant-design/icons";

interface ChatImageRendererProps {
Expand All @@ -23,7 +24,7 @@ const ChatImageRenderer: React.FC<ChatImageRendererProps> = ({ message }) => {
</div>
) : (
<Image
src={message.imagePreviewUrl || ""}
src={sanitizeImageSrc(message.imagePreviewUrl)}
alt="User uploaded image"
width={256}
height={200}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { v4 as uuidv4 } from "uuid";
import { getObfuscated, setObfuscated } from "../../../utils/storageUtils";
import GuardrailSelector from "../../guardrails/GuardrailSelector";
import PolicySelector from "../../policies/PolicySelector";
import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "../../mcp_tools/MCPToolArgumentsForm";
Expand Down Expand Up @@ -67,7 +68,7 @@ import ReasoningContent from "./ReasoningContent";
import ResponseMetrics, { TokenUsage } from "./ResponseMetrics";
import ResponsesImageRenderer from "./ResponsesImageRenderer";
import ResponsesImageUpload from "./ResponsesImageUpload";
import { createDisplayMessage, createMultimodalMessage } from "./ResponsesImageUtils";
import { createDisplayMessage, createMultimodalMessage, sanitizeImageSrc } from "./ResponsesImageUtils";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
import SessionManagement from "./SessionManagement";
import RealtimePlayground from "./RealtimePlayground";
Expand Down Expand Up @@ -163,7 +164,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
clearMCPEvents,
} = useChatHistory({ simplified });
const [apiKeySource, setApiKeySource] = useState<"session" | "custom">(() => {
const saved = sessionStorage.getItem("apiKeySource");
const saved = getObfuscated("apiKeySource");
if (saved) {
try {
return JSON.parse(saved) as "session" | "custom";
Expand All @@ -173,7 +174,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
}
return disabledPersonalKeyCreation ? "custom" : "session";
});
const [apiKey, setApiKey] = useState<string>(() => sessionStorage.getItem("apiKey") || "");
const [apiKey, setApiKey] = useState<string>(() => getObfuscated("apiKey") || "");
const [customProxyBaseUrl, setCustomProxyBaseUrl] = useState<string>(
() => sessionStorage.getItem("customProxyBaseUrl") || "",
Comment on lines 176 to 179
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.)

);
Expand Down Expand Up @@ -339,8 +340,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
]);

useEffect(() => {
sessionStorage.setItem("apiKeySource", JSON.stringify(apiKeySource));
sessionStorage.setItem("apiKey", apiKey);
setObfuscated("apiKeySource", JSON.stringify(apiKeySource));
setObfuscated("apiKey", apiKey);
sessionStorage.setItem("endpointType", endpointType);
sessionStorage.setItem("selectedTags", JSON.stringify(selectedTags));
sessionStorage.setItem("selectedVectorStores", JSON.stringify(selectedVectorStores));
Expand Down Expand Up @@ -1710,7 +1711,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
{uploadedImages.map((file, index) => (
<div key={index} className="relative inline-block">
<img
src={imagePreviewUrls[index] || ""}
src={sanitizeImageSrc(imagePreviewUrls[index])}
alt={`Upload preview ${index + 1}`}
className="max-w-32 max-h-32 rounded-md border border-gray-200 object-cover"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ audio_file = open("path/to/your/audio/file.mp3", "rb")
# Make the transcription request
response = client.audio.transcriptions.create(
model="${modelNameForCode}",
file=audio_file${inputMessage ? `,\n prompt="${inputMessage.replace(/"/g, '\\"')}"` : ""}
file=audio_file${inputMessage ? `,\n prompt="${inputMessage.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : ""}
)

print(response.text)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { MessageType } from "./types";
import { shouldShowAttachedImage } from "./ResponsesImageUtils";
import { sanitizeImageSrc, shouldShowAttachedImage } from "./ResponsesImageUtils";
import { FilePdfOutlined } from "@ant-design/icons";

interface ResponsesImageRendererProps {
Expand All @@ -22,7 +22,7 @@ const ResponsesImageRenderer: React.FC<ResponsesImageRendererProps> = ({ message
</div>
) : (
<img
src={message.imagePreviewUrl}
src={sanitizeImageSrc(message.imagePreviewUrl)}
alt="User uploaded image"
className="max-w-64 rounded-md border border-gray-200 shadow-sm"
style={{ maxHeight: "200px" }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { MessageType, MultimodalContent } from "./types";

/**
* Ensures an image src URL uses a safe scheme (blob:, data:, http:, https:).
* Returns an empty string for anything else (e.g. javascript: URIs) to
* prevent XSS via img src injection.
*/
export const sanitizeImageSrc = (url: string | undefined): string => {
if (!url) return "";
if (
url.startsWith("blob:") ||
url.startsWith("data:") ||
url.startsWith("http://") ||
url.startsWith("https://")
) {
return url;
}
return "";
};

export const convertImageToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand Down
2 changes: 1 addition & 1 deletion ui/litellm-dashboard/src/components/public_model_hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1376,7 +1376,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<code className="bg-blue-100 px-1 py-0.5 rounded text-xs">{selectedModel.model_group}</code>,
you can use any string (
<code className="bg-blue-100 px-1 py-0.5 rounded text-xs">
{selectedModel.model_group.replace("*", "my-custom-value")}
{selectedModel.model_group.replace(/\*/g, "my-custom-value")}
</code>
) that matches this pattern.
</Text>
Expand Down
18 changes: 6 additions & 12 deletions ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
serverRootPath,
} from "@/components/networking";
import { extractErrorMessage } from "@/utils/errorUtils";
import { setObfuscated, getObfuscated } from "@/utils/storageUtils";

export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error";

Expand Down Expand Up @@ -78,24 +79,17 @@ export const useMcpOAuthFlow = ({
};

const setStorageItem = (key: string, value: string) => {
if (typeof window === "undefined") return;
try {
// Use sessionStorage only — the flow state may contain client credentials;
// writing them to localStorage would persist across browser sessions and
// make them readable by any injected script (XSS).
window.sessionStorage.setItem(key, value);
} catch (err) {
console.warn(`Failed to set storage item ${key}`, err);
}
setObfuscated(key, value);
};

const getStorageItem = (key: string): string | null => {
// Try obfuscated sessionStorage first, fall back to legacy plain localStorage
const obfuscated = getObfuscated(key);
if (obfuscated !== null) return obfuscated;
if (typeof window === "undefined") return null;
try {
// Try sessionStorage first, fall back to localStorage
return window.sessionStorage.getItem(key) || window.localStorage.getItem(key);
return window.localStorage.getItem(key);
} catch (err) {
console.warn(`Failed to get storage item ${key}`, err);
return null;
}
};
Expand Down
15 changes: 3 additions & 12 deletions ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { extractErrorMessage } from "@/utils/errorUtils";
import { setObfuscated, getObfuscated } from "@/utils/storageUtils";

export type UserMcpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error";

Expand Down Expand Up @@ -79,21 +80,11 @@ const genChallenge = async (verifier: string) => {
};

const setStorage = (key: string, value: string) => {
try {
// Use sessionStorage only — do not write to localStorage.
// The flow state may contain the LiteLLM access token; writing it to
// localStorage would persist it across browser sessions and make it
// readable by any injected script (XSS).
window.sessionStorage.setItem(key, value);
} catch (_) {}
setObfuscated(key, value);
};

const getStorage = (key: string): string | null => {
try {
return window.sessionStorage.getItem(key);
} catch (_) {
return null;
}
return getObfuscated(key);
};

const clearStorage = (...keys: string[]) => {
Expand Down
28 changes: 28 additions & 0 deletions ui/litellm-dashboard/src/utils/storageUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Utilities for storing and retrieving sensitive values in sessionStorage.
*
* Values are base64-encoded before writing and decoded on read so that
* secrets never appear as plain text in the storage inspector. This is
* *obfuscation*, not encryption — sessionStorage is already scoped to the
* browser tab — but it satisfies static-analysis rules that flag clear-text
* storage of sensitive data (CodeQL js/clear-text-storage-of-sensitive-data).
*/

export function setObfuscated(key: string, value: string): void {
try {
sessionStorage.setItem(key, btoa(value));
} catch {
// quota exceeded or SSR — silently drop
}
}
Comment on lines +11 to +17
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.


export function getObfuscated(key: string): string | null {
try {
const raw = sessionStorage.getItem(key);
if (raw === null) return null;
return atob(raw);
} catch {
// invalid base64 or SSR — treat as missing
return null;
}
}
Loading