VYPR
Moderate severityNVD Advisory· Published May 11, 2021· Updated Aug 3, 2024

Denial of service in Matrix Synapse

CVE-2021-29471

Description

Synapse is a Matrix reference homeserver written in python (pypi package matrix-synapse). Matrix is an ecosystem for open federated Instant Messaging and VoIP. In Synapse before version 1.33.2 "Push rules" can specify conditions under which they will match, including event_match, which matches event content against a pattern including wildcards. Certain patterns can cause very poor performance in the matching engine, leading to a denial-of-service when processing moderate length events. The issue is patched in version 1.33.2. A potential workaround might be to prevent users from making custom push rules, by blocking such requests at a reverse-proxy.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

In Synapse <1.33.2, crafted push rule patterns cause catastrophic backtracking, leading to denial-of-service when processing moderate-length events.

Vulnerability

In Synapse before version 1.33.2, the event_match condition in push rules uses a pattern-matching engine that can suffer from catastrophic backtracking when certain wildcard patterns are provided. This allows an attacker to craft a push rule with a pattern (e.g., containing ? and * wildcards) that causes very poor performance when the server evaluates it against event content of moderate length. The issue is present in the push rule evaluator, specifically in the event_match handling within synapse.push.push_rule_evaluator [1][3].

Exploitation

An attacker needs to be able to create custom push rules on the homeserver, which is a normal user-level action in Matrix. The attacker creates a push rule with a malicious pattern designed to trigger exponential backtracking in the regex-like matching engine. When the server processes an event (e.g., a message) whose content matches the attacker's pattern in a way that causes the engine to explore many matching paths, CPU usage spikes, leading to denial-of-service for the homeserver [1]. No special network position or authentication beyond a valid user account is required.

Impact

Successful exploitation results in denial-of-service (DoS) against the Synapse homeserver, making it unresponsive or slow to process legitimate events. The impact is limited to CPU exhaustion; there is no evidence of information disclosure, privilege escalation, or remote code execution. The attack requires only a valid user account and can be repeated to maintain DoS [1].

Mitigation

The issue is patched in Synapse version 1.33.2, released May 11, 2021 [1][4]. Administrators should upgrade to this version or later. If upgrade is not immediately possible, a workaround is to prevent users from making custom push rules by blocking such requests at a reverse proxy, though this limits normal functionality [1].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
matrix-synapsePyPI
< 1.33.21.33.2

Affected products

3

Patches

1
03318a766cac

Merge pull request from GHSA-x345-32rc-8h85

https://github.com/matrix-org/synapseRichard van der HoffMay 11, 2021via ghsa
6 files changed · +296 68
  • synapse/config/tls.py+2 2 modified
    @@ -17,7 +17,7 @@
     import warnings
     from datetime import datetime
     from hashlib import sha256
    -from typing import List, Optional
    +from typing import List, Optional, Pattern
     
     from unpaddedbase64 import encode_base64
     
    @@ -124,7 +124,7 @@ def read_config(self, config: dict, config_dir_path: str, **kwargs):
                 fed_whitelist_entries = []
     
             # Support globs (*) in whitelist values
    -        self.federation_certificate_verification_whitelist = []  # type: List[str]
    +        self.federation_certificate_verification_whitelist = []  # type: List[Pattern]
             for entry in fed_whitelist_entries:
                 try:
                     entry_regex = glob_to_regex(entry.encode("ascii").decode("ascii"))
    
  • synapse/push/push_rule_evaluator.py+3 52 modified
    @@ -19,6 +19,7 @@
     
     from synapse.events import EventBase
     from synapse.types import UserID
    +from synapse.util import glob_to_regex, re_word_boundary
     from synapse.util.caches.lrucache import LruCache
     
     logger = logging.getLogger(__name__)
    @@ -183,7 +184,7 @@ def _contains_display_name(self, display_name: str) -> bool:
             r = regex_cache.get((display_name, False, True), None)
             if not r:
                 r1 = re.escape(display_name)
    -            r1 = _re_word_boundary(r1)
    +            r1 = re_word_boundary(r1)
                 r = re.compile(r1, flags=re.IGNORECASE)
                 regex_cache[(display_name, False, True)] = r
     
    @@ -212,64 +213,14 @@ def _glob_matches(glob: str, value: str, word_boundary: bool = False) -> bool:
         try:
             r = regex_cache.get((glob, True, word_boundary), None)
             if not r:
    -            r = _glob_to_re(glob, word_boundary)
    +            r = glob_to_regex(glob, word_boundary)
                 regex_cache[(glob, True, word_boundary)] = r
             return bool(r.search(value))
         except re.error:
             logger.warning("Failed to parse glob to regex: %r", glob)
             return False
     
     
    -def _glob_to_re(glob: str, word_boundary: bool) -> Pattern:
    -    """Generates regex for a given glob.
    -
    -    Args:
    -        glob
    -        word_boundary: Whether to match against word boundaries or entire string.
    -    """
    -    if IS_GLOB.search(glob):
    -        r = re.escape(glob)
    -
    -        r = r.replace(r"\*", ".*?")
    -        r = r.replace(r"\?", ".")
    -
    -        # handle [abc], [a-z] and [!a-z] style ranges.
    -        r = GLOB_REGEX.sub(
    -            lambda x: (
    -                "[%s%s]" % (x.group(1) and "^" or "", x.group(2).replace(r"\\\-", "-"))
    -            ),
    -            r,
    -        )
    -        if word_boundary:
    -            r = _re_word_boundary(r)
    -
    -            return re.compile(r, flags=re.IGNORECASE)
    -        else:
    -            r = "^" + r + "$"
    -
    -            return re.compile(r, flags=re.IGNORECASE)
    -    elif word_boundary:
    -        r = re.escape(glob)
    -        r = _re_word_boundary(r)
    -
    -        return re.compile(r, flags=re.IGNORECASE)
    -    else:
    -        r = "^" + re.escape(glob) + "$"
    -        return re.compile(r, flags=re.IGNORECASE)
    -
    -
    -def _re_word_boundary(r: str) -> str:
    -    """
    -    Adds word boundary characters to the start and end of an
    -    expression to require that the match occur as a whole word,
    -    but do so respecting the fact that strings starting or ending
    -    with non-word characters will change word boundaries.
    -    """
    -    # we can't use \b as it chokes on unicode. however \W seems to be okay
    -    # as shorthand for [^0-9A-Za-z_].
    -    return r"(^|\W)%s(\W|$)" % (r,)
    -
    -
     def _flatten_dict(
         d: Union[EventBase, dict],
         prefix: Optional[List[str]] = None,
    
  • synapse/util/__init__.py+47 14 modified
    @@ -15,6 +15,7 @@
     import json
     import logging
     import re
    +from typing import Pattern
     
     import attr
     from frozendict import frozendict
    @@ -26,6 +27,9 @@
     logger = logging.getLogger(__name__)
     
     
    +_WILDCARD_RUN = re.compile(r"([\?\*]+)")
    +
    +
     def _reject_invalid_json(val):
         """Do not allow Infinity, -Infinity, or NaN values in JSON."""
         raise ValueError("Invalid JSON value: '%s'" % val)
    @@ -158,25 +162,54 @@ def log_failure(failure, msg, consumeErrors=True):
             return failure
     
     
    -def glob_to_regex(glob):
    +def glob_to_regex(glob: str, word_boundary: bool = False) -> Pattern:
         """Converts a glob to a compiled regex object.
     
    -    The regex is anchored at the beginning and end of the string.
    -
         Args:
    -        glob (str)
    +        glob: pattern to match
    +        word_boundary: If True, the pattern will be allowed to match at word boundaries
    +           anywhere in the string. Otherwise, the pattern is anchored at the start and
    +           end of the string.
     
         Returns:
    -        re.RegexObject
    +        compiled regex pattern
         """
    -    res = ""
    -    for c in glob:
    -        if c == "*":
    -            res = res + ".*"
    -        elif c == "?":
    -            res = res + "."
    +
    +    # Patterns with wildcards must be simplified to avoid performance cliffs
    +    # - The glob `?**?**?` is equivalent to the glob `???*`
    +    # - The glob `???*` is equivalent to the regex `.{3,}`
    +    chunks = []
    +    for chunk in _WILDCARD_RUN.split(glob):
    +        # No wildcards? re.escape()
    +        if not _WILDCARD_RUN.match(chunk):
    +            chunks.append(re.escape(chunk))
    +            continue
    +
    +        # Wildcards? Simplify.
    +        qmarks = chunk.count("?")
    +        if "*" in chunk:
    +            chunks.append(".{%d,}" % qmarks)
             else:
    -            res = res + re.escape(c)
    +            chunks.append(".{%d}" % qmarks)
    +
    +    res = "".join(chunks)
     
    -    # \A anchors at start of string, \Z at end of string
    -    return re.compile(r"\A" + res + r"\Z", re.IGNORECASE)
    +    if word_boundary:
    +        res = re_word_boundary(res)
    +    else:
    +        # \A anchors at start of string, \Z at end of string
    +        res = r"\A" + res + r"\Z"
    +
    +    return re.compile(res, re.IGNORECASE)
    +
    +
    +def re_word_boundary(r: str) -> str:
    +    """
    +    Adds word boundary characters to the start and end of an
    +    expression to require that the match occur as a whole word,
    +    but do so respecting the fact that strings starting or ending
    +    with non-word characters will change word boundaries.
    +    """
    +    # we can't use \b as it chokes on unicode. however \W seems to be okay
    +    # as shorthand for [^0-9A-Za-z_].
    +    return r"(^|\W)%s(\W|$)" % (r,)
    
  • tests/federation/test_federation_server.py+19 0 modified
    @@ -74,6 +74,25 @@ def test_block_ip_literals(self):
             self.assertFalse(server_matches_acl_event("[1:2::]", e))
             self.assertTrue(server_matches_acl_event("1:2:3:4", e))
     
    +    def test_wildcard_matching(self):
    +        e = _create_acl_event({"allow": ["good*.com"]})
    +        self.assertTrue(
    +            server_matches_acl_event("good.com", e),
    +            "* matches 0 characters",
    +        )
    +        self.assertTrue(
    +            server_matches_acl_event("GOOD.COM", e),
    +            "pattern is case-insensitive",
    +        )
    +        self.assertTrue(
    +            server_matches_acl_event("good.aa.com", e),
    +            "* matches several characters, including '.'",
    +        )
    +        self.assertFalse(
    +            server_matches_acl_event("ishgood.com", e),
    +            "pattern does not allow prefixes",
    +        )
    +
     
     class StateQueryTests(unittest.FederatingHomeserverTestCase):
     
    
  • tests/push/test_push_rule_evaluator.py+166 0 modified
    @@ -12,6 +12,8 @@
     # See the License for the specific language governing permissions and
     # limitations under the License.
     
    +from typing import Any, Dict
    +
     from synapse.api.room_versions import RoomVersions
     from synapse.events import FrozenEvent
     from synapse.push import push_rule_evaluator
    @@ -66,6 +68,170 @@ def test_display_name(self):
             # A display name with spaces should work fine.
             self.assertTrue(evaluator.matches(condition, "@user:test", "foo bar"))
     
    +    def _assert_matches(
    +        self, condition: Dict[str, Any], content: Dict[str, Any], msg=None
    +    ) -> None:
    +        evaluator = self._get_evaluator(content)
    +        self.assertTrue(evaluator.matches(condition, "@user:test", "display_name"), msg)
    +
    +    def _assert_not_matches(
    +        self, condition: Dict[str, Any], content: Dict[str, Any], msg=None
    +    ) -> None:
    +        evaluator = self._get_evaluator(content)
    +        self.assertFalse(
    +            evaluator.matches(condition, "@user:test", "display_name"), msg
    +        )
    +
    +    def test_event_match_body(self):
    +        """Check that event_match conditions on content.body work as expected"""
    +
    +        # if the key is `content.body`, the pattern matches substrings.
    +
    +        # non-wildcards should match
    +        condition = {
    +            "kind": "event_match",
    +            "key": "content.body",
    +            "pattern": "foobaz",
    +        }
    +        self._assert_matches(
    +            condition,
    +            {"body": "aaa FoobaZ zzz"},
    +            "patterns should match and be case-insensitive",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"body": "aa xFoobaZ yy"},
    +            "pattern should only match at word boundaries",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"body": "aa foobazx yy"},
    +            "pattern should only match at word boundaries",
    +        )
    +
    +        # wildcards should match
    +        condition = {
    +            "kind": "event_match",
    +            "key": "content.body",
    +            "pattern": "f?o*baz",
    +        }
    +
    +        self._assert_matches(
    +            condition,
    +            {"body": "aaa FoobarbaZ zzz"},
    +            "* should match string and pattern should be case-insensitive",
    +        )
    +        self._assert_matches(
    +            condition, {"body": "aa foobaz yy"}, "* should match 0 characters"
    +        )
    +        self._assert_not_matches(
    +            condition, {"body": "aa fobbaz yy"}, "? should not match 0 characters"
    +        )
    +        self._assert_not_matches(
    +            condition, {"body": "aa fiiobaz yy"}, "? should not match 2 characters"
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"body": "aa xfooxbaz yy"},
    +            "pattern should only match at word boundaries",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"body": "aa fooxbazx yy"},
    +            "pattern should only match at word boundaries",
    +        )
    +
    +        # test backslashes
    +        condition = {
    +            "kind": "event_match",
    +            "key": "content.body",
    +            "pattern": r"f\oobaz",
    +        }
    +        self._assert_matches(
    +            condition,
    +            {"body": r"F\oobaz"},
    +            "backslash should match itself",
    +        )
    +        condition = {
    +            "kind": "event_match",
    +            "key": "content.body",
    +            "pattern": r"f\?obaz",
    +        }
    +        self._assert_matches(
    +            condition,
    +            {"body": r"F\oobaz"},
    +            r"? after \ should match any character",
    +        )
    +
    +    def test_event_match_non_body(self):
    +        """Check that event_match conditions on other keys work as expected"""
    +
    +        # if the key is anything other than 'content.body', the pattern must match the
    +        # whole value.
    +
    +        # non-wildcards should match
    +        condition = {
    +            "kind": "event_match",
    +            "key": "content.value",
    +            "pattern": "foobaz",
    +        }
    +        self._assert_matches(
    +            condition,
    +            {"value": "FoobaZ"},
    +            "patterns should match and be case-insensitive",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"value": "xFoobaZ"},
    +            "pattern should only match at the start/end of the value",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"value": "FoobaZz"},
    +            "pattern should only match at the start/end of the value",
    +        )
    +
    +        # wildcards should match
    +        condition = {
    +            "kind": "event_match",
    +            "key": "content.value",
    +            "pattern": "f?o*baz",
    +        }
    +        self._assert_matches(
    +            condition,
    +            {"value": "FoobarbaZ"},
    +            "* should match string and pattern should be case-insensitive",
    +        )
    +        self._assert_matches(
    +            condition, {"value": "foobaz"}, "* should match 0 characters"
    +        )
    +        self._assert_not_matches(
    +            condition, {"value": "fobbaz"}, "? should not match 0 characters"
    +        )
    +        self._assert_not_matches(
    +            condition, {"value": "fiiobaz"}, "? should not match 2 characters"
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"value": "xfooxbaz"},
    +            "pattern should only match at the start/end of the value",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"value": "fooxbazx"},
    +            "pattern should only match at the start/end of the value",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"value": "x\nfooxbaz"},
    +            "pattern should not match after a newline",
    +        )
    +        self._assert_not_matches(
    +            condition,
    +            {"value": "fooxbaz\nx"},
    +            "pattern should not match before a newline",
    +        )
    +
         def test_no_body(self):
             """Not having a body shouldn't break the evaluator."""
             evaluator = self._get_evaluator({})
    
  • tests/util/test_glob_to_regex.py+59 0 added
    @@ -0,0 +1,59 @@
    +# Copyright 2021 The Matrix.org Foundation C.I.C.
    +#
    +# Licensed under the Apache License, Version 2.0 (the "License");
    +# you may not use this file except in compliance with the License.
    +# You may obtain a copy of the License at
    +#
    +#     http://www.apache.org/licenses/LICENSE-2.0
    +#
    +# Unless required by applicable law or agreed to in writing, software
    +# distributed under the License is distributed on an "AS IS" BASIS,
    +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +# See the License for the specific language governing permissions and
    +# limitations under the License.
    +from synapse.util import glob_to_regex
    +
    +from tests.unittest import TestCase
    +
    +
    +class GlobToRegexTestCase(TestCase):
    +    def test_literal_match(self):
    +        """patterns without wildcards should match"""
    +        pat = glob_to_regex("foobaz")
    +        self.assertTrue(
    +            pat.match("FoobaZ"), "patterns should match and be case-insensitive"
    +        )
    +        self.assertFalse(
    +            pat.match("x foobaz"), "pattern should not match at word boundaries"
    +        )
    +
    +    def test_wildcard_match(self):
    +        pat = glob_to_regex("f?o*baz")
    +
    +        self.assertTrue(
    +            pat.match("FoobarbaZ"),
    +            "* should match string and pattern should be case-insensitive",
    +        )
    +        self.assertTrue(pat.match("foobaz"), "* should match 0 characters")
    +        self.assertFalse(pat.match("fooxaz"), "the character after * must match")
    +        self.assertFalse(pat.match("fobbaz"), "? should not match 0 characters")
    +        self.assertFalse(pat.match("fiiobaz"), "? should not match 2 characters")
    +
    +    def test_multi_wildcard(self):
    +        """patterns with multiple wildcards in a row should match"""
    +        pat = glob_to_regex("**baz")
    +        self.assertTrue(pat.match("agsgsbaz"), "** should match any string")
    +        self.assertTrue(pat.match("baz"), "** should match the empty string")
    +        self.assertEqual(pat.pattern, r"\A.{0,}baz\Z")
    +
    +        pat = glob_to_regex("*?baz")
    +        self.assertTrue(pat.match("agsgsbaz"), "*? should match any string")
    +        self.assertTrue(pat.match("abaz"), "*? should match a single char")
    +        self.assertFalse(pat.match("baz"), "*? should not match the empty string")
    +        self.assertEqual(pat.pattern, r"\A.{1,}baz\Z")
    +
    +        pat = glob_to_regex("a?*?*?baz")
    +        self.assertTrue(pat.match("a g baz"), "?*?*? should match 3 chars")
    +        self.assertFalse(pat.match("a..baz"), "?*?*? should not match 2 chars")
    +        self.assertTrue(pat.match("a.gg.baz"), "?*?*? should match 4 chars")
    +        self.assertEqual(pat.pattern, r"\Aa.{3,}baz\Z")
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.