Skip to content

Commit 4db905f

Browse files
author
smkc
committed
Merge origin/main into feat/security-export-part-hash-verification
2 parents ce38add + 81bc405 commit 4db905f

5 files changed

Lines changed: 200 additions & 3 deletions

File tree

docs/configuration.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ options = KsefClientOptions(
1515
proxy=None,
1616
custom_headers={"X-Custom-Header": "value"},
1717
follow_redirects=False,
18+
strict_presigned_url_validation=True,
19+
allowed_presigned_hosts=None,
20+
allow_private_network_presigned_urls=False,
1821
base_qr_url=None,
1922
)
2023
```
@@ -72,6 +75,18 @@ Domyślnie `True`. Dotyczy pobierania partów eksportu (`ExportWorkflow`, `Async
7275
- jeśli hash się nie zgadza, biblioteka zgłasza `ValueError`;
7376
- ustawienie `False` pozwala przejść dalej, gdy nagłówek hash nie został zwrócony (nadal występuje walidacja, gdy hash jest obecny).
7477

78+
### `strict_presigned_url_validation`
79+
80+
Domyślnie `True`. Dla absolutnych URL używanych z `skip_auth=True` wymusza `https`. Przy wyłączeniu możliwe są URL `http`, ale nadal działa walidacja hosta/IP.
81+
82+
### `allowed_presigned_hosts`
83+
84+
Domyślnie `None` (brak allowlisty). Jeśli ustawione, host pre-signed URL musi pasować dokładnie albo jako subdomena (np. `a.uploads.example.com` pasuje do `uploads.example.com`).
85+
86+
### `allow_private_network_presigned_urls`
87+
88+
Domyślnie `False`. Gdy `False`, blokowane są hosty IP prywatne/link-local/reserved dla żądań `skip_auth=True`. Ustaw `True` wyłącznie w kontrolowanym środowisku.
89+
7590
## Przekazywanie `access_token`
7691

7792
Dostępne są dwa sposoby przekazywania `access_token`:

docs/errors.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Obsługa błędów jest oparta o kody HTTP (>= 400). Biblioteka nie interpretuje
44

55
## Typy wyjątków
66

7+
### `ValueError` (walidacja pre-signed URL)
8+
9+
Dla żądań z `skip_auth=True` i absolutnym URL biblioteka wykonuje walidację bezpieczeństwa. W przypadku niespełnienia reguł (np. host `localhost`, loopback/private IP bez opt-in, host poza allowlistą, albo `http` przy `strict_presigned_url_validation=True`) rzucany jest `ValueError` z komunikatem bezpieczeństwa.
10+
711
### `KsefHttpError`
812

913
Bazowy błąd HTTP.

src/ksef_client/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class KsefClientOptions:
4141
follow_redirects: bool = False
4242
verify_ssl: bool = True
4343
require_export_part_hash: bool = True
44+
strict_presigned_url_validation: bool = True
45+
allowed_presigned_hosts: list[str] | None = None
46+
allow_private_network_presigned_urls: bool = False
4447
user_agent: str = field(default_factory=_default_user_agent)
4548

4649
def normalized_base_url(self) -> str:

src/ksef_client/http.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import ipaddress
34
from dataclasses import dataclass
45
from typing import Any
6+
from urllib.parse import urlparse
57

68
import httpx
79

@@ -17,6 +19,75 @@ def _merge_headers(base: dict[str, str], extra: dict[str, str] | None) -> dict[s
1719
return merged
1820

1921

22+
def _is_absolute_http_url(url: str) -> bool:
23+
return url.startswith("http://") or url.startswith("https://")
24+
25+
26+
def _host_allowed(host: str, allowed_hosts: list[str]) -> bool:
27+
normalized_host = host.lower().rstrip(".")
28+
for allowed in allowed_hosts:
29+
normalized_allowed = allowed.lower().strip().rstrip(".")
30+
if not normalized_allowed:
31+
continue
32+
if normalized_host == normalized_allowed:
33+
return True
34+
try:
35+
ipaddress.ip_address(normalized_allowed)
36+
continue
37+
except ValueError:
38+
pass
39+
if normalized_host.endswith("." + normalized_allowed):
40+
return True
41+
return False
42+
43+
44+
def _validate_presigned_url_security(options: KsefClientOptions, url: str) -> None:
45+
parsed = urlparse(url)
46+
host = parsed.hostname
47+
if not host:
48+
raise ValueError("Rejected insecure presigned URL: host is missing.")
49+
50+
normalized_host = host.lower().rstrip(".")
51+
if normalized_host == "localhost" or normalized_host.endswith(".localhost"):
52+
raise ValueError(
53+
"Rejected insecure presigned URL: localhost hosts are not allowed "
54+
"for skip_auth requests."
55+
)
56+
57+
if options.strict_presigned_url_validation and parsed.scheme != "https":
58+
raise ValueError(
59+
"Rejected insecure presigned URL: https is required for skip_auth requests."
60+
)
61+
62+
try:
63+
host_ip = ipaddress.ip_address(normalized_host)
64+
except ValueError:
65+
host_ip = None
66+
67+
if host_ip is not None:
68+
if host_ip.is_loopback:
69+
raise ValueError(
70+
"Rejected insecure presigned URL: loopback addresses are not allowed "
71+
"for skip_auth requests."
72+
)
73+
if (
74+
not options.allow_private_network_presigned_urls
75+
and (host_ip.is_private or host_ip.is_link_local or host_ip.is_reserved)
76+
):
77+
raise ValueError(
78+
"Rejected insecure presigned URL: private, link-local, and reserved "
79+
"IP hosts are blocked for skip_auth requests."
80+
)
81+
82+
if options.allowed_presigned_hosts and not _host_allowed(
83+
normalized_host, options.allowed_presigned_hosts
84+
):
85+
raise ValueError(
86+
"Rejected insecure presigned URL: host is not in allowed_presigned_hosts "
87+
"for skip_auth requests."
88+
)
89+
90+
2091
@dataclass
2192
class HttpResponse:
2293
status_code: int
@@ -60,8 +131,10 @@ def request(
60131
expected_status: set[int] | None = None,
61132
) -> HttpResponse:
62133
url = path
63-
if not url.startswith("http://") and not url.startswith("https://"):
134+
if not _is_absolute_http_url(url):
64135
url = self._options.normalized_base_url().rstrip("/") + "/" + path.lstrip("/")
136+
elif skip_auth:
137+
_validate_presigned_url_security(self._options, url)
65138

66139
base_headers = {
67140
"User-Agent": self._options.user_agent,
@@ -166,8 +239,10 @@ async def request(
166239
expected_status: set[int] | None = None,
167240
) -> HttpResponse:
168241
url = path
169-
if not url.startswith("http://") and not url.startswith("https://"):
242+
if not _is_absolute_http_url(url):
170243
url = self._options.normalized_base_url().rstrip("/") + "/" + path.lstrip("/")
244+
elif skip_auth:
245+
_validate_presigned_url_security(self._options, url)
171246

172247
base_headers = {
173248
"User-Agent": self._options.user_agent,

tests/test_http.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55

66
from ksef_client.config import KsefClientOptions
77
from ksef_client.exceptions import KsefApiError, KsefHttpError, KsefRateLimitError
8-
from ksef_client.http import AsyncBaseHttpClient, BaseHttpClient, HttpResponse, _merge_headers
8+
from ksef_client.http import (
9+
AsyncBaseHttpClient,
10+
BaseHttpClient,
11+
HttpResponse,
12+
_host_allowed,
13+
_merge_headers,
14+
_validate_presigned_url_security,
15+
)
916

1017

1118
class HttpTests(unittest.TestCase):
@@ -119,6 +126,93 @@ def test_default_status_error(self):
119126
):
120127
client.request("GET", "/path")
121128

129+
def test_skip_auth_presigned_url_accepts_valid_https(self):
130+
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
131+
client = BaseHttpClient(options)
132+
response = httpx.Response(200, json={"ok": True})
133+
with patch.object(client._client, "request", Mock(return_value=response)) as request_mock:
134+
client.request("GET", "https://files.example.com/upload", skip_auth=True)
135+
_, kwargs = request_mock.call_args
136+
self.assertEqual(kwargs["url"], "https://files.example.com/upload")
137+
self.assertNotIn("Authorization", kwargs["headers"])
138+
139+
def test_skip_auth_presigned_url_rejects_http_when_strict(self):
140+
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
141+
client = BaseHttpClient(options)
142+
with self.assertRaisesRegex(ValueError, "https is required"):
143+
client.request("GET", "http://files.example.com/upload", skip_auth=True)
144+
145+
def test_skip_auth_presigned_url_allows_http_when_not_strict(self):
146+
options = KsefClientOptions(
147+
base_url="https://api-test.ksef.mf.gov.pl",
148+
strict_presigned_url_validation=False,
149+
)
150+
client = BaseHttpClient(options)
151+
response = httpx.Response(200, json={"ok": True})
152+
with patch.object(client._client, "request", Mock(return_value=response)) as request_mock:
153+
client.request("GET", "http://files.example.com/upload", skip_auth=True)
154+
_, kwargs = request_mock.call_args
155+
self.assertEqual(kwargs["url"], "http://files.example.com/upload")
156+
157+
def test_skip_auth_presigned_url_rejects_localhost_and_loopback(self):
158+
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
159+
client = BaseHttpClient(options)
160+
with self.assertRaisesRegex(ValueError, "localhost"):
161+
client.request("GET", "https://localhost/upload", skip_auth=True)
162+
with self.assertRaisesRegex(ValueError, "loopback"):
163+
client.request("GET", "https://127.0.0.1/upload", skip_auth=True)
164+
165+
def test_skip_auth_presigned_url_rejects_private_ip_by_default(self):
166+
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
167+
client = BaseHttpClient(options)
168+
with self.assertRaisesRegex(ValueError, "private, link-local, and reserved IP"):
169+
client.request("GET", "https://10.1.2.3/upload", skip_auth=True)
170+
171+
def test_skip_auth_presigned_url_allows_private_ip_when_opted_in(self):
172+
options = KsefClientOptions(
173+
base_url="https://api-test.ksef.mf.gov.pl",
174+
allow_private_network_presigned_urls=True,
175+
)
176+
client = BaseHttpClient(options)
177+
response = httpx.Response(200, json={"ok": True})
178+
with patch.object(client._client, "request", Mock(return_value=response)) as request_mock:
179+
client.request("GET", "https://10.1.2.3/upload", skip_auth=True)
180+
_, kwargs = request_mock.call_args
181+
self.assertEqual(kwargs["url"], "https://10.1.2.3/upload")
182+
183+
def test_skip_auth_presigned_url_allowlist_exact_and_subdomain(self):
184+
options = KsefClientOptions(
185+
base_url="https://api-test.ksef.mf.gov.pl",
186+
allowed_presigned_hosts=["uploads.example.com"],
187+
)
188+
client = BaseHttpClient(options)
189+
response = httpx.Response(200, json={"ok": True})
190+
with patch.object(client._client, "request", Mock(return_value=response)):
191+
client.request("GET", "https://uploads.example.com/path", skip_auth=True)
192+
client.request("GET", "https://sub.uploads.example.com/path", skip_auth=True)
193+
194+
def test_skip_auth_presigned_url_allowlist_rejects_other_hosts(self):
195+
options = KsefClientOptions(
196+
base_url="https://api-test.ksef.mf.gov.pl",
197+
allowed_presigned_hosts=["uploads.example.com"],
198+
)
199+
client = BaseHttpClient(options)
200+
with self.assertRaisesRegex(ValueError, "allowed_presigned_hosts"):
201+
client.request("GET", "https://other.example.com/path", skip_auth=True)
202+
203+
def test_host_allowed_skips_empty_and_ip_allowlist_entries(self):
204+
self.assertTrue(
205+
_host_allowed(
206+
"sub.uploads.example.com",
207+
["", "10.0.0.1", "uploads.example.com"],
208+
)
209+
)
210+
211+
def test_validate_presigned_url_security_rejects_missing_host(self):
212+
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
213+
with self.assertRaisesRegex(ValueError, "host is missing"):
214+
_validate_presigned_url_security(options, "https:///no-host")
215+
122216

123217
class AsyncHttpTests(unittest.IsolatedAsyncioTestCase):
124218
async def test_async_request(self):
@@ -193,6 +287,12 @@ async def test_async_raise_for_status_paths(self):
193287
with self.assertRaises(KsefHttpError):
194288
client._raise_for_status(response_http)
195289

290+
async def test_async_skip_auth_presigned_validation_rejects_localhost(self):
291+
options = KsefClientOptions(base_url="https://api-test.ksef.mf.gov.pl")
292+
client = AsyncBaseHttpClient(options)
293+
with self.assertRaisesRegex(ValueError, "localhost"):
294+
await client.request("GET", "https://localhost/upload", skip_auth=True)
295+
196296

197297
if __name__ == "__main__":
198298
unittest.main()

0 commit comments

Comments
 (0)