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.
Files changed (68) hide show
  1. chronos_mcp/__init__.py +5 -0
  2. chronos_mcp/__main__.py +9 -0
  3. chronos_mcp/accounts.py +410 -0
  4. chronos_mcp/bulk.py +946 -0
  5. chronos_mcp/caldav_utils.py +149 -0
  6. chronos_mcp/calendars.py +204 -0
  7. chronos_mcp/config.py +187 -0
  8. chronos_mcp/credentials.py +190 -0
  9. chronos_mcp/events.py +515 -0
  10. chronos_mcp/exceptions.py +477 -0
  11. chronos_mcp/journals.py +477 -0
  12. chronos_mcp/logging_config.py +23 -0
  13. chronos_mcp/models.py +202 -0
  14. chronos_mcp/py.typed +0 -0
  15. chronos_mcp/rrule.py +259 -0
  16. chronos_mcp/search.py +315 -0
  17. chronos_mcp/server.py +121 -0
  18. chronos_mcp/tasks.py +518 -0
  19. chronos_mcp/tools/__init__.py +29 -0
  20. chronos_mcp/tools/accounts.py +151 -0
  21. chronos_mcp/tools/base.py +59 -0
  22. chronos_mcp/tools/bulk.py +557 -0
  23. chronos_mcp/tools/calendars.py +142 -0
  24. chronos_mcp/tools/events.py +698 -0
  25. chronos_mcp/tools/journals.py +310 -0
  26. chronos_mcp/tools/tasks.py +414 -0
  27. chronos_mcp/utils.py +163 -0
  28. chronos_mcp/validation.py +636 -0
  29. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/METADATA +299 -0
  30. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/RECORD +68 -0
  31. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/WHEEL +5 -0
  32. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/entry_points.txt +2 -0
  33. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/licenses/LICENSE +21 -0
  34. iflow_mcp_democratize_technology_chronos_mcp-2.0.0.dist-info/top_level.txt +2 -0
  35. tests/__init__.py +0 -0
  36. tests/conftest.py +91 -0
  37. tests/unit/__init__.py +0 -0
  38. tests/unit/test_accounts.py +380 -0
  39. tests/unit/test_accounts_ssrf.py +134 -0
  40. tests/unit/test_base.py +135 -0
  41. tests/unit/test_bulk.py +380 -0
  42. tests/unit/test_bulk_create.py +408 -0
  43. tests/unit/test_bulk_delete.py +341 -0
  44. tests/unit/test_bulk_resource_limits.py +74 -0
  45. tests/unit/test_caldav_utils.py +300 -0
  46. tests/unit/test_calendars.py +286 -0
  47. tests/unit/test_config.py +111 -0
  48. tests/unit/test_config_validation.py +128 -0
  49. tests/unit/test_credentials_security.py +189 -0
  50. tests/unit/test_cryptography_security.py +178 -0
  51. tests/unit/test_events.py +536 -0
  52. tests/unit/test_exceptions.py +58 -0
  53. tests/unit/test_journals.py +1097 -0
  54. tests/unit/test_models.py +95 -0
  55. tests/unit/test_race_conditions.py +202 -0
  56. tests/unit/test_recurring_events.py +156 -0
  57. tests/unit/test_rrule.py +217 -0
  58. tests/unit/test_search.py +372 -0
  59. tests/unit/test_search_advanced.py +333 -0
  60. tests/unit/test_server_input_validation.py +219 -0
  61. tests/unit/test_ssrf_protection.py +505 -0
  62. tests/unit/test_tasks.py +918 -0
  63. tests/unit/test_thread_safety.py +301 -0
  64. tests/unit/test_tools_journals.py +617 -0
  65. tests/unit/test_tools_tasks.py +968 -0
  66. tests/unit/test_url_validation_security.py +234 -0
  67. tests/unit/test_utils.py +180 -0
  68. 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}"