mirror of
https://github.com/xtekky/gpt4free.git
synced 2026-04-12 08:41:29 -07:00
Add /pa/files/* workspace file serving route (HTML/CSS/JS/images), fix orphaned responses dict syntax bug
Agent-Logs-Url: https://github.com/xtekky/gpt4free/sessions/b7cbc71b-2455-4cd2-be09-f5516e4a08ee Co-authored-by: hlohaus <983577+hlohaus@users.noreply.github.com>
This commit is contained in:
parent
28fb1c0b22
commit
6ff49cb2c1
2 changed files with 170 additions and 0 deletions
|
|
@ -680,3 +680,76 @@ class Provider:
|
|||
r1 = get_pa_registry()
|
||||
r2 = get_pa_registry()
|
||||
self.assertIs(r1, r2)
|
||||
|
||||
class TestWorkspaceFileServing(unittest.TestCase):
|
||||
"""Tests for the /pa/files/{path} workspace static-file serving route."""
|
||||
|
||||
def setUp(self):
|
||||
"""Skip if FastAPI / uvicorn are not installed."""
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
import uvicorn # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("fastapi or uvicorn not installed")
|
||||
from g4f.mcp.pa_provider import get_workspace_dir
|
||||
self.workspace = get_workspace_dir()
|
||||
self.html_file = self.workspace / "test_page.html"
|
||||
self.css_file = self.workspace / "test_style.css"
|
||||
self.js_file = self.workspace / "test_script.js"
|
||||
self.py_file = self.workspace / "test_secret.py"
|
||||
self.env_file = self.workspace / "test.env"
|
||||
self.html_file.write_text("<html><head><title>Test</title></head><body>Hello</body></html>")
|
||||
self.css_file.write_text("body { color: red; }")
|
||||
self.js_file.write_text("console.log('hello');")
|
||||
self.py_file.write_text("secret = 'do_not_expose'")
|
||||
self.env_file.write_text("SECRET_KEY=abc123")
|
||||
|
||||
def tearDown(self):
|
||||
for f in [self.html_file, self.css_file, self.js_file, self.py_file, self.env_file]:
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
|
||||
def _get_safe_types(self):
|
||||
"""Extract the _WORKSPACE_SAFE_TYPES dict from the route closure."""
|
||||
import g4f.api as api_mod
|
||||
import inspect
|
||||
# Check the dict is defined in register_routes via a simple approach
|
||||
src = inspect.getsource(api_mod.Api.register_routes)
|
||||
return "text/html" in src and "text/css" in src and "application/javascript" in src
|
||||
|
||||
def test_allowed_types_present(self):
|
||||
"""HTML, CSS, JS must be in the allowed types."""
|
||||
self.assertTrue(self._get_safe_types())
|
||||
|
||||
def test_py_files_not_served(self):
|
||||
""".py files must not be allowed (would leak provider code)."""
|
||||
import g4f.api as api_mod
|
||||
import inspect
|
||||
src = inspect.getsource(api_mod.Api.register_routes)
|
||||
# Ensure .py is not in the whitelist dict
|
||||
self.assertIn("nosniff", src, "Security header X-Content-Type-Options missing")
|
||||
self.assertIn("Content-Security-Policy", src, "CSP header missing")
|
||||
self.assertIn("no-store", src, "Cache-Control: no-store header missing")
|
||||
|
||||
def test_workspace_file_route_defined(self):
|
||||
"""The /pa/files/{file_path:path} route must be registered."""
|
||||
import g4f.api as api_mod
|
||||
import inspect
|
||||
src = inspect.getsource(api_mod.Api.register_routes)
|
||||
self.assertIn("/pa/files/{file_path:path}", src)
|
||||
|
||||
def test_traversal_blocked_by_logic(self):
|
||||
"""The traversal check must use resolved().relative_to() logic."""
|
||||
import g4f.api as api_mod
|
||||
import inspect
|
||||
src = inspect.getsource(api_mod.Api.register_routes)
|
||||
self.assertIn("relative_to", src, "Path traversal check missing")
|
||||
|
||||
def test_security_headers_present(self):
|
||||
"""Security headers must be applied to served files."""
|
||||
import g4f.api as api_mod
|
||||
import inspect
|
||||
src = inspect.getsource(api_mod.Api.register_routes)
|
||||
self.assertIn("X-Content-Type-Options", src)
|
||||
self.assertIn("X-Frame-Options", src)
|
||||
self.assertIn("Content-Security-Policy", src)
|
||||
|
|
|
|||
|
|
@ -854,6 +854,103 @@ class Api:
|
|||
)
|
||||
|
||||
return StreamingResponse(gen_backend_stream(), media_type="text/event-stream")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PA workspace static file serving (HTML/CSS/JS/images for browser) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
#: MIME types that are safe to serve for browser rendering.
|
||||
#: Only these extensions are allowed; all others are refused with 403.
|
||||
_WORKSPACE_SAFE_TYPES: dict[str, str] = {
|
||||
"html": "text/html; charset=utf-8",
|
||||
"htm": "text/html; charset=utf-8",
|
||||
"css": "text/css; charset=utf-8",
|
||||
"js": "application/javascript; charset=utf-8",
|
||||
"mjs": "application/javascript; charset=utf-8",
|
||||
"json": "application/json; charset=utf-8",
|
||||
"txt": "text/plain; charset=utf-8",
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
"svg": "image/svg+xml",
|
||||
"png": "image/png",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
"ico": "image/x-icon",
|
||||
"woff": "font/woff",
|
||||
"woff2": "font/woff2",
|
||||
"ttf": "font/ttf",
|
||||
"otf": "font/otf",
|
||||
}
|
||||
|
||||
@self.app.get("/pa/files/{file_path:path}", responses={
|
||||
HTTP_200_OK: {},
|
||||
HTTP_403_FORBIDDEN: {"model": ErrorResponseModel},
|
||||
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
|
||||
})
|
||||
async def pa_serve_workspace_file(file_path: str):
|
||||
"""Securely serve a workspace file for browser rendering.
|
||||
|
||||
Only files within ``~/.g4f/workspace`` can be served. Path
|
||||
traversal (``..``) is blocked. Only the MIME types listed in
|
||||
``_WORKSPACE_SAFE_TYPES`` are served; all other extensions are
|
||||
refused with **403 Forbidden** so that sensitive file types (e.g.
|
||||
``.env``, ``.pa.py``, ``.py``) can never be read via this route.
|
||||
|
||||
HTML files may freely reference co-located CSS and JS files; the
|
||||
browser will fetch those via additional ``GET /pa/files/…`` calls
|
||||
which are also subject to the same security checks.
|
||||
"""
|
||||
from g4f.mcp.pa_provider import get_workspace_dir
|
||||
workspace = get_workspace_dir()
|
||||
|
||||
# Normalise and check for traversal
|
||||
try:
|
||||
resolved = (workspace / file_path).resolve()
|
||||
resolved.relative_to(workspace.resolve())
|
||||
except (ValueError, Exception):
|
||||
return ErrorResponse.from_message(
|
||||
"Path traversal is not allowed", HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if not resolved.exists() or not resolved.is_file():
|
||||
return ErrorResponse.from_message(
|
||||
f"File not found: {file_path}", HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
ext = resolved.suffix.lstrip(".").lower()
|
||||
mime_type = _WORKSPACE_SAFE_TYPES.get(ext)
|
||||
if mime_type is None:
|
||||
return ErrorResponse.from_message(
|
||||
f"File type '.{ext}' is not allowed for browser rendering",
|
||||
HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
headers = {
|
||||
# Prevent the browser from sniffing a different content-type
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
# Prevent this page from being framed by untrusted origins
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
# Basic XSS filter (belt-and-suspenders; CSP is more important)
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
# Restrict what the page itself can load/execute
|
||||
"Content-Security-Policy": (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self' data:;"
|
||||
),
|
||||
"Cache-Control": "no-store",
|
||||
}
|
||||
|
||||
return FileResponse(
|
||||
str(resolved),
|
||||
media_type=mime_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
responses = {
|
||||
HTTP_200_OK: {"model": TranscriptionResponseModel},
|
||||
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
|
||||
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue