fix: clamp the logs limit and reject non-integers

GET /prompt_manager/logs accepted any limit up to 1000 and answered 500
when the value was not an integer. The limit is now clamped to [1, 500]
and a non-integer value is a 400.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 879247f1ef
commit e282f6ea50
2 changed files with 182 additions and 6 deletions
+22 -6
View File
@@ -20,6 +20,21 @@ _LOG_INT_KEYS = frozenset({"max_file_size", "backup_count", "buffer_size"})
_LOG_BOOL_KEYS = frozenset({"console_logging", "file_logging"})
_LOG_LEVEL_NAMES = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"})
DEFAULT_LOG_LIMIT = 100
MAX_LOG_LIMIT = 500
def _parse_limit(raw):
"""Parse a ``limit`` query value, clamped to [1, MAX_LOG_LIMIT].
Raises:
ValueError: when ``raw`` is not an integer.
"""
if raw is None or raw == "":
return DEFAULT_LOG_LIMIT
limit = int(raw)
return max(1, min(limit, MAX_LOG_LIMIT))
def _validate_log_config(data):
"""Validate a log-config payload against LOG_CONFIG_KEYS.
@@ -108,14 +123,15 @@ class LoggingRoutesMixin:
try:
logger_manager = self._get_logger_manager()
limit = int(request.query.get("limit", 100))
try:
limit = _parse_limit(request.query.get("limit"))
except ValueError:
return web.json_response(
{"success": False, "error": "limit must be an integer", "logs": []},
status=400,
)
level = request.query.get("level", None)
if limit > 1000:
limit = 1000
elif limit < 1:
limit = 1
logs = logger_manager.get_recent_logs(limit=limit, level=level)
return web.json_response(
+160
View File
@@ -230,5 +230,165 @@ class TestNoAbsolutePathsInLogResponses(LoggingAPITestCase):
self.assertIn("x.log", json.loads(body)["error"])
class TestGetLogs(LoggingAPITestCase):
def _fill(self, n):
for i in range(n):
self.manager.logs.append(
{"level": "INFO" if i % 2 else "ERROR", "message": f"m{i}"}
)
async def test_default_limit_and_count(self):
self._fill(3)
resp = await self.client.request("GET", "/prompt_manager/logs")
data = await resp.json()
self.assertEqual(resp.status, 200)
self.assertTrue(data["success"])
self.assertEqual(data["count"], 3)
self.assertEqual(data["limit"], 100)
self.assertIsNone(data["level_filter"])
async def test_level_filter_passed_through(self):
self._fill(4)
resp = await self.client.request("GET", "/prompt_manager/logs?level=error")
data = await resp.json()
self.assertEqual(data["level_filter"], "error")
self.assertEqual(data["count"], 2)
async def test_limit_is_clamped_to_500(self):
resp = await self.client.request("GET", "/prompt_manager/logs?limit=99999")
self.assertEqual((await resp.json())["limit"], 500)
async def test_limit_below_one_becomes_one(self):
self._fill(3)
resp = await self.client.request("GET", "/prompt_manager/logs?limit=0")
data = await resp.json()
self.assertEqual(data["limit"], 1)
self.assertEqual(data["count"], 1)
async def test_non_integer_limit_is_400(self):
resp = await self.client.request("GET", "/prompt_manager/logs?limit=ten")
self.assertEqual(resp.status, 400)
self.assertFalse((await resp.json())["success"])
async def test_manager_failure_is_500_with_empty_logs(self):
def boom(**kwargs):
raise RuntimeError("buffer gone")
self.manager.get_recent_logs = boom
resp = await self.client.request("GET", "/prompt_manager/logs")
data = await resp.json()
self.assertEqual(resp.status, 500)
self.assertEqual(data["logs"], [])
class TestLogFilesAndDownload(LoggingAPITestCase):
async def test_files_failure_is_500(self):
def boom():
raise RuntimeError("no dir")
self.manager.get_log_files = boom
resp = await self.client.request("GET", "/prompt_manager/logs/files")
data = await resp.json()
self.assertEqual(resp.status, 500)
self.assertEqual(data["files"], [])
async def test_download_existing_file(self):
(self.log_dir / "prompt_manager.log").write_text("line1\nline2\n")
resp = await self.client.request(
"GET", "/prompt_manager/logs/download/prompt_manager.log"
)
self.assertEqual(resp.status, 200)
self.assertIn("attachment", resp.headers["Content-Disposition"])
self.assertEqual(await resp.text(), "line1\nline2\n")
async def test_download_missing_file_is_404(self):
resp = await self.client.request(
"GET", "/prompt_manager/logs/download/nope.log"
)
self.assertEqual(resp.status, 404)
async def test_download_rejects_traversal(self):
resp = await self.client.request(
"GET", "/prompt_manager/logs/download/..%2F..%2Fetc%2Fpasswd"
)
self.assertEqual(resp.status, 400)
async def test_download_rejects_backslash(self):
resp = await self.client.request(
"GET", "/prompt_manager/logs/download/..%5Cx.log"
)
self.assertEqual(resp.status, 400)
async def test_download_read_failure_is_500(self):
(self.log_dir / "dir.log").mkdir()
resp = await self.client.request("GET", "/prompt_manager/logs/download/dir.log")
body = await resp.text()
self.assertEqual(resp.status, 500)
self.assertNotIn(self.tmpdir, body)
class TestTruncateConfigAndStats(LoggingAPITestCase):
async def test_truncate_success(self):
(self.log_dir / "prompt_manager.log").write_text("data")
resp = await self.client.request("POST", "/prompt_manager/logs/truncate")
data = await resp.json()
self.assertEqual(resp.status, 200)
self.assertEqual(data["results"]["truncated"], ["prompt_manager.log"])
self.assertEqual((self.log_dir / "prompt_manager.log").read_text(), "")
async def test_truncate_failure_is_500(self):
def boom():
raise RuntimeError("locked")
self.manager.truncate_logs = boom
resp = await self.client.request("POST", "/prompt_manager/logs/truncate")
self.assertEqual(resp.status, 500)
async def test_get_config(self):
resp = await self.client.request("GET", "/prompt_manager/logs/config")
data = await resp.json()
self.assertEqual(resp.status, 200)
self.assertEqual(data["config"]["level"], "INFO")
async def test_get_config_failure_is_500(self):
def boom():
raise RuntimeError("gone")
self.manager.get_config = boom
resp = await self.client.request("GET", "/prompt_manager/logs/config")
self.assertEqual(resp.status, 500)
async def test_update_config_manager_failure_is_500(self):
def boom(config):
raise RuntimeError("handler error")
self.manager.update_config = boom
resp = await self.client.request(
"POST", "/prompt_manager/logs/config", json={"level": "INFO"}
)
self.assertEqual(resp.status, 500)
async def test_stats_success(self):
resp = await self.client.request("GET", "/prompt_manager/logs/stats")
data = await resp.json()
self.assertEqual(resp.status, 200)
self.assertEqual(data["stats"]["current_level"], "INFO")
class TestLoggerManagerResolution(LoggingAPITestCase):
def test_default_manager_is_the_singleton(self):
from py.api.logging_routes import LoggingRoutesMixin
from utils.logging_config import get_logger_manager
manager = LoggingRoutesMixin._get_logger_manager(self.api)
self.assertIs(manager, get_logger_manager())
if __name__ == "__main__":
unittest.main()