openai_server: fix path traversal in web static serving (relative_to, not startswith) + empty-prompt validation, with tests (#212)

This commit is contained in:
Kushida
2026-07-14 23:07:41 +03:00
committed by GitHub
parent bad64d1b06
commit 8c18b7af68
2 changed files with 50 additions and 3 deletions
+8 -2
View File
@@ -731,12 +731,16 @@ class APIHandler(BaseHTTPRequestHandler):
Read-only, no auth (same trust level as /health), traversal-safe."""
if path.startswith("/v1/") or path == "/health":
return False
base = self.WEB_DIST
base = self.WEB_DIST.resolve()
if not base.is_dir():
return False
rel = unquote(path).lstrip("/") or "index.html"
target = (base / rel).resolve()
if not str(target).startswith(str(base)) or not target.is_file():
try:
target.relative_to(base)
except ValueError:
target = None
if target is None or not target.is_file():
if path == "/" or "." not in rel: # SPA fallback
target = base / "index.html"
if not target.is_file():
@@ -1045,6 +1049,8 @@ class APIHandler(BaseHTTPRequestHandler):
prompt = body.get("prompt")
if not isinstance(prompt, str):
raise APIError(400, "Colibri currently requires `prompt` to be a string.", "prompt")
if not prompt:
raise APIError(400, "`prompt` must not be empty.", "prompt")
self.generation(body, prompt, request_id, False)
+42 -1
View File
@@ -2,13 +2,15 @@ import io
import json
import math
import socket
import tempfile
import threading
import unittest
from unittest.mock import patch
from urllib.error import HTTPError
from urllib.request import Request, urlopen
from pathlib import Path
from openai_server import (APIError, APIServer, ClientCancelled, END, GenerationScheduler,
from openai_server import (APIError, APIHandler, APIServer, ClientCancelled, END, GenerationScheduler,
READY, Engine, generation_options, parse_tool_calls,
read_engine_turn, render_chat, serve)
@@ -490,6 +492,12 @@ class HTTPTest(unittest.TestCase):
self.assertEqual(body["choices"][0]["text"], "Héllo")
self.assertEqual(self.engine.calls[-1][0], "Complete me")
def test_rejects_empty_legacy_completion(self):
with self.assertRaises(HTTPError) as caught:
self.request("/v1/completions", {"model": "test-model", "prompt": ""})
self.assertEqual(caught.exception.code, 400)
self.assertEqual(json.load(caught.exception)["error"]["param"], "prompt")
def test_rejects_invalid_stream_options(self):
with self.assertRaises(HTTPError) as caught:
self.request("/v1/chat/completions", {
@@ -499,6 +507,39 @@ class HTTPTest(unittest.TestCase):
self.assertEqual(caught.exception.code, 400)
class StaticServingTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
dist = root / "dist"
dist.mkdir()
(dist / "index.html").write_text("dashboard", encoding="utf-8")
sibling = root / "dist-private"
sibling.mkdir()
(sibling / "secret.txt").write_text("private", encoding="utf-8")
self.web_dist = patch.object(APIHandler, "WEB_DIST", dist)
self.web_dist.start()
self.server = APIServer(("127.0.0.1", 0), FakeEngine(), "test-model")
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.base = f"http://127.0.0.1:{self.server.server_port}"
def tearDown(self):
self.server.scheduler.close()
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
self.web_dist.stop()
self.tmp.cleanup()
def test_static_root_stays_inside_dist_directory(self):
with urlopen(self.base + "/", timeout=2) as response:
self.assertEqual(response.read(), b"dashboard")
with self.assertRaises(HTTPError) as caught:
urlopen(self.base + "/%2e%2e/dist-private/secret.txt", timeout=2)
self.assertEqual(caught.exception.code, 404)
class SchedulerHTTPTest(unittest.TestCase):
def setUp(self):
self.engine = BlockingEngine()