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:
yuneng-jiang 2026-03-20 21:52:08 -07:00
parent d8e4fc4dd0
commit fd4fc284e2
12 changed files with 76 additions and 41 deletions

View file

@ -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) {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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}

View file

@ -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"
/>

View file

@ -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)

View file

@ -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" }}

View file

@ -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();

View file

@ -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>

View file

@ -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;
}
};

View file

@ -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[]) => {

View 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;
}
}