iflow-mcp_democratize-technology-chronos-mcp 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chronos_mcp/__init__.py +5 -0
- chronos_mcp/__main__.py +9 -0
- chronos_mcp/accounts.py +410 -0
- chronos_mcp/bulk.py +946 -0
- chronos_mcp/caldav_utils.py +149 -0
- chronos_mcp/calendars.py +204 -0
- chronos_mcp/config.py +187 -0
- chronos_mcp/credentials.py +190 -0
- chronos_mcp/events.py +515 -0
- chronos_mcp/exceptions.py +477 -0
- chronos_mcp/journals.py +477 -0
- chronos_mcp/logging_config.py +23 -0
- chronos_mcp/models.py +202 -0
- chronos_mcp/py.typed +0 -0
- chronos_mcp/rrule.py +259 -0
- chronos_mcp/search.py +315 -0
- chronos_mcp/server.py +121 -0
- chronos_mcp/tasks.py +518 -0
- chronos_mcp/tools/__init__.py +29 -0
- chronos_mcp/tools/accounts.py +151 -0
- chronos_mcp/tools/base.py +59 -0
- chronos_mcp/tools/bulk.py +557 -0
- chronos_mcp/tools/calendars.py +142 -0
- chronos_mcp/tools/events.py +698 -0
- chronos_mcp/tools/journals.py +310 -0
- chronos_mcp/tools/tasks.py +414 -0
- chronos_mcp/utils.py +163 -0
- chronos_mcp/validation.py +636 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +91 -0
- tests/unit/__init__.py +0 -0
- tests/unit/test_accounts.py +380 -0
- tests/unit/test_accounts_ssrf.py +134 -0
- tests/unit/test_base.py +135 -0
- tests/unit/test_bulk.py +380 -0
- tests/unit/test_bulk_create.py +408 -0
- tests/unit/test_bulk_delete.py +341 -0
- tests/unit/test_bulk_resource_limits.py +74 -0
- tests/unit/test_caldav_utils.py +300 -0
- tests/unit/test_calendars.py +286 -0
- tests/unit/test_config.py +111 -0
- tests/unit/test_config_validation.py +128 -0
- tests/unit/test_credentials_security.py +189 -0
- tests/unit/test_cryptography_security.py +178 -0
- tests/unit/test_events.py +536 -0
- tests/unit/test_exceptions.py +58 -0
- tests/unit/test_journals.py +1097 -0
- tests/unit/test_models.py +95 -0
- tests/unit/test_race_conditions.py +202 -0
- tests/unit/test_recurring_events.py +156 -0
- tests/unit/test_rrule.py +217 -0
- tests/unit/test_search.py +372 -0
- tests/unit/test_search_advanced.py +333 -0
- tests/unit/test_server_input_validation.py +219 -0
- tests/unit/test_ssrf_protection.py +505 -0
- tests/unit/test_tasks.py +918 -0
- tests/unit/test_thread_safety.py +301 -0
- tests/unit/test_tools_journals.py +617 -0
- tests/unit/test_tools_tasks.py +968 -0
- tests/unit/test_url_validation_security.py +234 -0
- tests/unit/test_utils.py +180 -0
- tests/unit/test_validation.py +983 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive SSRF (Server-Side Request Forgery) protection tests for URL validation.
|
|
3
|
+
|
|
4
|
+
This module tests the enhanced URL validation that prevents SSRF attacks by blocking
|
|
5
|
+
requests to localhost, private IP ranges, and other potentially dangerous addresses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from chronos_mcp.exceptions import ValidationError
|
|
14
|
+
from chronos_mcp.validation import InputValidator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestSSRFProtection:
|
|
18
|
+
"""Test suite for SSRF protection in URL validation"""
|
|
19
|
+
|
|
20
|
+
def test_validate_url_blocks_localhost(self):
|
|
21
|
+
"""Test that localhost in various forms is blocked by default"""
|
|
22
|
+
validator = InputValidator()
|
|
23
|
+
|
|
24
|
+
localhost_urls = [
|
|
25
|
+
"https://localhost/caldav",
|
|
26
|
+
"https://localhost:8443/caldav",
|
|
27
|
+
"https://LOCALHOST/caldav", # Case variations
|
|
28
|
+
"https://localhost.localdomain/caldav",
|
|
29
|
+
"https://127.0.0.1/caldav",
|
|
30
|
+
"https://127.0.0.1:8443/caldav",
|
|
31
|
+
"https://127.0.0.2/caldav", # Other loopback addresses
|
|
32
|
+
"https://127.255.255.255/caldav", # End of loopback range
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
for url in localhost_urls:
|
|
36
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
37
|
+
validator.validate_url(url)
|
|
38
|
+
|
|
39
|
+
error_msg = str(exc_info.value)
|
|
40
|
+
assert (
|
|
41
|
+
"not allowed for security reasons" in error_msg
|
|
42
|
+
or "Unable to resolve hostname" in error_msg
|
|
43
|
+
), f"URL should be blocked for SSRF protection: {url}"
|
|
44
|
+
|
|
45
|
+
def test_validate_url_blocks_ipv6_localhost(self):
|
|
46
|
+
"""Test that IPv6 localhost addresses are blocked"""
|
|
47
|
+
validator = InputValidator()
|
|
48
|
+
|
|
49
|
+
ipv6_localhost_urls = [
|
|
50
|
+
"https://[::1]/caldav",
|
|
51
|
+
"https://[::1]:8443/caldav",
|
|
52
|
+
"https://[::ffff:127.0.0.1]/caldav", # IPv4-mapped IPv6
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
for url in ipv6_localhost_urls:
|
|
56
|
+
# IPv6 URLs might not match our pattern or fail in resolution
|
|
57
|
+
with pytest.raises(ValidationError):
|
|
58
|
+
validator.validate_url(url)
|
|
59
|
+
|
|
60
|
+
def test_validate_url_blocks_private_ipv4_ranges(self):
|
|
61
|
+
"""Test that private IPv4 ranges are blocked"""
|
|
62
|
+
validator = InputValidator()
|
|
63
|
+
|
|
64
|
+
private_ip_urls = [
|
|
65
|
+
# Class A private (10.0.0.0/8)
|
|
66
|
+
"https://10.0.0.1/caldav",
|
|
67
|
+
"https://10.255.255.255/caldav",
|
|
68
|
+
"https://10.1.2.3:8443/caldav",
|
|
69
|
+
# Class B private (172.16.0.0/12)
|
|
70
|
+
"https://172.16.0.1/caldav",
|
|
71
|
+
"https://172.31.255.255/caldav",
|
|
72
|
+
"https://172.20.10.5:8443/caldav",
|
|
73
|
+
# Class C private (192.168.0.0/16)
|
|
74
|
+
"https://192.168.0.1/caldav",
|
|
75
|
+
"https://192.168.1.1/caldav",
|
|
76
|
+
"https://192.168.255.255/caldav",
|
|
77
|
+
"https://192.168.1.100:8443/caldav",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for url in private_ip_urls:
|
|
81
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
82
|
+
validator.validate_url(url)
|
|
83
|
+
|
|
84
|
+
error_msg = str(exc_info.value)
|
|
85
|
+
assert (
|
|
86
|
+
"private or internal IP address" in error_msg
|
|
87
|
+
or "Unable to resolve hostname" in error_msg
|
|
88
|
+
), f"Private IP should be blocked: {url}"
|
|
89
|
+
|
|
90
|
+
def test_validate_url_blocks_link_local_addresses(self):
|
|
91
|
+
"""Test that link-local addresses are blocked"""
|
|
92
|
+
validator = InputValidator()
|
|
93
|
+
|
|
94
|
+
link_local_urls = [
|
|
95
|
+
"https://169.254.0.1/caldav",
|
|
96
|
+
"https://169.254.169.254/caldav", # AWS metadata endpoint
|
|
97
|
+
"https://169.254.255.255/caldav",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
for url in link_local_urls:
|
|
101
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
102
|
+
validator.validate_url(url)
|
|
103
|
+
|
|
104
|
+
error_msg = str(exc_info.value)
|
|
105
|
+
assert (
|
|
106
|
+
"private or internal IP address" in error_msg
|
|
107
|
+
or "restricted IP address" in error_msg
|
|
108
|
+
or "Unable to resolve hostname" in error_msg
|
|
109
|
+
), f"Link-local address should be blocked: {url}"
|
|
110
|
+
|
|
111
|
+
def test_validate_url_blocks_zero_address(self):
|
|
112
|
+
"""Test that 0.0.0.0 is blocked"""
|
|
113
|
+
validator = InputValidator()
|
|
114
|
+
|
|
115
|
+
with pytest.raises(ValidationError):
|
|
116
|
+
validator.validate_url("https://0.0.0.0/caldav")
|
|
117
|
+
|
|
118
|
+
@patch("socket.getaddrinfo")
|
|
119
|
+
def test_validate_url_blocks_domains_resolving_to_private_ips(
|
|
120
|
+
self, mock_getaddrinfo
|
|
121
|
+
):
|
|
122
|
+
"""Test that domains resolving to private IPs are blocked"""
|
|
123
|
+
validator = InputValidator()
|
|
124
|
+
|
|
125
|
+
# Mock a domain that resolves to a private IP
|
|
126
|
+
test_cases = [
|
|
127
|
+
("internal.example.com", "192.168.1.100"),
|
|
128
|
+
("dev.local", "10.0.0.50"),
|
|
129
|
+
("staging.app", "172.16.0.10"),
|
|
130
|
+
("metadata.local", "169.254.169.254"),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
for domain, private_ip in test_cases:
|
|
134
|
+
mock_getaddrinfo.return_value = [
|
|
135
|
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (private_ip, 443))
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
url = f"https://{domain}/caldav"
|
|
139
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
140
|
+
validator.validate_url(url)
|
|
141
|
+
|
|
142
|
+
error_msg = str(exc_info.value)
|
|
143
|
+
assert (
|
|
144
|
+
"private or internal IP address" in error_msg
|
|
145
|
+
or "restricted IP address" in error_msg
|
|
146
|
+
), f"Domain resolving to {private_ip} should be blocked: {url}"
|
|
147
|
+
|
|
148
|
+
@patch("socket.getaddrinfo")
|
|
149
|
+
def test_validate_url_allows_public_ips(self, mock_getaddrinfo):
|
|
150
|
+
"""Test that public IP addresses are allowed"""
|
|
151
|
+
validator = InputValidator()
|
|
152
|
+
|
|
153
|
+
# Mock domains resolving to public IPs
|
|
154
|
+
public_test_cases = [
|
|
155
|
+
("caldav.example.com", "93.184.216.34"), # Public IP
|
|
156
|
+
("calendar.company.org", "8.8.8.8"), # Google DNS
|
|
157
|
+
("sync.service.io", "1.1.1.1"), # Cloudflare DNS
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
for domain, public_ip in public_test_cases:
|
|
161
|
+
mock_getaddrinfo.return_value = [
|
|
162
|
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (public_ip, 443))
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
url = f"https://{domain}/caldav"
|
|
166
|
+
result = validator.validate_url(url)
|
|
167
|
+
assert result == url, f"Public IP should be allowed: {url}"
|
|
168
|
+
|
|
169
|
+
def test_validate_url_with_allow_private_ips_flag(self):
|
|
170
|
+
"""Test that private IPs are allowed when flag is set"""
|
|
171
|
+
validator = InputValidator()
|
|
172
|
+
|
|
173
|
+
# URLs that would normally be blocked
|
|
174
|
+
private_urls = [
|
|
175
|
+
"https://localhost/caldav",
|
|
176
|
+
"https://127.0.0.1/caldav",
|
|
177
|
+
"https://192.168.1.100/caldav",
|
|
178
|
+
"https://10.0.0.50/caldav",
|
|
179
|
+
"https://172.16.0.10/caldav",
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
for url in private_urls:
|
|
183
|
+
# Should pass when allow_private_ips=True
|
|
184
|
+
result = validator.validate_url(url, allow_private_ips=True)
|
|
185
|
+
assert result == url, f"Private IP should be allowed with flag: {url}"
|
|
186
|
+
|
|
187
|
+
# Should fail when allow_private_ips=False (default)
|
|
188
|
+
with pytest.raises(ValidationError):
|
|
189
|
+
validator.validate_url(url, allow_private_ips=False)
|
|
190
|
+
|
|
191
|
+
@patch("socket.getaddrinfo")
|
|
192
|
+
def test_validate_url_dns_rebinding_protection(self, mock_getaddrinfo):
|
|
193
|
+
"""Test protection against DNS rebinding attacks"""
|
|
194
|
+
validator = InputValidator()
|
|
195
|
+
|
|
196
|
+
# Simulate DNS rebinding - domain resolves to multiple IPs including private
|
|
197
|
+
mock_getaddrinfo.return_value = [
|
|
198
|
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", 443)), # Public
|
|
199
|
+
(
|
|
200
|
+
socket.AF_INET,
|
|
201
|
+
socket.SOCK_STREAM,
|
|
202
|
+
6,
|
|
203
|
+
"",
|
|
204
|
+
("192.168.1.1", 443),
|
|
205
|
+
), # Private
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
url = "https://evil.example.com/caldav"
|
|
209
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
210
|
+
validator.validate_url(url)
|
|
211
|
+
|
|
212
|
+
error_msg = str(exc_info.value)
|
|
213
|
+
assert (
|
|
214
|
+
"private or internal IP address" in error_msg
|
|
215
|
+
), "Should block domain with mixed public/private IPs"
|
|
216
|
+
|
|
217
|
+
@patch("socket.getaddrinfo")
|
|
218
|
+
def test_validate_url_handles_dns_resolution_failures(self, mock_getaddrinfo):
|
|
219
|
+
"""Test handling of DNS resolution failures"""
|
|
220
|
+
validator = InputValidator()
|
|
221
|
+
|
|
222
|
+
# Simulate DNS resolution failure
|
|
223
|
+
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
|
|
224
|
+
|
|
225
|
+
url = "https://nonexistent.example.com/caldav"
|
|
226
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
227
|
+
validator.validate_url(url)
|
|
228
|
+
|
|
229
|
+
error_msg = str(exc_info.value)
|
|
230
|
+
assert (
|
|
231
|
+
"Unable to resolve hostname" in error_msg
|
|
232
|
+
), "Should handle DNS resolution failure gracefully"
|
|
233
|
+
|
|
234
|
+
def test_validate_url_enforces_https(self):
|
|
235
|
+
"""Test that only HTTPS URLs are allowed"""
|
|
236
|
+
validator = InputValidator()
|
|
237
|
+
|
|
238
|
+
# HTTP and other protocols should be rejected
|
|
239
|
+
invalid_protocols = [
|
|
240
|
+
"http://example.com/caldav",
|
|
241
|
+
"ftp://example.com/caldav",
|
|
242
|
+
"file:///etc/passwd",
|
|
243
|
+
"javascript:alert(1)",
|
|
244
|
+
"data:text/html,<script>alert(1)</script>",
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
for url in invalid_protocols:
|
|
248
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
249
|
+
validator.validate_url(url)
|
|
250
|
+
|
|
251
|
+
error_msg = str(exc_info.value)
|
|
252
|
+
assert (
|
|
253
|
+
"Invalid URL format" in error_msg
|
|
254
|
+
or "Must be a valid HTTPS URL" in error_msg
|
|
255
|
+
), f"Non-HTTPS protocol should be rejected: {url}"
|
|
256
|
+
|
|
257
|
+
def test_validate_url_length_limits(self):
|
|
258
|
+
"""Test URL length validation"""
|
|
259
|
+
validator = InputValidator()
|
|
260
|
+
|
|
261
|
+
# Create a URL that exceeds the maximum length
|
|
262
|
+
long_path = "a" * 3000
|
|
263
|
+
long_url = f"https://example.com/{long_path}"
|
|
264
|
+
|
|
265
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
266
|
+
validator.validate_url(long_url)
|
|
267
|
+
|
|
268
|
+
error_msg = str(exc_info.value)
|
|
269
|
+
assert "exceeds maximum length" in error_msg
|
|
270
|
+
|
|
271
|
+
def test_is_private_ip_method(self):
|
|
272
|
+
"""Test the is_private_ip helper method"""
|
|
273
|
+
validator = InputValidator()
|
|
274
|
+
|
|
275
|
+
# Private IPs should return True
|
|
276
|
+
private_ips = [
|
|
277
|
+
"127.0.0.1",
|
|
278
|
+
"192.168.1.1",
|
|
279
|
+
"10.0.0.1",
|
|
280
|
+
"172.16.0.1",
|
|
281
|
+
"169.254.1.1",
|
|
282
|
+
"::1",
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
for ip in private_ips:
|
|
286
|
+
assert validator.is_private_ip(ip), f"Should identify {ip} as private"
|
|
287
|
+
|
|
288
|
+
# Public IPs should return False
|
|
289
|
+
public_ips = [
|
|
290
|
+
"8.8.8.8",
|
|
291
|
+
"1.1.1.1",
|
|
292
|
+
"93.184.216.34",
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
for ip in public_ips:
|
|
296
|
+
assert not validator.is_private_ip(ip), f"Should identify {ip} as public"
|
|
297
|
+
|
|
298
|
+
# Invalid IPs should return True (fail-safe)
|
|
299
|
+
invalid_ips = [
|
|
300
|
+
"not-an-ip",
|
|
301
|
+
"999.999.999.999",
|
|
302
|
+
"",
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
for ip in invalid_ips:
|
|
306
|
+
assert validator.is_private_ip(
|
|
307
|
+
ip
|
|
308
|
+
), f"Should treat invalid IP {ip} as suspicious"
|
|
309
|
+
|
|
310
|
+
def test_validate_url_field_name_in_errors(self):
|
|
311
|
+
"""Test that custom field names appear in error messages"""
|
|
312
|
+
validator = InputValidator()
|
|
313
|
+
|
|
314
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
315
|
+
validator.validate_url(
|
|
316
|
+
"https://127.0.0.1/caldav", field_name="caldav_server"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
error_msg = str(exc_info.value)
|
|
320
|
+
assert "caldav_server" in error_msg, "Custom field name should appear in error"
|
|
321
|
+
|
|
322
|
+
def test_validate_url_empty_url(self):
|
|
323
|
+
"""Test handling of empty URLs"""
|
|
324
|
+
validator = InputValidator()
|
|
325
|
+
|
|
326
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
327
|
+
validator.validate_url("")
|
|
328
|
+
|
|
329
|
+
error_msg = str(exc_info.value)
|
|
330
|
+
assert "cannot be empty" in error_msg
|
|
331
|
+
|
|
332
|
+
def test_validate_url_whitespace_handling(self):
|
|
333
|
+
"""Test that URLs with whitespace are properly handled"""
|
|
334
|
+
validator = InputValidator()
|
|
335
|
+
|
|
336
|
+
# Leading/trailing whitespace should be stripped
|
|
337
|
+
result = validator.validate_url(
|
|
338
|
+
" https://example.com/caldav ", allow_private_ips=True
|
|
339
|
+
)
|
|
340
|
+
assert result == "https://example.com/caldav"
|
|
341
|
+
|
|
342
|
+
# Whitespace in URL should fail validation
|
|
343
|
+
with pytest.raises(ValidationError):
|
|
344
|
+
validator.validate_url("https://example .com/caldav")
|
|
345
|
+
|
|
346
|
+
@patch("socket.getaddrinfo")
|
|
347
|
+
def test_validate_url_ipv6_private_ranges(self, mock_getaddrinfo):
|
|
348
|
+
"""Test that private IPv6 ranges are blocked"""
|
|
349
|
+
validator = InputValidator()
|
|
350
|
+
|
|
351
|
+
# Mock domain resolving to private IPv6
|
|
352
|
+
test_cases = [
|
|
353
|
+
("::1", "IPv6 loopback"),
|
|
354
|
+
("fe80::1", "IPv6 link-local"),
|
|
355
|
+
("fc00::1", "IPv6 unique local"),
|
|
356
|
+
("fd00::1", "IPv6 unique local"),
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
for ipv6_addr, description in test_cases:
|
|
360
|
+
mock_getaddrinfo.return_value = [
|
|
361
|
+
(socket.AF_INET6, socket.SOCK_STREAM, 6, "", (ipv6_addr, 443, 0, 0))
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
url = "https://ipv6.example.com/caldav"
|
|
365
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
366
|
+
validator.validate_url(url)
|
|
367
|
+
|
|
368
|
+
error_msg = str(exc_info.value)
|
|
369
|
+
assert (
|
|
370
|
+
"private or internal IP address" in error_msg
|
|
371
|
+
or "restricted IP address" in error_msg
|
|
372
|
+
), f"{description} should be blocked: {ipv6_addr}"
|
|
373
|
+
|
|
374
|
+
def test_validate_url_special_case_addresses(self):
|
|
375
|
+
"""Test that special case addresses are handled correctly"""
|
|
376
|
+
validator = InputValidator()
|
|
377
|
+
|
|
378
|
+
special_addresses = [
|
|
379
|
+
"https://0.0.0.0/caldav", # Wildcard address
|
|
380
|
+
"https://255.255.255.255/caldav", # Broadcast address
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
for url in special_addresses:
|
|
384
|
+
with pytest.raises(ValidationError):
|
|
385
|
+
validator.validate_url(url)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class TestBackwardCompatibility:
|
|
389
|
+
"""Test backward compatibility of the URL validation enhancement"""
|
|
390
|
+
|
|
391
|
+
def test_pattern_still_accessible(self):
|
|
392
|
+
"""Test that the URL pattern is still accessible for existing code"""
|
|
393
|
+
validator = InputValidator()
|
|
394
|
+
|
|
395
|
+
# The pattern should still exist and work
|
|
396
|
+
assert validator.PATTERNS["url"] is not None
|
|
397
|
+
assert validator.PATTERNS["url"].match("https://example.com/caldav")
|
|
398
|
+
assert not validator.PATTERNS["url"].match("http://example.com/caldav")
|
|
399
|
+
|
|
400
|
+
def test_default_ssrf_protection_enabled(self):
|
|
401
|
+
"""Test that SSRF protection is enabled by default"""
|
|
402
|
+
validator = InputValidator()
|
|
403
|
+
|
|
404
|
+
# By default, private IPs should be blocked
|
|
405
|
+
with pytest.raises(ValidationError):
|
|
406
|
+
validator.validate_url("https://192.168.1.1/caldav")
|
|
407
|
+
|
|
408
|
+
# But can be disabled for backward compatibility
|
|
409
|
+
result = validator.validate_url(
|
|
410
|
+
"https://192.168.1.1/caldav", allow_private_ips=True
|
|
411
|
+
)
|
|
412
|
+
assert result == "https://192.168.1.1/caldav"
|
|
413
|
+
|
|
414
|
+
def test_validate_url_optional_parameters(self):
|
|
415
|
+
"""Test that all parameters have sensible defaults"""
|
|
416
|
+
validator = InputValidator()
|
|
417
|
+
|
|
418
|
+
# Should work with just URL (uses defaults)
|
|
419
|
+
result = validator.validate_url("https://example.com/caldav")
|
|
420
|
+
assert result == "https://example.com/caldav"
|
|
421
|
+
|
|
422
|
+
# Can override field_name
|
|
423
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
424
|
+
validator.validate_url("invalid-url", field_name="custom_field")
|
|
425
|
+
assert "custom_field" in str(exc_info.value)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class TestRealWorldScenarios:
|
|
429
|
+
"""Test real-world CalDAV server URLs and SSRF attack vectors"""
|
|
430
|
+
|
|
431
|
+
@patch("socket.getaddrinfo")
|
|
432
|
+
def test_common_caldav_servers_allowed(self, mock_getaddrinfo):
|
|
433
|
+
"""Test that common CalDAV servers are allowed"""
|
|
434
|
+
validator = InputValidator()
|
|
435
|
+
|
|
436
|
+
# Mock public IP resolution
|
|
437
|
+
mock_getaddrinfo.return_value = [
|
|
438
|
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
caldav_urls = [
|
|
442
|
+
"https://caldav.fastmail.com/dav/calendars/user/",
|
|
443
|
+
"https://calendar.google.com/calendar/dav/",
|
|
444
|
+
"https://outlook.office365.com/owa/calendar/",
|
|
445
|
+
"https://dav.icloud.com/calendar/",
|
|
446
|
+
"https://nextcloud.example.com/remote.php/dav/calendars/",
|
|
447
|
+
"https://owncloud.example.org/remote.php/caldav/",
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
for url in caldav_urls:
|
|
451
|
+
result = validator.validate_url(url)
|
|
452
|
+
assert result == url, f"Common CalDAV URL should be allowed: {url}"
|
|
453
|
+
|
|
454
|
+
@patch("socket.getaddrinfo")
|
|
455
|
+
def test_ssrf_attack_vectors_blocked(self, mock_getaddrinfo):
|
|
456
|
+
"""Test that common SSRF attack vectors are blocked"""
|
|
457
|
+
validator = InputValidator()
|
|
458
|
+
|
|
459
|
+
# Test various SSRF attack patterns
|
|
460
|
+
attack_vectors = [
|
|
461
|
+
# Direct private IPs
|
|
462
|
+
(
|
|
463
|
+
"https://169.254.169.254/latest/meta-data/",
|
|
464
|
+
"169.254.169.254",
|
|
465
|
+
"AWS metadata",
|
|
466
|
+
),
|
|
467
|
+
("https://metadata.google.internal/", "169.254.169.254", "GCP metadata"),
|
|
468
|
+
# Encoded variations (should be caught by pattern validation)
|
|
469
|
+
# These won't match our HTTPS pattern anyway
|
|
470
|
+
]
|
|
471
|
+
|
|
472
|
+
for url, ip, description in attack_vectors:
|
|
473
|
+
mock_getaddrinfo.return_value = [
|
|
474
|
+
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 443))
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
478
|
+
validator.validate_url(url)
|
|
479
|
+
|
|
480
|
+
error_msg = str(exc_info.value)
|
|
481
|
+
assert (
|
|
482
|
+
"not allowed for security reasons" in error_msg
|
|
483
|
+
or "Unable to resolve hostname" in error_msg
|
|
484
|
+
), f"SSRF vector should be blocked: {description}"
|
|
485
|
+
|
|
486
|
+
def test_local_development_with_flag(self):
|
|
487
|
+
"""Test that local development can still work with explicit flag"""
|
|
488
|
+
validator = InputValidator()
|
|
489
|
+
|
|
490
|
+
# Local development URLs that might be legitimately used
|
|
491
|
+
local_dev_urls = [
|
|
492
|
+
"https://localhost:8443/caldav",
|
|
493
|
+
"https://127.0.0.1:3000/api/caldav",
|
|
494
|
+
"https://192.168.1.100:8080/dav",
|
|
495
|
+
"https://10.0.0.50:443/calendar",
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
for url in local_dev_urls:
|
|
499
|
+
# Blocked by default
|
|
500
|
+
with pytest.raises(ValidationError):
|
|
501
|
+
validator.validate_url(url)
|
|
502
|
+
|
|
503
|
+
# Allowed with flag for local development
|
|
504
|
+
result = validator.validate_url(url, allow_private_ips=True)
|
|
505
|
+
assert result == url, f"Should allow local URL with flag: {url}"
|