diff --git a/pyproject.toml b/pyproject.toml index 204e77f..a87b46a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.31" +version = "0.1.32" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/security/http_fetch.py b/src/govoplan_core/security/http_fetch.py index 974b939..860a901 100644 --- a/src/govoplan_core/security/http_fetch.py +++ b/src/govoplan_core/security/http_fetch.py @@ -12,6 +12,9 @@ from govoplan_core.security.outbound_http import ( ) +MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES = 1_000_000 + + @dataclass(frozen=True, slots=True) class HttpFetchResponse: status: int @@ -46,11 +49,17 @@ def fetch_http( label: str = "URL", method: str = "GET", headers: Mapping[str, str] | None = None, + body: bytes | None = None, max_bytes: int | None = None, ) -> HttpFetchResponse: + if body is not None and len(body) > MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES: + raise ValueError( + "Outbound HTTP request body exceeds the 1000000-byte safety limit." + ) validated_url = validate_outbound_http_url(url, label=label) request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S). validated_url, + data=body, headers=dict(headers or {}), method=method, ) @@ -76,10 +85,19 @@ def fetch_http_text( label: str = "URL", method: str = "GET", headers: Mapping[str, str] | None = None, + body: bytes | None = None, encoding: str = "utf-8", max_bytes: int | None = None, ) -> str: - return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding) + return fetch_http( + url, + timeout=timeout, + label=label, + method=method, + headers=headers, + body=body, + max_bytes=max_bytes, + ).text(encoding) class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler): diff --git a/tests/test_http_fetch.py b/tests/test_http_fetch.py index cb54812..b6e8ba8 100644 --- a/tests/test_http_fetch.py +++ b/tests/test_http_fetch.py @@ -2,9 +2,14 @@ from __future__ import annotations import io import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch -from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url +from govoplan_core.security.http_fetch import ( + _PolicyRedirectHandler, + fetch_http, + is_http_url, + validate_http_url, +) from govoplan_core.security.outbound_http import ( DEFAULT_FILE_TRANSFER_BYTES, DEFAULT_STRUCTURED_RESPONSE_BYTES, @@ -21,6 +26,51 @@ from govoplan_core.security.outbound_http import ( class HttpFetchTests(unittest.TestCase): + def test_fetch_http_forwards_a_bounded_request_body(self) -> None: + class Response(io.BytesIO): + status = 200 + headers = {"Content-Type": "application/json"} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + opener = Mock() + opener.open.return_value = Response(b"{}") + with patch( + "govoplan_core.security.http_fetch.validate_outbound_http_url", + return_value="https://wiki.example.test/api.php", + ), patch( + "govoplan_core.security.http_fetch.build_outbound_http_opener", + return_value=opener, + ): + response = fetch_http( + "https://wiki.example.test/api.php", + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=b"action=edit", + max_bytes=1024, + ) + + request = opener.open.call_args.args[0] + self.assertEqual("POST", request.get_method()) + self.assertEqual(b"action=edit", request.data) + self.assertEqual(b"{}", response.body) + + def test_fetch_http_rejects_an_oversized_request_body_before_transport(self) -> None: + with patch( + "govoplan_core.security.http_fetch.validate_outbound_http_url" + ) as validate: + with self.assertRaisesRegex(ValueError, "request body exceeds"): + fetch_http( + "https://wiki.example.test/api.php", + method="POST", + body=b"x" * 1_000_001, + ) + validate.assert_not_called() + def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None: self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json")) self.assertTrue(is_http_url("http://example.test/catalog.json")) diff --git a/webui/package-lock.json b/webui/package-lock.json index 3d08c96..5b3ecf5 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.31", + "version": "0.1.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.31", + "version": "0.1.32", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", @@ -243,7 +243,7 @@ }, "../../govoplan-connectors/webui": { "name": "@govoplan/connectors-webui", - "version": "0.1.20", + "version": "0.1.21", "peerDependencies": { "@govoplan/core-webui": "^0.1.18", "react": ">=19.2.7 <20", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index a012c17..5c5cf85 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.31", + "version": "0.1.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.31", + "version": "0.1.32", "dependencies": { "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18", diff --git a/webui/package.json b/webui/package.json index 9ffd02d..3d2c00a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/package.release.json b/webui/package.release.json index 87a824d..d38b42e 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "main": "src/index.ts",