Harden outbound connector transports
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.security.http_fetch import is_http_url, validate_http_url
|
||||
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url
|
||||
from govoplan_core.security.outbound_http import (
|
||||
DEFAULT_FILE_TRANSFER_BYTES,
|
||||
DEFAULT_STRUCTURED_RESPONSE_BYTES,
|
||||
OutboundHttpBlocked,
|
||||
OutboundResponseTooLarge,
|
||||
bounded_chunks_bytes,
|
||||
bounded_response_bytes,
|
||||
create_outbound_connection,
|
||||
outbound_http_policy,
|
||||
validate_outbound_http_url,
|
||||
validate_unpinned_sdk_http_url,
|
||||
)
|
||||
|
||||
|
||||
class HttpFetchTests(unittest.TestCase):
|
||||
@@ -22,6 +36,124 @@ class HttpFetchTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
validate_http_url(value)
|
||||
|
||||
def test_outbound_policy_has_practical_bounded_defaults(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
self.assertFalse(policy.allow_private_networks)
|
||||
self.assertEqual(DEFAULT_STRUCTURED_RESPONSE_BYTES, policy.structured_response_bytes)
|
||||
self.assertEqual(DEFAULT_FILE_TRANSFER_BYTES, policy.file_transfer_bytes)
|
||||
|
||||
configured = outbound_http_policy(
|
||||
{
|
||||
"APP_ENV": "production",
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
|
||||
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "1024",
|
||||
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "2048",
|
||||
}
|
||||
)
|
||||
self.assertTrue(configured.allow_private_networks)
|
||||
self.assertEqual(1024, configured.structured_response_bytes)
|
||||
self.assertEqual(2048, configured.file_transfer_bytes)
|
||||
|
||||
def test_private_network_policy_is_deployment_wide(self) -> None:
|
||||
denied_policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
|
||||
), self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=denied_policy)
|
||||
|
||||
allowed_policy = outbound_http_policy(
|
||||
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true"}
|
||||
)
|
||||
self.assertEqual(
|
||||
"https://127.0.0.1/path",
|
||||
validate_outbound_http_url("https://127.0.0.1/path", policy=allowed_policy),
|
||||
)
|
||||
|
||||
def test_outbound_policy_rejects_mixed_public_and_private_dns_answers(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 443)),
|
||||
(2, 1, 6, "", ("10.0.0.4", 443)),
|
||||
],
|
||||
), self.assertRaises(OutboundHttpBlocked):
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||
|
||||
def test_bounded_response_rejects_declared_and_streamed_oversize_payloads(self) -> None:
|
||||
with self.assertRaises(OutboundResponseTooLarge):
|
||||
bounded_response_bytes(io.BytesIO(b"small"), headers={"Content-Length": "20"}, max_bytes=10)
|
||||
with self.assertRaises(OutboundResponseTooLarge):
|
||||
bounded_response_bytes(io.BytesIO(b"eleven-byte"), max_bytes=10)
|
||||
self.assertEqual(b"ten-bytes!", bounded_response_bytes(io.BytesIO(b"ten-bytes!"), max_bytes=10))
|
||||
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES": "5"}), self.assertRaises(
|
||||
OutboundResponseTooLarge
|
||||
):
|
||||
bounded_response_bytes(io.BytesIO(b"123456"), max_bytes=10)
|
||||
|
||||
def test_bounded_chunks_rejects_an_oversized_chunk_before_retaining_it(self) -> None:
|
||||
with self.assertRaises(OutboundResponseTooLarge):
|
||||
bounded_chunks_bytes((b"one-large-chunk",), max_bytes=8)
|
||||
|
||||
def test_connection_time_resolution_blocks_dns_rebinding_before_socket_open(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
side_effect=(public, private),
|
||||
) as resolver, patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory:
|
||||
validate_outbound_http_url("https://connector.example.test/path", policy=policy)
|
||||
with self.assertRaisesRegex(OutboundHttpBlocked, "non-public network"):
|
||||
create_outbound_connection("connector.example.test", 443, policy=policy)
|
||||
|
||||
self.assertEqual(2, resolver.call_count)
|
||||
socket_factory.assert_not_called()
|
||||
|
||||
def test_unpinned_sdk_endpoints_fail_closed_for_dns_hosts(self) -> None:
|
||||
policy = outbound_http_policy({"APP_ENV": "production"})
|
||||
with patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 443))],
|
||||
), self.assertRaisesRegex(OutboundHttpBlocked, "cannot pin"):
|
||||
validate_unpinned_sdk_http_url(
|
||||
"https://objects.example.test",
|
||||
label="S3 endpoint",
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
def test_core_redirects_strip_credentials_cross_origin_and_reject_https_downgrades(self) -> None:
|
||||
import urllib.request
|
||||
|
||||
request = urllib.request.Request(
|
||||
"https://catalog.example.test/releases",
|
||||
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"},
|
||||
)
|
||||
handler = _PolicyRedirectHandler(label="Catalog URL")
|
||||
with patch.dict("os.environ", {"APP_ENV": "test"}):
|
||||
redirected = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"https://cdn.example.test/releases",
|
||||
)
|
||||
downgrade = handler.redirect_request(
|
||||
request,
|
||||
None,
|
||||
302,
|
||||
"Found",
|
||||
{},
|
||||
"http://catalog.example.test/releases",
|
||||
)
|
||||
|
||||
self.assertIsNotNone(redirected)
|
||||
self.assertIsNone(redirected.get_header("Authorization"))
|
||||
self.assertEqual("request-1", redirected.get_header("X-request-id"))
|
||||
self.assertIsNone(downgrade)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user