LLaMA Factory's Chat API has Critical SSRF and LFI Vulnerabilities
Description
LLaMA-Factory is a tuning library for large language models. Prior to version 0.9.4, a Server-Side Request Forgery (SSRF) vulnerability in the chat API allows any authenticated user to force the server to make arbitrary HTTP requests to internal and external networks. This can lead to the exposure of sensitive internal services, reconnaissance of the internal network, or interaction with third-party services. The same mechanism also allows for a Local File Inclusion (LFI) vulnerability, enabling users to read arbitrary files from the server's filesystem. The vulnerability exists in the _process_request function within src/llamafactory/api/chat.py. This function is responsible for processing incoming multimodal content, including images, videos, and audio provided via URLs. The function checks if the provided URL is a base64 data URI or a local file path (os.path.isfile). If neither is true, it falls back to treating the URL as a web URI and makes a direct HTTP GET request using requests.get(url, stream=True).raw without any validation or sanitization of the URL. Version 0.9.4 fixes the underlying issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
LLaMA-Factory prior to 0.9.4 has SSRF and LFI in its chat API, allowing authenticated users to proxy HTTP requests or read server files.
Vulnerability
Description
CVE-2025-61784 identifies a Server-Side Request Forgery (SSRF) and Local File Inclusion (LFI) vulnerability in LLaMA-Factory, an LLM tuning library. The flaw resides in the _process_request function within src/llamafactory/api/chat.py. This function handles multimodal content (images, videos, audio) supplied via URLs. It checks if a URL is a base64 data URI or a local file path using os.path.isfile, but if neither matches, it simply performs an HTTP GET request via requests.get(url, stream=True).raw without any URL validation or sanitization [2][3].
Exploitation and
Attack Surface
An authenticated user can trigger this vulnerability by crafting a POST request to the /v1/chat/completions endpoint with a message containing a malicious URL in the multimodal content fields. The server then issues an HTTP request to the attacker-specified URL, which can target internal or external network services. Additionally, because the fallback logic treats any non-base64, non-filepath string as a web URI, an attacker could use a file:// scheme or path traversal to read arbitrary files from the server's filesystem, as the os.path.isfile check is bypassed for other URI schemes [3].
Impact
Successful exploitation enables an attacker to probe internal network services (SSRF), potentially exposing sensitive systems or interacting with third-party APIs. The same mechanism grants the ability to read local files (LFI), such as configuration files containing secrets or model data, leading to information disclosure and further compromise [2][3].
Mitigation
The issue is fixed in LLaMA-Factory version 0.9.4. The commit introduces input validation functions (check_ssrf_url and check_lfi_path) that restrict network requests to safe destinations and limit file access to a designated safe media directory, raising HTTP errors for invalid or blocked URLs [4]. Users are advised to upgrade immediately.
AI Insight generated on May 19, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
llamafactoryPyPI | < 0.9.4 | 0.9.4 |
Affected products
2- Range: <0.9.4
- hiyouga/LLaMA-Factoryv5Range: < 0.9.4
Patches
195b7188090a1Merge commit from fork
2 files changed · +69 −1
src/llamafactory/api/chat.py+7 −1 modified@@ -26,7 +26,7 @@ from ..extras.constants import AUDIO_PLACEHOLDER, IMAGE_PLACEHOLDER, VIDEO_PLACEHOLDER from ..extras.misc import is_env_enabled from ..extras.packages import is_fastapi_available, is_pillow_available, is_requests_available -from .common import dictify, jsonify +from .common import check_lfi_path, check_ssrf_url, dictify, jsonify from .protocol import ( ChatCompletionMessage, ChatCompletionResponse, @@ -121,8 +121,10 @@ def _process_request( if re.match(r"^data:image\/(png|jpg|jpeg|gif|bmp);base64,(.+)$", image_url): # base64 image image_stream = io.BytesIO(base64.b64decode(image_url.split(",", maxsplit=1)[1])) elif os.path.isfile(image_url): # local file + check_lfi_path(image_url) image_stream = open(image_url, "rb") else: # web uri + check_ssrf_url(image_url) image_stream = requests.get(image_url, stream=True).raw images.append(Image.open(image_stream).convert("RGB")) @@ -132,8 +134,10 @@ def _process_request( if re.match(r"^data:video\/(mp4|mkv|avi|mov);base64,(.+)$", video_url): # base64 video video_stream = io.BytesIO(base64.b64decode(video_url.split(",", maxsplit=1)[1])) elif os.path.isfile(video_url): # local file + check_lfi_path(video_url) video_stream = video_url else: # web uri + check_ssrf_url(video_url) video_stream = requests.get(video_url, stream=True).raw videos.append(video_stream) @@ -143,8 +147,10 @@ def _process_request( if re.match(r"^data:audio\/(mpeg|mp3|wav|ogg);base64,(.+)$", audio_url): # base64 audio audio_stream = io.BytesIO(base64.b64decode(audio_url.split(",", maxsplit=1)[1])) elif os.path.isfile(audio_url): # local file + check_lfi_path(audio_url) audio_stream = audio_url else: # web uri + check_ssrf_url(audio_url) audio_stream = requests.get(audio_url, stream=True).raw audios.append(audio_stream)
src/llamafactory/api/common.py+62 −0 modified@@ -12,14 +12,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +import ipaddress import json +import os +import socket from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from ..extras.misc import is_env_enabled +from ..extras.packages import is_fastapi_available + + +if is_fastapi_available(): + from fastapi import HTTPException, status if TYPE_CHECKING: from pydantic import BaseModel +SAFE_MEDIA_PATH = os.environ.get("SAFE_MEDIA_PATH", os.path.join(os.path.dirname(__file__), "safe_media")) +ALLOW_LOCAL_FILES = is_env_enabled("ALLOW_LOCAL_FILES", "1") + + def dictify(data: "BaseModel") -> dict[str, Any]: try: # pydantic v2 return data.model_dump(exclude_unset=True) @@ -32,3 +47,50 @@ def jsonify(data: "BaseModel") -> str: return json.dumps(data.model_dump(exclude_unset=True), ensure_ascii=False) except AttributeError: # pydantic v1 return data.json(exclude_unset=True, ensure_ascii=False) + + +def check_lfi_path(path: str) -> None: + """Checks if a given path is vulnerable to LFI. Raises HTTPException if unsafe.""" + if not ALLOW_LOCAL_FILES: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Local file access is disabled.") + + try: + os.makedirs(SAFE_MEDIA_PATH, exist_ok=True) + real_path = os.path.realpath(path) + safe_path = os.path.realpath(SAFE_MEDIA_PATH) + + if not real_path.startswith(safe_path): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="File access is restricted to the safe media directory." + ) + except Exception: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or inaccessible file path.") + + +def check_ssrf_url(url: str) -> None: + """Checks if a given URL is vulnerable to SSRF. Raises HTTPException if unsafe.""" + try: + parsed_url = urlparse(url) + if parsed_url.scheme not in ["http", "https"]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only HTTP/HTTPS URLs are allowed.") + + hostname = parsed_url.hostname + if not hostname: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid URL hostname.") + + ip_info = socket.getaddrinfo(hostname, parsed_url.port) + ip_address_str = ip_info[0][4][0] + ip = ipaddress.ip_address(ip_address_str) + + if not ip.is_global: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access to private or reserved IP addresses is not allowed.", + ) + + except socket.gaierror: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=f"Could not resolve hostname: {parsed_url.hostname}" + ) + except Exception as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid URL: {e}")
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-527m-2xhr-j27gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-61784ghsaADVISORY
- github.com/hiyouga/LLaMA-Factory/commit/95b7188090a1018935c9dc072bfc97f24f1c96e9mitrex_refsource_MISC
- github.com/hiyouga/LLaMA-Factory/security/advisories/GHSA-527m-2xhr-j27gmitrex_refsource_CONFIRM
- github.com/hiyouga/LLaMAFactory/commit/95b7188090a1018935c9dc072bfc97f24f1c96e9ghsaWEB
- github.com/hiyouga/LlamaFactory/security/advisories/GHSA-527m-2xhr-j27gghsaWEB
News mentions
0No linked articles in our index yet.