Compare commits

...
3 Commits
Author SHA1 Message Date
zemion aa91063211 Release Core v0.1.34 with JMAP mail contracts
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 17:08:54 +02:00
zemion fa2d5d40dd Release Core v0.1.33 with redirect-sensitive headers
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 16:11:02 +02:00
zemion 6ccef162f6 Release Core v0.1.32 with bounded HTTP request bodies
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 14:32:24 +02:00
9 changed files with 162 additions and 23 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.31"
version = "0.1.34"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"
+44 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import urllib.parse
import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Mapping
@@ -12,6 +13,12 @@ from govoplan_core.security.outbound_http import (
)
MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES = 1_000_000
_STANDARD_REDIRECT_SENSITIVE_HEADERS = frozenset(
{"authorization", "proxy-authorization", "cookie", "cookie2"}
)
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
status: int
@@ -46,15 +53,27 @@ def fetch_http(
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
body: bytes | None = None,
max_bytes: int | None = None,
redirect_sensitive_headers: Iterable[str] = (),
) -> 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,
)
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label))
opener = build_outbound_http_opener(
_PolicyRedirectHandler(
label=label,
sensitive_headers=redirect_sensitive_headers,
)
)
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items())
return HttpFetchResponse(
@@ -76,16 +95,35 @@ 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,
redirect_sensitive_headers: Iterable[str] = (),
) -> 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,
redirect_sensitive_headers=redirect_sensitive_headers,
).text(encoding)
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, *, label: str) -> None:
def __init__(
self,
*,
label: str,
sensitive_headers: Iterable[str] = (),
) -> None:
super().__init__()
self._label = label
self._sensitive_headers = _STANDARD_REDIRECT_SENSITIVE_HEADERS | {
value.strip().lower() for value in sensitive_headers if value.strip()
}
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
@@ -95,8 +133,9 @@ class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
return None
new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
if new_request is not None and _http_origin(previous) != _http_origin(redirected):
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"):
new_request.remove_header(header)
for header in tuple(new_request.headers) + tuple(new_request.unredirected_hdrs):
if header.lower() in self._sensitive_headers:
new_request.remove_header(header)
return new_request
+91 -4
View File
@@ -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"))
@@ -189,9 +239,17 @@ class HttpFetchTests(unittest.TestCase):
request = urllib.request.Request(
"https://catalog.example.test/releases",
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
headers={
"Authorization": "Bearer secret",
"Cookie": "session=secret",
"X-OTRS-Header-Password": "secret",
"X-Request-ID": "request-1",
},
)
handler = _PolicyRedirectHandler(
label="Catalog URL",
sensitive_headers=("X-OTRS-Header-Password",),
)
handler = _PolicyRedirectHandler(label="Catalog URL")
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
@@ -215,9 +273,38 @@ class HttpFetchTests(unittest.TestCase):
self.assertIsNotNone(redirected)
self.assertIsNone(redirected.get_header("Authorization"))
self.assertIsNone(redirected.get_header("Cookie"))
self.assertIsNone(redirected.get_header("X-otrs-header-password"))
self.assertEqual("request-1", redirected.get_header("X-request-id"))
self.assertIsNone(downgrade)
def test_core_redirects_preserve_caller_sensitive_headers_on_the_same_origin(self) -> None:
import urllib.request
request = urllib.request.Request(
"https://desk.example.test/original",
headers={"X-OTRS-Header-SessionID": "secret"},
)
handler = _PolicyRedirectHandler(
label="Service-desk URL",
sensitive_headers=("X-OTRS-Header-SessionID",),
)
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
):
redirected = handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://desk.example.test/final",
)
self.assertIsNotNone(redirected)
self.assertEqual("secret", redirected.get_header("X-otrs-header-sessionid"))
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.31",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.31",
"version": "0.1.34",
"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.22",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"react": ">=19.2.7 <20",
@@ -475,7 +475,7 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.21",
"version": "0.1.22",
"devDependencies": {
"typescript": "^5.7.2"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.31",
"version": "0.1.34",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.31",
"version": "0.1.34",
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.31",
"version": "0.1.34",
"private": true,
"type": "module",
"main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.31",
"version": "0.1.34",
"private": true,
"type": "module",
"main": "src/index.ts",
+5 -2
View File
@@ -35,7 +35,7 @@ export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, stri
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
protocol: "smtp" | "imap" | "jmap" | "pop3";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
@@ -52,7 +52,7 @@ export type MailImapFolderResponse = {
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
protocol: "imap" | "jmap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
@@ -69,6 +69,7 @@ export type MailImapFolderListResponse = {
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"jmap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
@@ -82,11 +83,13 @@ export const mailProfilePolicyLimitKeys = [
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.jmap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.jmap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
+13 -3
View File
@@ -847,6 +847,16 @@ export type MailImapTransportSettings = MailTransportSettings & {
folder_mappings?: MailImapFolderMappings | null;
};
export type MailJmapTransportSettings = {
session_url: string;
account_id?: string | null;
auth_scheme?: "bearer" | "basic";
timeout_seconds?: number | null;
max_response_bytes?: number | null;
max_body_value_bytes?: number | null;
allowed_api_origins?: string[];
};
export type MailServerProfileCredentials = {
smtp?: MailTransportCredentials | null;
imap?: MailTransportCredentials | null;
@@ -883,9 +893,9 @@ export type MailServerEndpoint = {
id: string;
profile_id: string;
tenant_id?: string | null;
protocol: "smtp" | "imap";
protocol: "smtp" | "imap" | "jmap" | "pop3";
name: string;
config: MailTransportSettings | MailImapTransportSettings;
config: MailTransportSettings | MailImapTransportSettings | MailJmapTransportSettings;
scope_type: MailProfileScope;
scope_id?: string | null;
inherit_to_lower_scopes: boolean;
@@ -922,7 +932,7 @@ export type MailCredentialPolicy = {
allow_override?: boolean | null;
};
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;