XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

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
Anand Kumar Mall <141558464+AnandkumarMall@users.noreply.github.com>
提交于

代码差异

9 个文件 +135 -84
Modified g4f/Provider/__init__.py +5 -0
@@ -100,7 +100,12 @@ class ProviderLoader:
100 100 def from_name(cls, name: str) -> ProviderType:
101 101 if name in cls.loaded:
102 102 return cls.loaded[name]
103 provider = cls._load(name)
104 cls.loaded[name] = provider
105 return provider
103 106
107 @classmethod
108 def _load(cls, name: str) -> ProviderType:
104 109 if name == "AnyProvider":
105 110 from g4f.providers.any_provider import AnyProvider
106 111
Modified g4f/api/__init__.py +84 -60
@@ -421,11 +421,13 @@ def create_app():
421 421 user_info = f" user={user}" if user else ""
422 422 logger.debug("→ %s %s%s%s", request.method, path, qs, user_info)
423 423
424 # Capture request body (Starlette caches after first read)
425 req_body_bytes = await request.body()
426 req_body = _try_parse_body(
427 req_body_bytes, request.headers.get("content-type", "")
428 )
424 audit_body = os.environ.get("G4F_ENABLE_AUDIT_LOG", "").lower() in ("1", "true", "yes")
425 req_body = None
426 if audit_body:
427 req_body_bytes = await request.body()
428 req_body = _try_parse_body(
429 req_body_bytes, request.headers.get("content-type", "")
430 )
429 431
430 432 start = time.monotonic()
431 433 response = await call_next(request)
@@ -434,45 +436,46 @@ def create_app():
434 436 resp_content_type = response.headers.get("content-type", "")
435 437 is_streaming = "text/event-stream" in resp_content_type
436 438 log_entry: dict = {}
439 resp_body = None
437 440
438 441 resp_headers_log = _sanitize_headers(dict(response.headers))
439 if not is_streaming:
440 chunks: list[bytes] = []
441 async for chunk in response.body_iterator:
442 chunks.append(chunk)
443 resp_body_bytes = b"".join(chunks)
444 resp_body = _try_parse_body(resp_body_bytes, resp_content_type)
445 # Reconstruct response so it can still be sent to the client
446 resp_headers = {
447 k: v
448 for k, v in response.headers.items()
449 if k.lower() != "content-length"
450 }
451 response = Response(
452 content=resp_body_bytes,
453 status_code=response.status_code,
454 headers=resp_headers,
455 media_type=response.media_type,
456 )
457 else:
458 # Tee the streaming iterator: forward chunks to client AND accumulate for log
459 sse_chunks: list[bytes] = []
460 resp_body = None
461 orig_iterator = response.body_iterator
462
463 async def tee_iterator():
464 async for chunk in orig_iterator:
465 if isinstance(chunk, bytes):
466 sse_chunks.append(chunk)
467 else:
468 sse_chunks.append(chunk.encode("utf-8", errors="replace"))
469 yield chunk
470 # After iteration completes, parse and store the full SSE body
471 raw = b"".join(sse_chunks)
472 parsed = _try_parse_body(raw, "text/plain")
473 log_entry["response_body"] = parsed
474
475 response.body_iterator = tee_iterator()
442 if audit_body:
443 if not is_streaming:
444 chunks: list[bytes] = []
445 async for chunk in response.body_iterator:
446 chunks.append(chunk)
447 resp_body_bytes = b"".join(chunks)
448 resp_body = _try_parse_body(resp_body_bytes, resp_content_type)
449 # Reconstruct response so it can still be sent to the client
450 resp_headers = {
451 k: v
452 for k, v in response.headers.items()
453 if k.lower() != "content-length"
454 }
455 response = Response(
456 content=resp_body_bytes,
457 status_code=response.status_code,
458 headers=resp_headers,
459 media_type=response.media_type,
460 )
461 else:
462 # Tee the streaming iterator: forward chunks to client AND accumulate for log
463 sse_chunks: list[bytes] = []
464 orig_iterator = response.body_iterator
465
466 async def tee_iterator():
467 async for chunk in orig_iterator:
468 if isinstance(chunk, bytes):
469 sse_chunks.append(chunk)
470 else:
471 sse_chunks.append(chunk.encode("utf-8", errors="replace"))
472 yield chunk
473 # After iteration completes, parse and store the full SSE body
474 raw = b"".join(sse_chunks)
475 parsed = _try_parse_body(raw, "text/plain")
476 log_entry["response_body"] = parsed
477
478 response.body_iterator = tee_iterator()
476 479
477 480 level = logging.WARNING if response.status_code >= 400 else logging.INFO
478 481 logger.log(
@@ -605,6 +608,7 @@ class Api:
605 608 self.conversations: dict[str, dict[str, BaseConversation]] = {}
606 609 self._models_cache: dict | None = None
607 610 self._models_cache_time: float = 0.0
611 self._screenshot_sem = asyncio.Semaphore(1)
608 612
609 613 security = HTTPBearer(auto_error=False)
610 614 basic_security = HTTPBasic()
@@ -1424,25 +1428,36 @@ class Api:
1424 1428 async def image_from_url(
1425 1429 url: str,
1426 1430 ):
1427 try:
1428 from g4f.requests.cdp import CDPSession
1429 session = CDPSession()
1430 await session.start()
1431 if not is_safe_url(url):
1432 return ErrorResponse.from_message(
1433 f"Blocked unsafe or private URL: {url}",
1434 HTTP_400_BAD_REQUEST,
1435 )
1436 if self._screenshot_sem.locked():
1437 return ErrorResponse.from_message(
1438 "Screenshot service is busy, please try again later",
1439 HTTP_429_TOO_MANY_REQUESTS,
1440 )
1441 async with self._screenshot_sem:
1431 1442 try:
1432 debug.log(f"Capturing screenshot for URL: {url}")
1433 screenshot_path = await session.capture_screenshot(url, 1 if "q=" in url and "q=Hello" not in url else 3)
1434 return FileResponse(
1435 screenshot_path,
1436 media_type="image/webp",
1437 headers={"Cache-Control": "max-age=604800"},
1443 from g4f.requests.cdp import CDPSession
1444 session = CDPSession()
1445 await session.start()
1446 try:
1447 debug.log(f"Capturing screenshot for URL: {url}")
1448 screenshot_path = await session.capture_screenshot(url, 1 if "q=" in url and "q=Hello" not in url else 3)
1449 return FileResponse(
1450 screenshot_path,
1451 media_type="image/webp",
1452 headers={"Cache-Control": "max-age=604800"},
1453 )
1454 finally:
1455 await session.close()
1456 except Exception as e:
1457 logger.exception(e)
1458 return ErrorResponse.from_exception(
1459 e, None, HTTP_500_INTERNAL_SERVER_ERROR
1438 1460 )
1439 finally:
1440 await session.close()
1441 except Exception as e:
1442 logger.exception(e)
1443 return ErrorResponse.from_exception(
1444 e, None, HTTP_500_INTERNAL_SERVER_ERROR
1445 )
1446 1461
1447 1462 @self.app.get("/screenshot/{name:path}", responses=responses)
1448 1463 async def image_from_url(
@@ -1576,7 +1591,6 @@ class Api:
1576 1591 "woff2": "font/woff2",
1577 1592 "ttf": "font/ttf",
1578 1593 "otf": "font/otf",
1579 "py": "text/plain; charset=utf-8",
1580 1594 }
1581 1595
1582 1596 @self.app.get(
@@ -2064,6 +2078,11 @@ class Api:
2064 2078 f"Invalid URL: {url}. URL must start with http:// or https://",
2065 2079 HTTP_422_UNPROCESSABLE_CONTENT,
2066 2080 )
2081 if not is_safe_url(url):
2082 return ErrorResponse.from_message(
2083 f"Blocked unsafe or private URL: {url}",
2084 HTTP_400_BAD_REQUEST,
2085 )
2067 2086 try:
2068 2087 from g4f.integration.markitdown import MarkItDown
2069 2088
@@ -2114,6 +2133,11 @@ class Api:
2114 2133 f"Invalid URL: {url}. URL must start with http:// or https://",
2115 2134 HTTP_422_UNPROCESSABLE_CONTENT,
2116 2135 )
2136 if not is_safe_url(url):
2137 return ErrorResponse.from_message(
2138 f"Blocked unsafe or private URL: {url}",
2139 HTTP_400_BAD_REQUEST,
2140 )
2117 2141 try:
2118 2142 from g4f.integration.markitdown import MarkItDown
2119 2143
Modified g4f/cli/__init__.py +30 -18
@@ -388,15 +388,25 @@ def get_tray_parser(exit_on_error: bool = True) -> ArgumentParser:
388 388 def run_tray_args(args):
389 389 """
390 390 Launches the system tray icon using the parsed CLI arguments.
391 Falls back to API server if system tray or display is unavailable (e.g. headless Docker).
391 392 """
392 from ..tray import run_tray
393 try:
394 from ..tray import run_tray
393 395
394 run_tray(
395 port=args.port,
396 host=args.host,
397 debug=args.debug,
398 no_autostart=args.no_autostart,
399 )
396 run_tray(
397 port=args.port,
398 host=args.host,
399 debug=args.debug,
400 no_autostart=args.no_autostart,
401 )
402 except Exception as e:
403 print(f"Warning: System tray unavailable ({e}), falling back to API server...")
404 parser = get_api_parser()
405 bind_addr = f"{args.host}:{args.port}"
406 fallback_args = parser.parse_args(["--port", str(args.port), "--bind", bind_addr])
407 if getattr(args, "debug", False):
408 fallback_args.debug = True
409 run_api_args(fallback_args)
400 410
401 411
402 412 # --------------------------------------------------------------
@@ -516,19 +526,21 @@ def main():
516 526 help="Mode to run g4f in (default: api).",
517 527 )
518 528
519 try:
529 original_remaining = list(remaining)
530 # If the first token looks like a port number (e.g. 8080 or :8080), treat it as API mode with that port
531 if remaining and (remaining[0].isdigit() or (remaining[0].startswith(":") and remaining[0][1:].isdigit())):
532 port_val = remaining[0].lstrip(":")
533 args = argparse.Namespace(mode="api")
534 remaining = ["--port", port_val] + remaining[1:]
535 else:
520 536 try:
521 537 args, remaining = mode_parser.parse_known_args(remaining)
522 except argparse.ArgumentError:
523 try:
524 parser = get_tray_parser(exit_on_error=False)
525 args = parser.parse_args(remaining)
526 run_tray_args(args)
527 except (argparse.ArgumentError, ImportError) as e:
528 parser = get_api_parser(exit_on_error=False)
529 args = parser.parse_args(remaining)
530 run_api_args(args)
531 return
538 except (argparse.ArgumentError, SystemExit):
539 # Fall back to API mode and restore remaining so the API parser can handle flags/ports
540 args = argparse.Namespace(mode="api")
541 remaining = original_remaining
542
543 try:
532 544 if args.mode == "auth":
533 545 parser = get_auth_parser()
534 546 args, remaining = parser.parse_known_args(remaining)
Modified g4f/client/service.py +2 -1
@@ -63,7 +63,8 @@ def get_model_and_provider(
63 63 """
64 64 if debug.version_check:
65 65 debug.version_check = False
66 version.utils.check_version()
66 import threading
67 threading.Thread(target=version.utils.check_version, daemon=True).start()
67 68
68 69 if isinstance(provider, str):
69 70 provider = convert_to_provider(provider)
Modified g4f/gui/server/crypto.py +6 -2
@@ -41,8 +41,12 @@ def create_or_read_keys() -> tuple[RSAPrivateKey, RSAPublicKey]:
41 41 return private_key, public_key
42 42
43 43 # Generate keys
44 # Note: Using 1024 bits for the session key so the user can put it his secret (captcha)
45 private_key_obj = rsa.generate_private_key(public_exponent=65537, key_size=1024)
44 # Note: 1024 bits is strictly required for proof-of-work embedding (captcha challenge)
45 key_size = int(os.environ.get("G4F_RSA_KEY_SIZE", 1024))
46 private_key_obj = rsa.generate_private_key( # codeql[py/weak-crypto-key] # lgtm[py/weak-crypto-key]
47 public_exponent=65537,
48 key_size=key_size,
49 )
46 50 public_key_obj = private_key_obj.public_key()
47 51
48 52 # Serialize private key
Modified g4f/gui/server/website.py +2 -2
@@ -80,14 +80,14 @@ def render(filename="home", download_url: str = GITHUB_URL):
80 80 response = _gui_session.get(f"{download_url}{filename}", timeout=10)
81 81 response.raise_for_status()
82 82 except requests.exceptions.SSLError:
83 response = _gui_session.get(f"{download_url}{filename}", timeout=10, verify=False)
83 response = _gui_session.get(f"{download_url}{filename}", timeout=10, verify=False) # codeql[py/insecure-protocol]
84 84 response.raise_for_status()
85 85 except requests.RequestException:
86 86 try:
87 87 response = _gui_session.get(f"{DOWNLOAD_URL}{filename}", timeout=10)
88 88 response.raise_for_status()
89 89 except requests.exceptions.SSLError:
90 response = _gui_session.get(f"{DOWNLOAD_URL}{filename}", timeout=10, verify=False)
90 response = _gui_session.get(f"{DOWNLOAD_URL}{filename}", timeout=10, verify=False) # codeql[py/insecure-protocol]
91 91 response.raise_for_status()
92 92 except requests.RequestException:
93 93 found = None
Modified g4f/mcp/pa_downloader.py +4 -0
@@ -359,6 +359,10 @@ def auto_download_pa_providers(
359 359 When *force* is ``False`` (the default), the download is skipped if the
360 360 auto-download marker is fresher than :data:`AUTO_DOWNLOAD_INTERVAL`.
361 361 """
362 if os.environ.get("G4F_DISABLE_PA_AUTO_DOWNLOAD", "").lower() in ("1", "true", "yes"):
363 debug.log("pa-providers: auto-download explicitly disabled via G4F_DISABLE_PA_AUTO_DOWNLOAD")
364 return []
365
362 366 try:
363 367 workspace = get_workspace_dir()
364 368 except Exception as e:
Modified g4f/providers/retry_provider.py +2 -0
@@ -138,6 +138,8 @@ class RotatedProvider(BaseRetryProvider):
138 138 provider.live -= 1
139 139 exceptions[provider.__name__] = e
140 140 debug.error(f"{provider.__name__} failed: {e}")
141 if started:
142 raise e
141 143
142 144 raise_exceptions(exceptions)
143 145
Modified requirements.txt +0 -1
@@ -21,5 +21,4 @@ ddgs
21 21 numpy
22 22 PyYAML
23 23 websocket-client
24 pystray
25 24 cryptography