From 686ba03dede729dcd6bfc993945f25cad24109ee Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 07:34:30 +0200 Subject: [PATCH] test: cover P0 security boundaries --- .github/workflows/sonarqube.yml | 3 ++ CHANGELOG.md | 2 ++ tests/test_security.py | 53 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 tests/test_security.py diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 34759ed..b02896d 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -39,6 +39,9 @@ jobs: - name: Verify Python sources compile run: python -m compileall -q core routes scripts server.py ttsvc_cli.py + - name: Run Python security regressions + run: python -m unittest discover -s tests -p 'test_*.py' + - name: Verify JavaScript parses run: | for f in $(git ls-files 'static/js/*.js' 'scripts/*.mjs'); do diff --git a/CHANGELOG.md b/CHANGELOG.md index 4feeea5..ed51049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ยท versioned wi ### Changed - **API Boundary Hardening.** External API authentication is enabled by default, request and mutation limits are enforced, and settings responses redact provider credentials for non-browser callers. +- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL + rejection and oversized JSON request rejection. ### Fixed - **SSRF and Credential-Leak Paths.** User-supplied downloads are validated against private-network access settings, diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..f90fe2d --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,53 @@ +"""Fast, dependency-light regression tests for P0 request protections.""" +from __future__ import annotations + +import asyncio +import os +import tempfile +import unittest + +os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-")) + +from fastapi import HTTPException + +from core.validation import _validate_character_record, _validate_http_url +from server import RequestSizeLimitMiddleware + + +class SecurityValidationTests(unittest.TestCase): + def test_character_record_rejects_missing_id_and_bad_sheet(self): + for record in ( + {"name": "Missing id", "sheet": {}}, + {"id": "c1", "name": "Bad sheet", "sheet": []}, + ): + with self.assertRaises(HTTPException): + _validate_character_record(record) + + def test_character_record_accepts_minimal_record(self): + record = {"id": "c1", "book": "Book", "name": "Name", "tags": "", "sheet": {}} + self.assertIs(_validate_character_record(record), record) + + def test_private_url_is_rejected_when_downloads_are_public_only(self): + with self.assertRaises(HTTPException): + _validate_http_url("http://127.0.0.1:8188/image", allow_private=False) + + def test_request_size_middleware_rejects_oversized_json(self): + sent = [] + + async def downstream(scope, receive, send): + sent.append({"type": "downstream"}) + + async def send(message): + sent.append(message) + + scope = { + "type": "http", "method": "POST", "path": "/api/test", + "headers": [(b"content-type", b"application/json"), (b"content-length", b"40000000")], + } + asyncio.run(RequestSizeLimitMiddleware(downstream)(scope, None, send)) + self.assertEqual(sent[0]["status"], 413) + self.assertNotIn({"type": "downstream"}, sent) + + +if __name__ == "__main__": + unittest.main()