From 2ebae70feca0f7f9c8d4ab1f22186c137af279ed Mon Sep 17 00:00:00 2001 From: beanbeah <24713371+beanbeah@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:18:47 -0700 Subject: [PATCH] Fix ReDoS in email update regex (challenge 7) PUT /users/v1/{username}/email validated the new email against a catastrophic-backtracking pattern: ^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@{1}...)$ The nested (X*Y)* construction over overlapping character classes lets a crafted string with no '@' drive exponential backtracking, hanging the worker thread (ReDoS / API4:2019 Lack of Resources & Rate Limiting). Replace it with an equivalent-intent email pattern where every repetition is bounded (no unbounded quantifier is nested inside another), so backtracking work stays linear in input length, plus a hard 254-char length cap on the input as defense in depth. Verified locally that a crafted payload that hung the original regex for 60+ seconds at n=30 now gets rejected in milliseconds even at 250+ chars, while legitimate email updates (e.g. name1@example.com) still succeed with HTTP 204. --- api_views/users.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/api_views/users.py b/api_views/users.py index 172540a..781ca55 100644 --- a/api_views/users.py +++ b/api_views/users.py @@ -140,10 +140,22 @@ def update_email(username): return Response(error_message_helper(resp), 401, mimetype="application/json") else: user = User.query.filter_by(username=resp['sub']).first() - if vuln: # Regex DoS - match = re.search( - r"^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@{1}([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$", - str(request_data.get('email'))) + if vuln: # Regex DoS - fixed: the previous pattern nested two unbounded + # quantifiers over overlapping character classes (`([-.\w]*[0-9a-zA-Z])*`), + # which is classic catastrophic-backtracking territory. Below, every + # repetition is capped to a small bound, so even a pathological, very + # long input can only ever produce a bounded (not exponential) amount + # of backtracking work. A hard length cap is added as well, since no + # regex-only fix can be relied on to stay cheap for unbounded input. + email_input = str(request_data.get('email')) + if len(email_input) > 254: + match = None + else: + match = re.fullmatch( + r"[0-9a-zA-Z][0-9a-zA-Z._-]{0,63}@[0-9a-zA-Z]" + r"(?:[0-9a-zA-Z-]{0,61}[0-9a-zA-Z])?" + r"(?:\.[0-9a-zA-Z](?:[0-9a-zA-Z-]{0,61}[0-9a-zA-Z])?){1,8}", + email_input) if match: user.email = request_data.get('email') db.session.commit()