Coverage for src / mcp_server_langgraph / core / url_utils.py: 85%
27 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
1"""URL encoding utilities for Redis connection strings.
3This module provides utilities to ensure Redis connection URLs have properly
4percent-encoded passwords per RFC 3986, preventing parsing errors when passwords
5contain special characters like /, +, =, etc.
7Background:
8-----------
9Production incident with revision 758b8f744 where unencoded special characters
10in Redis password caused ValueError during redis.connection.parse_url():
11"Port could not be cast to integer value as 'Du0PmDvmqDWqDTgfGnmi6'"
13The password "Du0PmDvmqDWqDTgfGnmi6/SKyuQydi3z7cPTgEQoE+s=" contained unencoded
14/ and + characters, causing the URL parser to misinterpret the password as
15host/port components.
17Solution:
18---------
19Percent-encode all special characters in the password component:
20- / → %2F
21- + → %2B
22- = → %3D
23- @ → %40
24- etc.
26References:
27-----------
28- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax
29- Redis URL format: redis://[user]:[password]@[host]:[port]/[database]
30"""
32from urllib.parse import quote, unquote
35def ensure_redis_password_encoded(redis_url: str) -> str:
36 """Ensure Redis password in URL is properly percent-encoded per RFC 3986.
38 This function provides defense-in-depth URL encoding for Redis connection
39 strings. It handles cases where passwords contain special characters that
40 would otherwise break URL parsing (/, +, =, @, etc.).
42 The function is idempotent - running it multiple times on the same URL
43 produces the same result (already-encoded passwords are not double-encoded).
45 Args:
46 redis_url: Redis connection URL in format:
47 redis://[username]:[password]@[host]:[port]/[database]
48 Examples:
49 - redis://:mypassword@localhost:6379/1
50 - redis://user:pass@redis-host:6380/0
51 - redis://localhost:6379/1 (no auth)
53 Returns:
54 URL with properly percent-encoded password component. All other
55 components (scheme, host, port, database) remain unchanged.
57 Examples:
58 >>> ensure_redis_password_encoded("redis://:pass/word@localhost:6379/1")
59 'redis://:pass%2Fword@localhost:6379/1'
61 >>> ensure_redis_password_encoded("redis://:p+w=d@host:6379/0")
62 'redis://:p%2Bw%3Dd@host:6379/0'
64 >>> ensure_redis_password_encoded("redis://localhost:6379/1")
65 'redis://localhost:6379/1'
67 Notes:
68 - Uses empty safe='' parameter to urllib.parse.quote to encode ALL
69 special characters, ensuring maximum compatibility
70 - Preserves username if present
71 - Does not modify URLs without passwords
72 - Handles edge cases: empty password, no auth, etc.
73 - Uses regex for manual parsing because urlparse() fails on
74 unencoded special characters in passwords
75 """
76 # Redis URL pattern: redis://[username]:[password]@host:port/database
77 # We need to manually parse this because urlparse() fails when password
78 # contains unencoded special characters like / or @
80 # Strategy: Find the LAST @ in the URL (before any # fragment)
81 # This @ separates credentials from host, even if @ appears in password
82 if "@" not in redis_url:
83 # No authentication
84 return redis_url
86 # Split on scheme
87 if not redis_url.startswith("redis://"):
88 return redis_url
90 after_scheme = redis_url[8:] # Remove 'redis://'
92 # Find the LAST @ in the entire string (handles @ in password)
93 # The rightmost @ is the delimiter between auth and host
94 at_pos = after_scheme.rfind("@")
95 if at_pos == -1: 95 ↛ 97line 95 didn't jump to line 97 because the condition on line 95 was never true
96 # No @ found (shouldn't happen given earlier check, but defensive)
97 return redis_url
99 credentials = after_scheme[:at_pos]
100 rest = after_scheme[at_pos + 1 :] # Everything after @
102 # Parse credentials as username:password or :password
103 if ":" not in credentials: 103 ↛ 105line 103 didn't jump to line 105 because the condition on line 103 was never true
104 # Just username, no password (unusual for Redis but valid)
105 return redis_url
107 # Split on FIRST : to separate username from password
108 # (password might contain : as well)
109 colon_pos = credentials.find(":")
110 username = credentials[:colon_pos] if colon_pos > 0 else ""
111 password = credentials[colon_pos + 1 :]
113 if not password:
114 # Empty password
115 return redis_url
117 # Check if password is already encoded (idempotent behavior)
118 # If unquote changes the string, it was encoded; if not, it's raw
119 # We'll encode it regardless, but first decode if already encoded
120 # to avoid double-encoding
121 try:
122 # Try to decode the password
123 decoded_password = unquote(password)
124 # Now encode it properly (whether it was encoded or not)
125 encoded_password = quote(decoded_password, safe="")
126 except Exception:
127 # If decoding fails, just encode as-is
128 encoded_password = quote(password, safe="")
130 # Reconstruct URL
131 if username:
132 return f"redis://{username}:{encoded_password}@{rest}"
133 else:
134 return f"redis://:{encoded_password}@{rest}"