Building patterns safely at runtime

re · Expert · 10 min

Unescaped input in a pattern is a correctness bug at best and a denial-of-service hole at worst.

Patterns often come from data: a search box, a list of banned words, configurable rules. Three things go wrong.

Escaping. User text like "1+1" or "C++" contains metacharacters. re.escape(s) backslash-escapes everything special, so the text is matched literally. Never interpolate raw input into a pattern; otherwise a user can type (a+)+$ and trigger catastrophic backtracking (regex injection).

Alternation order. When joining many words with |, the engine takes the first alternative that matches, not the longest. Sort by length descending (sorted(words, key=len, reverse=True)) so "new york" wins over "new". Wrap the whole alternation in a group with boundaries: rf"\b(?:{alts})\b".

Scale. An alternation of 10,000 words is compiled into a branch the engine tries one by one at every position. For big vocabularies, it's faster to tokenize the text with a simple pattern (\w+) and look each token up in a set, or to use a dedicated algorithm (Aho-Corasick).

In replacement strings, escape backslashes too (repl.replace("\\", "\\\\")), or pass a function that returns the literal text.

If users really need to supply patterns, cap their length, compile them with a try/except for re.error, and run matches against bounded input, ideally in a linear-time engine.

Example

import re

query = "C++ (beta)"
print(re.escape(query))                    # C\+\+\ \(beta\)
print(bool(re.search(re.escape(query), "Using C++ (beta) today")))  # True

cities = ["new", "new york", "york"]
naive = re.compile(r"\b(?:" + "|".join(map(re.escape, cities)) + r")\b")
print(naive.findall("I love new york"))    # ['new', 'york']
ordered = sorted(cities, key=len, reverse=True)
better = re.compile(r"\b(?:" + "|".join(map(re.escape, ordered)) + r")\b")
print(better.findall("I love new york"))   # ['new york']

banned = {"spam", "scam", "fraud"}
words = re.findall(r"\w+", "This is not a SCAM, honest".lower())
print([w for w in words if w in banned])   # ['scam']

try:
    re.compile("(unclosed")
except re.error as e:
    print(e.msg)                           # missing ), unterminated subpattern

Remember

  • Always re.escape user text inserted into a pattern
  • Sort alternatives longest first; the first match wins, not the longest
  • For large word lists, tokenize and use a set instead of one huge alternation

Watch out for

  • Building re.compile(user_input) directly, letting users inject slow or invalid patterns.

Check yourself

  1. Why sort words by length before joining them with '|'?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/re.html#re.escape

Builds on

Unlocks

Nothing builds on this yet.