Wagtail regular expression denial-of-service via search query parsing
Description
Wagtail is an open source content management system built on Django. A bug in Wagtail's parse_query_string would result in it taking a long time to process suitably crafted inputs. When used to parse sufficiently long strings of characters without a space, parse_query_string would take an unexpectedly large amount of time to process, resulting in a denial of service. In an initial Wagtail installation, the vulnerability can be exploited by any Wagtail admin user. It cannot be exploited by end users. If your Wagtail site has a custom search implementation which uses parse_query_string, it may be exploitable by other users (e.g. unauthenticated users). Patched versions have been released as Wagtail 5.2.6, 6.0.6 and 6.1.3.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
wagtailPyPI | >= 6.0, < 6.0.6 | 6.0.6 |
wagtailPyPI | >= 6.1, < 6.1.3 | 6.1.3 |
wagtailPyPI | >= 2.0, < 5.2.6 | 5.2.6 |
Affected products
1Patches
63ee28ee8b25524fb4064e5b77af87e08b9d131b1e8532dfbRequire word boundaries before search query filters (CVE-2024-39317)
2 files changed · +30 −12
wagtail/search/tests/test_queries.py+24 −0 modified@@ -258,6 +258,30 @@ def test_phrase_with_filter(self): self.assertDictEqual(filters.dict(), {"author": "foo bar", "bar": "beer"}) self.assertEqual(repr(query), repr(Phrase("hello world"))) + def test_long_queries(self): + filters, query = parse_query_string("0" * 60_000) + self.assertEqual(filters.dict(), {}) + self.assertEqual(repr(query), repr(PlainText("0" * 60_000))) + + filters, _ = parse_query_string(f'{"a" * 60_000}:"foo bar"') + self.assertEqual(filters.dict(), {"a" * 60_000: "foo bar"}) + + def test_long_filter_value(self): + filters, _ = parse_query_string(f'foo:ba{"r" * 60_000}') + self.assertEqual(filters.dict(), {"foo": f"ba{"r" * 60_000}"}) + + def test_joined_filters(self): + filters, query = parse_query_string("foo:bar:baz") + self.assertEqual(filters.dict(), {"foo": "bar"}) + self.assertEqual(repr(query), repr(PlainText(":baz"))) + + filters, query = parse_query_string("foo:'bar':baz") + self.assertEqual(filters.dict(), {"foo": "bar"}) + self.assertEqual(repr(query), repr(PlainText(":baz"))) + + filters, query = parse_query_string("foo:'bar:baz'") + self.assertEqual(filters.dict(), {"foo": "bar:baz"}) + def test_multiple_phrases(self): filters, query = parse_query_string('"hello world" "hi earth"')
wagtail/search/utils.py+6 −12 modified@@ -69,6 +69,8 @@ def balanced_reduce(operator, seq, initializer=NOT_SET): MAX_QUERY_STRING_LENGTH = 255 +filters_regexp = re.compile(r'\b(\w+):(\w+|"[^"]+"|\'[^\']+\')') + def normalise_query_string(query_string): # Truncate query string @@ -83,20 +85,12 @@ def normalise_query_string(query_string): def separate_filters_from_query(query_string): - filters_regexp = r'(\w+):(\w+|"[^"]+"|\'[^\']+\')' - filters = QueryDict(mutable=True) - for match_object in re.finditer(filters_regexp, query_string): + for match_object in filters_regexp.finditer(query_string): key, value = match_object.groups() - filters.update( - { - key: value.strip('"') - if value.strip('"') is not value - else value.strip("'") - } - ) - - query_string = re.sub(filters_regexp, "", query_string).strip() + filters.update({key: value.strip("\"'")}) + + query_string = filters_regexp.sub("", query_string).strip() return filters, query_string
b783c096b6d4Require word boundaries before search query filters (CVE-2024-39317)
2 files changed · +30 −12
wagtail/search/tests/test_queries.py+24 −0 modified@@ -171,6 +171,30 @@ def test_phrase_with_filter(self): self.assertDictEqual(filters.dict(), {"author": "foo bar", "bar": "beer"}) self.assertEqual(repr(query), repr(Phrase("hello world"))) + def test_long_queries(self): + filters, query = parse_query_string("0" * 60_000) + self.assertEqual(filters.dict(), {}) + self.assertEqual(repr(query), repr(PlainText("0" * 60_000))) + + filters, _ = parse_query_string(f'{"a" * 60_000}:"foo bar"') + self.assertEqual(filters.dict(), {"a" * 60_000: "foo bar"}) + + def test_long_filter_value(self): + filters, _ = parse_query_string(f'foo:ba{"r" * 60_000}') + self.assertEqual(filters.dict(), {"foo": f"ba{"r" * 60_000}"}) + + def test_joined_filters(self): + filters, query = parse_query_string("foo:bar:baz") + self.assertEqual(filters.dict(), {"foo": "bar"}) + self.assertEqual(repr(query), repr(PlainText(":baz"))) + + filters, query = parse_query_string("foo:'bar':baz") + self.assertEqual(filters.dict(), {"foo": "bar"}) + self.assertEqual(repr(query), repr(PlainText(":baz"))) + + filters, query = parse_query_string("foo:'bar:baz'") + self.assertEqual(filters.dict(), {"foo": "bar:baz"}) + def test_multiple_phrases(self): filters, query = parse_query_string('"hello world" "hi earth"')
wagtail/search/utils.py+6 −12 modified@@ -69,6 +69,8 @@ def balanced_reduce(operator, seq, initializer=NOT_SET): MAX_QUERY_STRING_LENGTH = 255 +filters_regexp = re.compile(r'\b(\w+):(\w+|"[^"]+"|\'[^\']+\')') + def normalise_query_string(query_string): # Truncate query string @@ -83,20 +85,12 @@ def normalise_query_string(query_string): def separate_filters_from_query(query_string): - filters_regexp = r'(\w+):(\w+|"[^"]+"|\'[^\']+\')' - filters = QueryDict(mutable=True) - for match_object in re.finditer(filters_regexp, query_string): + for match_object in filters_regexp.finditer(query_string): key, value = match_object.groups() - filters.update( - { - key: value.strip('"') - if value.strip('"') is not value - else value.strip("'") - } - ) - - query_string = re.sub(filters_regexp, "", query_string).strip() + filters.update({key: value.strip("\"'")}) + + query_string = filters_regexp.sub("", query_string).strip() return filters, query_string
3c941136f79cRequire word boundaries before search query filters (CVE-2024-39317)
2 files changed · +30 −12
wagtail/search/tests/test_queries.py+24 −0 modified@@ -171,6 +171,30 @@ def test_phrase_with_filter(self): self.assertDictEqual(filters.dict(), {"author": "foo bar", "bar": "beer"}) self.assertEqual(repr(query), repr(Phrase("hello world"))) + def test_long_queries(self): + filters, query = parse_query_string("0" * 60_000) + self.assertEqual(filters.dict(), {}) + self.assertEqual(repr(query), repr(PlainText("0" * 60_000))) + + filters, _ = parse_query_string(f'{"a" * 60_000}:"foo bar"') + self.assertEqual(filters.dict(), {"a" * 60_000: "foo bar"}) + + def test_long_filter_value(self): + filters, _ = parse_query_string(f'foo:ba{"r" * 60_000}') + self.assertEqual(filters.dict(), {"foo": f"ba{"r" * 60_000}"}) + + def test_joined_filters(self): + filters, query = parse_query_string("foo:bar:baz") + self.assertEqual(filters.dict(), {"foo": "bar"}) + self.assertEqual(repr(query), repr(PlainText(":baz"))) + + filters, query = parse_query_string("foo:'bar':baz") + self.assertEqual(filters.dict(), {"foo": "bar"}) + self.assertEqual(repr(query), repr(PlainText(":baz"))) + + filters, query = parse_query_string("foo:'bar:baz'") + self.assertEqual(filters.dict(), {"foo": "bar:baz"}) + def test_multiple_phrases(self): filters, query = parse_query_string('"hello world" "hi earth"')
wagtail/search/utils.py+6 −12 modified@@ -69,6 +69,8 @@ def balanced_reduce(operator, seq, initializer=NOT_SET): MAX_QUERY_STRING_LENGTH = 255 +filters_regexp = re.compile(r'\b(\w+):(\w+|"[^"]+"|\'[^\']+\')') + def normalise_query_string(query_string): # Truncate query string @@ -83,20 +85,12 @@ def normalise_query_string(query_string): def separate_filters_from_query(query_string): - filters_regexp = r'(\w+):(\w+|"[^"]+"|\'[^\']+\')' - filters = QueryDict(mutable=True) - for match_object in re.finditer(filters_regexp, query_string): + for match_object in filters_regexp.finditer(query_string): key, value = match_object.groups() - filters.update( - { - key: value.strip('"') - if value.strip('"') is not value - else value.strip("'") - } - ) - - query_string = re.sub(filters_regexp, "", query_string).strip() + filters.update({key: value.strip("\"'")}) + + query_string = filters_regexp.sub("", query_string).strip() return filters, query_string
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-jmp3-39vp-fwg8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-39317ghsaADVISORY
- github.com/pypa/advisory-database/tree/main/vulns/wagtail/PYSEC-2024-86.yamlghsaWEB
- github.com/wagtail/wagtail/commit/31b1e8532dfb1b70d8d37d22aff9cbde9109cdf2ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/commit/3c941136f79c48446e3858df46e5b668d7f83797ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/commit/b783c096b6d4fd2cfc05f9137a0be288850e99a2ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/security/advisories/GHSA-jmp3-39vp-fwg8ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.