java-topology/defects/salt/patch/salt-0004-pcre-redos-safe-wrapper.patch
russell@unturf.com f74250d8a3 feat: add UNDF-2026-000001266 (salt-0004) and UNDF-2026-000001267 (ansible-0004) CWE-1333
Salt pcre/grain_pcre targeting passes user-controlled regex to re.match/re.compile
without timeout. Ansible inventory manager passes tilde-prefixed host patterns
directly to re.compile. Both fixed with ThreadPoolExecutor 1s timeout wrapper.
2026-04-13 09:56:20 -04:00

117 lines
4 KiB
Diff

# UNDF: UNDF-2026-000001266
# CWE-1333: Inefficient Regular Expression Complexity. ReDoS via user-controlled grain_pcre pattern.
#
# Defect: re.match/re.compile receives tgt from job payload when tgt_type=pcre or grain_pcre.
# A crafted pattern like ^(a+)+$ against adversarial input causes exponential backtracking.
# Attack vector: authenticated Salt API caller POST /run with tgt_type=pcre.
# Stalls the master process indefinitely.
#
# Fix: route re.match/re.compile through a ThreadPoolExecutor worker with 1s timeout.
# Fail-closed. timeout or re.error returns False (minion excluded from target).
#
# Complexity gate: pattern ^(a+)+$ against input 'a'*25+'b' must complete in <3s (vs infinite without fix).
--- a/salt/matchers/pcre_match.py
+++ b/salt/matchers/pcre_match.py
@@ -1,17 +1,56 @@
"""
This is the default pcre matcher.
"""
import re
+import concurrent.futures
+import logging
+
+log = logging.getLogger(__name__)
+
+_PCRE_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=4)
+_PCRE_TIMEOUT = 1.0 # seconds
+
+
+def _safe_pcre_match(pattern, text, timeout=_PCRE_TIMEOUT):
+ """
+ Run re.match(pattern, text) in a worker thread with a timeout.
+ Returns the match object on success, False on timeout or re.error.
+ Fail-closed: a pattern that stalls or fails to compile returns False.
+ """
+ try:
+ future = _PCRE_EXECUTOR.submit(re.match, pattern, text)
+ return future.result(timeout=timeout)
+ except concurrent.futures.TimeoutError:
+ log.warning(
+ "pcre match timed out after %ss: pattern=%r target=%r",
+ timeout, pattern, text
+ )
+ return False
+ except re.error as exc:
+ log.warning("invalid pcre pattern %r: %s", pattern, exc)
+ return False
+ except Exception as exc: # pylint: disable=broad-except
+ log.warning("pcre match error pattern=%r: %s", pattern, exc)
+ return False
def match(tgt, opts=None, minion_id=None):
"""
Returns true if the passed pcre regex matches
"""
if not opts:
opts = __opts__
if not minion_id:
minion_id = opts.get("id")
- return bool(re.match(tgt, minion_id))
+ return bool(_safe_pcre_match(tgt, minion_id))
--- a/salt/utils/minions.py
+++ b/salt/utils/minions.py
@@ -1,6 +1,8 @@
import re
import fnmatch
import logging
+import concurrent.futures
+from salt.matchers.pcre_match import _safe_pcre_match, _PCRE_EXECUTOR, _PCRE_TIMEOUT
@@ -320,11 +322,17 @@ class CkMinions:
def _check_pcre_minions(
self, expr, greedy, minions=None
): # pylint: disable=unused-argument
"""
Return the minions found by looking via regular expressions
"""
- reg = re.compile(expr)
-
if not minions:
minions = self._pki_minions()
- return {
- "minions": [m for m in minions if reg.match(m)],
- "missing": [],
- }
+ try:
+ future = _PCRE_EXECUTOR.submit(re.compile, expr)
+ reg = future.result(timeout=_PCRE_TIMEOUT)
+ except concurrent.futures.TimeoutError:
+ log.warning(
+ "pcre compile timed out after %ss: expr=%r", _PCRE_TIMEOUT, expr
+ )
+ return {"minions": [], "missing": []}
+ except re.error as exc:
+ log.warning("invalid pcre expr %r: %s", expr, exc)
+ return {"minions": [], "missing": []}
+
+ return {
+ "minions": [m for m in minions if _safe_pcre_match(expr, m)],
+ "missing": [],
+ }
--- a/salt/utils/data.py
+++ b/salt/utils/data.py
@@ -1,5 +1,6 @@
import re
import fnmatch
+from salt.matchers.pcre_match import _safe_pcre_match
@@ -145,9 +146,9 @@ def subdict_match(
if regex_match:
try:
- return re.match(pattern, target)
+ return _safe_pcre_match(pattern, target)
except Exception: # pylint: disable=broad-except
log.error("Invalid regex '%s' in match", pattern)
return False