From cc3d434c020d61ef5e2561a2d9e1fdce08dedeb4 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 8 Apr 2026 01:04:59 -0400 Subject: [PATCH] feat: add server-side validation for web-based INI deployments --- decnet/ini_loader.py | 22 ++++++++++++++++++- decnet/web/api.py | 4 ++-- tests/test_ini_validation.py | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/test_ini_validation.py diff --git a/decnet/ini_loader.py b/decnet/ini_loader.py index 91002c9..0e48673 100644 --- a/decnet/ini_loader.py +++ b/decnet/ini_loader.py @@ -88,11 +88,27 @@ def load_ini(path: str | Path) -> IniConfig: def load_ini_from_string(content: str) -> IniConfig: """Parse a DECNET INI string and return an IniConfig.""" + validate_ini_string(content) cp = configparser.ConfigParser() cp.read_string(content) return _parse_configparser(cp) +def validate_ini_string(content: str) -> None: + """Perform safety and sanity checks on raw INI content string.""" + # 1. Size limit (e.g. 512KB) + if len(content) > 512 * 1024: + raise ValueError("INI content too large (max 512KB).") + + # 2. Ensure it's not empty + if not content.strip(): + raise ValueError("INI content is empty.") + + # 3. Basic structure check (must contain at least one section header) + if "[" not in content or "]" not in content: + raise ValueError("Invalid INI format: no sections found.") + + def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig: cfg = IniConfig() @@ -152,7 +168,11 @@ def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig: amount = int(amount_raw) if amount < 1: raise ValueError - except ValueError: + if amount > 100: + raise ValueError(f"[{section}] amount={amount} exceeds maximum allowed (100).") + except ValueError as e: + if "exceeds maximum" in str(e): + raise e raise ValueError(f"[{section}] amount= must be a positive integer, got '{amount_raw}'") if amount == 1: diff --git a/decnet/web/api.py b/decnet/web/api.py index 8a22897..e1af88d 100644 --- a/decnet/web/api.py +++ b/decnet/web/api.py @@ -8,7 +8,7 @@ from fastapi import Depends, FastAPI, HTTPException, Query, status, Request from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.security import OAuth2PasswordBearer -from pydantic import BaseModel +from pydantic import BaseModel, Field from decnet.web.auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, @@ -268,7 +268,7 @@ async def stream_events( class DeployIniRequest(BaseModel): - ini_content: str + ini_content: str = Field(..., min_length=5, max_length=512 * 1024) @app.post("/api/v1/deckies/deploy") async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: diff --git a/tests/test_ini_validation.py b/tests/test_ini_validation.py new file mode 100644 index 0000000..749a52f --- /dev/null +++ b/tests/test_ini_validation.py @@ -0,0 +1,41 @@ +import pytest +from decnet.ini_loader import load_ini_from_string, validate_ini_string + +def test_validate_ini_string_too_large(): + content = "[" + "a" * (512 * 1024 + 1) + "]" + with pytest.raises(ValueError, match="too large"): + validate_ini_string(content) + +def test_validate_ini_string_empty(): + with pytest.raises(ValueError, match="is empty"): + validate_ini_string("") + with pytest.raises(ValueError, match="is empty"): + validate_ini_string(" ") + +def test_validate_ini_string_no_sections(): + with pytest.raises(ValueError, match="no sections found"): + validate_ini_string("key=value") + +def test_load_ini_from_string_amount_limit(): + content = """ +[general] +net=192.168.1.0/24 + +[decky-01] +amount=101 +archetype=linux-server +""" + with pytest.raises(ValueError, match="exceeds maximum allowed"): + load_ini_from_string(content) + +def test_load_ini_from_string_valid(): + content = """ +[general] +net=192.168.1.0/24 + +[decky-01] +amount=5 +archetype=linux-server +""" + cfg = load_ini_from_string(content) + assert len(cfg.deckies) == 5