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

1"""URL encoding utilities for Redis connection strings. 

2 

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. 

6 

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'" 

12 

13The password "Du0PmDvmqDWqDTgfGnmi6/SKyuQydi3z7cPTgEQoE+s=" contained unencoded 

14/ and + characters, causing the URL parser to misinterpret the password as 

15host/port components. 

16 

17Solution: 

18--------- 

19Percent-encode all special characters in the password component: 

20- / → %2F 

21- + → %2B 

22- = → %3D 

23- @ → %40 

24- etc. 

25 

26References: 

27----------- 

28- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax 

29- Redis URL format: redis://[user]:[password]@[host]:[port]/[database] 

30""" 

31 

32from urllib.parse import quote, unquote 

33 

34 

35def ensure_redis_password_encoded(redis_url: str) -> str: 

36 """Ensure Redis password in URL is properly percent-encoded per RFC 3986. 

37 

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.). 

41 

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). 

44 

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) 

52 

53 Returns: 

54 URL with properly percent-encoded password component. All other 

55 components (scheme, host, port, database) remain unchanged. 

56 

57 Examples: 

58 >>> ensure_redis_password_encoded("redis://:pass/word@localhost:6379/1") 

59 'redis://:pass%2Fword@localhost:6379/1' 

60 

61 >>> ensure_redis_password_encoded("redis://:p+w=d@host:6379/0") 

62 'redis://:p%2Bw%3Dd@host:6379/0' 

63 

64 >>> ensure_redis_password_encoded("redis://localhost:6379/1") 

65 'redis://localhost:6379/1' 

66 

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 @ 

79 

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 

85 

86 # Split on scheme 

87 if not redis_url.startswith("redis://"): 

88 return redis_url 

89 

90 after_scheme = redis_url[8:] # Remove 'redis://' 

91 

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 

98 

99 credentials = after_scheme[:at_pos] 

100 rest = after_scheme[at_pos + 1 :] # Everything after @ 

101 

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 

106 

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 :] 

112 

113 if not password: 

114 # Empty password 

115 return redis_url 

116 

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="") 

129 

130 # Reconstruct URL 

131 if username: 

132 return f"redis://{username}:{encoded_password}@{rest}" 

133 else: 

134 return f"redis://:{encoded_password}@{rest}"