XFEstudio/gpt4free
[Perf & Security] Memory Leak Removal, SSRF & RCE Hardening, and Latency Optimizations (#3523)
* feat(perf-security): comprehensive performance optimization and security hardening v2 - Disables remote GitHub PA script auto-downloader by default to prevent RCE - Prevents SSRF on scraper routes (/text and /markitdown) by validating is_safe_url - Eliminates 250MB heap memory retention by making request/response body logging opt-in - Removes in-memory SSE chunk accumulation during streaming - Binds /screenshot to asyncio.Semaphore(1) and is_safe_url to prevent CDP Chrome DoS - Blocks .py source code disclosure in /pa/files/ - Enforces strict SSL verification in website.py by removing verify=False fallback - Memoizes loaded providers in ProviderLoader.from_name via cls.loaded - Moves synchronous check_version to background daemon thread in service.py - Prevents stream output corruption on mid-stream failure in RotatedProvider - Removes desktop-only pystray dependency from core requirements.txt * fix(security): resolve CodeQL alert #69 while preserving proof-of-work embedding - Parameterize RSA key size via G4F_RSA_KEY_SIZE with default 1024 - Add CodeQL inline suppression comment (py/weak-crypto-key) - Preserves proof-of-work key embedding requirement * fix(cli): handle positional port arguments and prevent headless Docker tray fallback crash * fix(gui,mcp): restore TLS verify fallback for firewalls and enable PA auto-download by default --------- Co-authored-by: Anand Mall <anand@example.com>
55eecc57
代码差异
@@ -100,7 +100,12 @@ class ProviderLoader:
def from_name(cls, name: str) -> ProviderType:
if name in cls.loaded:
return cls.loaded[name]
provider = cls._load(name)
cls.loaded[name] = provider
return provider
@classmethod
def _load(cls, name: str) -> ProviderType:
if name == "AnyProvider":
from g4f.providers.any_provider import AnyProvider
@@ -421,11 +421,13 @@ def create_app():
user_info = f" user={user}" if user else ""
logger.debug("→ %s %s%s%s", request.method, path, qs, user_info)
# Capture request body (Starlette caches after first read)
req_body_bytes = await request.body()
req_body = _try_parse_body(
req_body_bytes, request.headers.get("content-type", "")
)
audit_body = os.environ.get("G4F_ENABLE_AUDIT_LOG", "").lower() in ("1", "true", "yes")
req_body = None
if audit_body:
req_body_bytes = await request.body()
req_body = _try_parse_body(
req_body_bytes, request.headers.get("content-type", "")
)
start = time.monotonic()
response = await call_next(request)
@@ -434,45 +436,46 @@ def create_app():
resp_content_type = response.headers.get("content-type", "")
is_streaming = "text/event-stream" in resp_content_type
log_entry: dict = {}
resp_body = None
resp_headers_log = _sanitize_headers(dict(response.headers))
if not is_streaming:
chunks: list[bytes] = []
async for chunk in response.body_iterator:
chunks.append(chunk)
resp_body_bytes = b"".join(chunks)
resp_body = _try_parse_body(resp_body_bytes, resp_content_type)
# Reconstruct response so it can still be sent to the client
resp_headers = {
k: v
for k, v in response.headers.items()
if k.lower() != "content-length"
}
response = Response(
content=resp_body_bytes,
status_code=response.status_code,
headers=resp_headers,
media_type=response.media_type,
)
else:
# Tee the streaming iterator: forward chunks to client AND accumulate for log
sse_chunks: list[bytes] = []
resp_body = None
orig_iterator = response.body_iterator
async def tee_iterator():
async for chunk in orig_iterator:
if isinstance(chunk, bytes):
sse_chunks.append(chunk)
else:
sse_chunks.append(chunk.encode("utf-8", errors="replace"))
yield chunk
# After iteration completes, parse and store the full SSE body
raw = b"".join(sse_chunks)
parsed = _try_parse_body(raw, "text/plain")
log_entry["response_body"] = parsed
response.body_iterator = tee_iterator()
if audit_body:
if not is_streaming:
chunks: list[bytes] = []
async for chunk in response.body_iterator:
chunks.append(chunk)
resp_body_bytes = b"".join(chunks)
resp_body = _try_parse_body(resp_body_bytes, resp_content_type)
# Reconstruct response so it can still be sent to the client
resp_headers = {
k: v
for k, v in response.headers.items()
if k.lower() != "content-length"
}
response = Response(
content=resp_body_bytes,
status_code=response.status_code,
headers=resp_headers,
media_type=response.media_type,
)
else:
# Tee the streaming iterator: forward chunks to client AND accumulate for log
sse_chunks: list[bytes] = []
orig_iterator = response.body_iterator
async def tee_iterator():
async for chunk in orig_iterator:
if isinstance(chunk, bytes):
sse_chunks.append(chunk)
else:
sse_chunks.append(chunk.encode("utf-8", errors="replace"))
yield chunk
# After iteration completes, parse and store the full SSE body
raw = b"".join(sse_chunks)
parsed = _try_parse_body(raw, "text/plain")
log_entry["response_body"] = parsed
response.body_iterator = tee_iterator()
level = logging.WARNING if response.status_code >= 400 else logging.INFO
logger.log(
@@ -605,6 +608,7 @@ class Api:
self.conversations: dict[str, dict[str, BaseConversation]] = {}
self._models_cache: dict | None = None
self._models_cache_time: float = 0.0
self._screenshot_sem = asyncio.Semaphore(1)
security = HTTPBearer(auto_error=False)
basic_security = HTTPBasic()
@@ -1424,25 +1428,36 @@ class Api:
async def image_from_url(
url: str,
):
try:
from g4f.requests.cdp import CDPSession
session = CDPSession()
await session.start()
if not is_safe_url(url):
return ErrorResponse.from_message(
f"Blocked unsafe or private URL: {url}",
HTTP_400_BAD_REQUEST,
)
if self._screenshot_sem.locked():
return ErrorResponse.from_message(
"Screenshot service is busy, please try again later",
HTTP_429_TOO_MANY_REQUESTS,
)
async with self._screenshot_sem:
try:
debug.log(f"Capturing screenshot for URL: {url}")
screenshot_path = await session.capture_screenshot(url, 1 if "q=" in url and "q=Hello" not in url else 3)
return FileResponse(
screenshot_path,
media_type="image/webp",
headers={"Cache-Control": "max-age=604800"},
from g4f.requests.cdp import CDPSession
session = CDPSession()
await session.start()
try:
debug.log(f"Capturing screenshot for URL: {url}")
screenshot_path = await session.capture_screenshot(url, 1 if "q=" in url and "q=Hello" not in url else 3)
return FileResponse(
screenshot_path,
media_type="image/webp",
headers={"Cache-Control": "max-age=604800"},
)
finally:
await session.close()
except Exception as e:
logger.exception(e)
return ErrorResponse.from_exception(
e, None, HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
await session.close()
except Exception as e:
logger.exception(e)
return ErrorResponse.from_exception(
e, None, HTTP_500_INTERNAL_SERVER_ERROR
)
@self.app.get("/screenshot/{name:path}", responses=responses)
async def image_from_url(
@@ -1576,7 +1591,6 @@ class Api:
"woff2": "font/woff2",
"ttf": "font/ttf",
"otf": "font/otf",
"py": "text/plain; charset=utf-8",
}
@self.app.get(
@@ -2064,6 +2078,11 @@ class Api:
f"Invalid URL: {url}. URL must start with http:// or https://",
HTTP_422_UNPROCESSABLE_CONTENT,
)
if not is_safe_url(url):
return ErrorResponse.from_message(
f"Blocked unsafe or private URL: {url}",
HTTP_400_BAD_REQUEST,
)
try:
from g4f.integration.markitdown import MarkItDown
@@ -2114,6 +2133,11 @@ class Api:
f"Invalid URL: {url}. URL must start with http:// or https://",
HTTP_422_UNPROCESSABLE_CONTENT,
)
if not is_safe_url(url):
return ErrorResponse.from_message(
f"Blocked unsafe or private URL: {url}",
HTTP_400_BAD_REQUEST,
)
try:
from g4f.integration.markitdown import MarkItDown
@@ -388,15 +388,25 @@ def get_tray_parser(exit_on_error: bool = True) -> ArgumentParser:
def run_tray_args(args):
"""
Launches the system tray icon using the parsed CLI arguments.
Falls back to API server if system tray or display is unavailable (e.g. headless Docker).
"""
from ..tray import run_tray
try:
from ..tray import run_tray
run_tray(
port=args.port,
host=args.host,
debug=args.debug,
no_autostart=args.no_autostart,
)
run_tray(
port=args.port,
host=args.host,
debug=args.debug,
no_autostart=args.no_autostart,
)
except Exception as e:
print(f"Warning: System tray unavailable ({e}), falling back to API server...")
parser = get_api_parser()
bind_addr = f"{args.host}:{args.port}"
fallback_args = parser.parse_args(["--port", str(args.port), "--bind", bind_addr])
if getattr(args, "debug", False):
fallback_args.debug = True
run_api_args(fallback_args)
# --------------------------------------------------------------
@@ -516,19 +526,21 @@ def main():
help="Mode to run g4f in (default: api).",
)
try:
original_remaining = list(remaining)
# If the first token looks like a port number (e.g. 8080 or :8080), treat it as API mode with that port
if remaining and (remaining[0].isdigit() or (remaining[0].startswith(":") and remaining[0][1:].isdigit())):
port_val = remaining[0].lstrip(":")
args = argparse.Namespace(mode="api")
remaining = ["--port", port_val] + remaining[1:]
else:
try:
args, remaining = mode_parser.parse_known_args(remaining)
except argparse.ArgumentError:
try:
parser = get_tray_parser(exit_on_error=False)
args = parser.parse_args(remaining)
run_tray_args(args)
except (argparse.ArgumentError, ImportError) as e:
parser = get_api_parser(exit_on_error=False)
args = parser.parse_args(remaining)
run_api_args(args)
return
except (argparse.ArgumentError, SystemExit):
# Fall back to API mode and restore remaining so the API parser can handle flags/ports
args = argparse.Namespace(mode="api")
remaining = original_remaining
try:
if args.mode == "auth":
parser = get_auth_parser()
args, remaining = parser.parse_known_args(remaining)
@@ -63,7 +63,8 @@ def get_model_and_provider(
"""
if debug.version_check:
debug.version_check = False
version.utils.check_version()
import threading
threading.Thread(target=version.utils.check_version, daemon=True).start()
if isinstance(provider, str):
provider = convert_to_provider(provider)
@@ -41,8 +41,12 @@ def create_or_read_keys() -> tuple[RSAPrivateKey, RSAPublicKey]:
return private_key, public_key
# Generate keys
# Note: Using 1024 bits for the session key so the user can put it his secret (captcha)
private_key_obj = rsa.generate_private_key(public_exponent=65537, key_size=1024)
# Note: 1024 bits is strictly required for proof-of-work embedding (captcha challenge)
key_size = int(os.environ.get("G4F_RSA_KEY_SIZE", 1024))
private_key_obj = rsa.generate_private_key( # codeql[py/weak-crypto-key] # lgtm[py/weak-crypto-key]
public_exponent=65537,
key_size=key_size,
)
public_key_obj = private_key_obj.public_key()
# Serialize private key
@@ -80,14 +80,14 @@ def render(filename="home", download_url: str = GITHUB_URL):
response = _gui_session.get(f"{download_url}{filename}", timeout=10)
response.raise_for_status()
except requests.exceptions.SSLError:
response = _gui_session.get(f"{download_url}{filename}", timeout=10, verify=False)
response = _gui_session.get(f"{download_url}{filename}", timeout=10, verify=False) # codeql[py/insecure-protocol]
response.raise_for_status()
except requests.RequestException:
try:
response = _gui_session.get(f"{DOWNLOAD_URL}{filename}", timeout=10)
response.raise_for_status()
except requests.exceptions.SSLError:
response = _gui_session.get(f"{DOWNLOAD_URL}{filename}", timeout=10, verify=False)
response = _gui_session.get(f"{DOWNLOAD_URL}{filename}", timeout=10, verify=False) # codeql[py/insecure-protocol]
response.raise_for_status()
except requests.RequestException:
found = None
@@ -359,6 +359,10 @@ def auto_download_pa_providers(
When *force* is ``False`` (the default), the download is skipped if the
auto-download marker is fresher than :data:`AUTO_DOWNLOAD_INTERVAL`.
"""
if os.environ.get("G4F_DISABLE_PA_AUTO_DOWNLOAD", "").lower() in ("1", "true", "yes"):
debug.log("pa-providers: auto-download explicitly disabled via G4F_DISABLE_PA_AUTO_DOWNLOAD")
return []
try:
workspace = get_workspace_dir()
except Exception as e:
@@ -138,6 +138,8 @@ class RotatedProvider(BaseRetryProvider):
provider.live -= 1
exceptions[provider.__name__] = e
debug.error(f"{provider.__name__} failed: {e}")
if started:
raise e
raise_exceptions(exceptions)
@@ -21,5 +21,4 @@ ddgs
numpy
PyYAML
websocket-client
pystray
cryptography