Skip to content

Commit 9a11238

Browse files
committed
fix: URL whitelist bypass via userinfo@host, scan_input mutation
- _extract_url: include :port and @userinfo in authority capture, then strip both to get bare hostname. Fixes localhost:8080@evil.com bypass. - scan(): use local effective_script_type instead of mutating scan_input. - Same fixes applied to duplicate _extract_url in _rules.py.
1 parent 276f9f8 commit 9a11238

2 files changed

Lines changed: 40 additions & 18 deletions

File tree

trpc_agent_sdk/tools/safety/_rules.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -923,19 +923,26 @@ def _path_boundary_pattern(path: str) -> str:
923923

924924

925925
def _extract_url(text: str) -> Optional[str]:
926-
"""Naive domain extractor from a line of text — used for whitelist checks."""
927-
# Match http(s)://domain or domain-like patterns after curl/wget
928-
m = re.search(r"https?://([^\s/\"':]+)", text)
926+
"""Extract a domain name from *text* for whitelist checks.
927+
928+
Strips user:pass@ prefixes so that localhost:8080@evil.com is correctly
929+
identified as evil.com rather than localhost.
930+
"""
931+
m = re.search(r"https?://([^\s/\"']+)", text)
929932
if m:
930-
return m.group(1)
931-
# Also try bare domain patterns like 'api.example.com'
932-
# Must have at least one dot separating valid TLD-like segments
933+
host = m.group(1)
934+
if "@" in host:
935+
host = host.rsplit("@", 1)[-1]
936+
if ":" in host:
937+
host = host.rsplit(":", 1)[0]
938+
return host
933939
m = re.search(r"(?:^|\s)((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})", text)
934940
if m:
935941
candidate = m.group(0).strip()
936-
# Filter out obvious false positives: Python method calls, variable names, etc.
937942
if "(" in candidate or candidate.startswith("."):
938943
return None
944+
if "@" in candidate:
945+
candidate = candidate.rsplit("@", 1)[-1]
939946
return candidate
940947
return None
941948

trpc_agent_sdk/tools/safety/_scanner.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,10 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
9191
"""
9292
t0 = time.perf_counter()
9393

94-
# Auto-detect script type if unknown
95-
if scan_input.script_type == ScriptType.UNKNOWN:
96-
scan_input.script_type = self._detect_type(scan_input.script_content)
94+
# Auto-detect script type if unknown (local copy — don't mutate the input)
95+
effective_script_type = scan_input.script_type
96+
if effective_script_type == ScriptType.UNKNOWN:
97+
effective_script_type = self._detect_type(scan_input.script_content)
9798

9899
# Build effective scan content: script + command-line args (if any)
99100
script = scan_input.script_content
@@ -160,7 +161,7 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
160161
blocklist_detail = (" Blocklist pattern matched — denied." if blocklist_hit else " Denied for safety.")
161162
return SafetyScanReport(
162163
tool_name=scan_input.tool_name,
163-
script_type=scan_input.script_type,
164+
script_type=effective_script_type,
164165
script_size_lines=script_lines,
165166
decision=Decision.DENY,
166167
risk_level=RiskLevel.CRITICAL if blocklist_hit else RiskLevel.HIGH,
@@ -177,10 +178,10 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
177178
# ══════════════════════════════════════════════════════════════
178179
# LAYER 1 & 2: AST-based Python + shlex-based Bash scanning
179180
# ══════════════════════════════════════════════════════════════
180-
if scan_input.script_type in (ScriptType.PYTHON, ScriptType.UNKNOWN):
181+
if effective_script_type in (ScriptType.PYTHON, ScriptType.UNKNOWN):
181182
all_findings.extend(self._scan_python_ast(script, scan_input))
182183

183-
if scan_input.script_type in (ScriptType.BASH, ScriptType.UNKNOWN):
184+
if effective_script_type in (ScriptType.BASH, ScriptType.UNKNOWN):
184185
all_findings.extend(self._scan_bash_tokens(script, scan_input))
185186

186187
# ══════════════════════════════════════════════════════════════
@@ -223,7 +224,7 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
223224

224225
# Apply blocklist override — blocklist patterns always → deny
225226
if decision != Decision.DENY:
226-
decision, bl_findings = self._check_blocklist_override(script, decision, scan_input.script_type)
227+
decision, bl_findings = self._check_blocklist_override(script, decision, effective_script_type)
227228
all_findings.extend(bl_findings)
228229
# Blocklist commands — also force DENY when a forbidden command literal appears
229230
if decision != Decision.DENY and self._policy.blocklist_commands:
@@ -287,7 +288,7 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
287288

288289
return SafetyScanReport(
289290
tool_name=scan_input.tool_name,
290-
script_type=scan_input.script_type,
291+
script_type=effective_script_type,
291292
script_size_lines=script_lines,
292293
decision=decision,
293294
risk_level=max_risk,
@@ -976,15 +977,29 @@ def _deduplicate_findings(findings: List[SafetyFinding]) -> List[SafetyFinding]:
976977

977978

978979
def _extract_url(text: str) -> Optional[str]:
979-
"""Naive domain extractor from a line of text — used for whitelist checks."""
980-
m = re.search(r"https?://([^\s/\"':]+)", text)
980+
"""Extract a domain name from *text* for whitelist checks.
981+
982+
Strips user:pass@ prefixes so that ``localhost:8080@evil.com`` is
983+
correctly identified as *evil.com* rather than being mistaken for a
984+
whitelisted localhost.
985+
"""
986+
# Match the full authority portion including :port and @userinfo,
987+
# then strip userinfo and port to get the bare hostname.
988+
m = re.search(r"https?://([^\s/\"']+)", text)
981989
if m:
982-
return m.group(1)
990+
host = m.group(1)
991+
if "@" in host:
992+
host = host.rsplit("@", 1)[-1]
993+
if ":" in host:
994+
host = host.rsplit(":", 1)[0]
995+
return host
983996
m = re.search(r"(?:^|\s)((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})", text)
984997
if m:
985998
candidate = m.group(0).strip()
986999
if "(" in candidate or candidate.startswith("."):
9871000
return None
1001+
if "@" in candidate:
1002+
candidate = candidate.rsplit("@", 1)[-1]
9881003
return candidate
9891004
return None
9901005

0 commit comments

Comments
 (0)