mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
fix(ui): address CodeQL security alerts from GHAS scan
- 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>
This commit is contained in:
parent
d8e4fc4dd0
commit
fd4fc284e2
12 changed files with 76 additions and 41 deletions
|
|
@ -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`,
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -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") || "",
|
||||
);
|
||||
|
|
@ -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));
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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" }}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
28
ui/litellm-dashboard/src/utils/storageUtils.ts
Normal file
28
ui/litellm-dashboard/src/utils/storageUtils.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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…
Add table
Reference in a new issue