From 436c8c67e268dc40cfaf26aad79ce3c2ddbb1486 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Wed, 22 Apr 2026 12:27:51 +0800 Subject: [PATCH 1/3] Prevent open redirect in login 'next' handling normalize the login "next" parameter in admin and contest login handlers. --- cms/server/admin/handlers/main.py | 10 ++++++++-- cms/server/contest/handlers/main.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cms/server/admin/handlers/main.py b/cms/server/admin/handlers/main.py index e3ce0760aa..c245812b4a 100644 --- a/cms/server/admin/handlers/main.py +++ b/cms/server/admin/handlers/main.py @@ -27,6 +27,7 @@ import json import logging +from urllib.parse import urlsplit from cms import ServiceCoord, get_service_shards, get_service_address from cms.db import Admin, Contest, Question @@ -48,8 +49,13 @@ def post(self): next_page: str = self.get_argument("next", None) if next_page is not None: error_args["next"] = next_page - if next_page != "/": - next_page = self.url(*next_page.strip("/").split("/")) + split = urlsplit(next_page) + if split.scheme or split.netloc or not split.path.startswith("/"): + next_page = self.url() + elif split.path != "/": + next_page = self.url(*split.path.strip("/").split("/")) + if split.query: + next_page += "?" + split.query else: next_page = self.url() else: diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index 93402e2b0c..a4f3070b98 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -33,6 +33,7 @@ import logging import os.path import re +from urllib.parse import urlsplit import collections @@ -217,8 +218,13 @@ def post(self): next_page: str | None = self.get_argument("next", None) if next_page is not None: error_args["next"] = next_page - if next_page != "/": - next_page = self.url(*next_page.strip("/").split("/")) + split = urlsplit(next_page) + if split.scheme or split.netloc or not split.path.startswith("/"): + next_page = self.contest_url() + elif split.path != "/": + next_page = self.url(*split.path.strip("/").split("/")) + if split.query: + next_page += "?" + split.query else: next_page = self.url() else: From 2c83bef2e66115f101839e796bfc4f3b3b823c3b Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Wed, 22 Apr 2026 14:34:17 +0800 Subject: [PATCH 2/3] Update cms/server/contest/handlers/main.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cms/server/contest/handlers/main.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index a4f3070b98..d101727fb8 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -222,9 +222,13 @@ def post(self): if split.scheme or split.netloc or not split.path.startswith("/"): next_page = self.contest_url() elif split.path != "/": - next_page = self.url(*split.path.strip("/").split("/")) - if split.query: - next_page += "?" + split.query + path_segments = split.path.strip("/").split("/") + if any(segment in ("", ".", "..") for segment in path_segments): + next_page = self.contest_url() + else: + next_page = self.url(*path_segments) + if split.query: + next_page += "?" + split.query else: next_page = self.url() else: From 46800cdf4768ab52a4a2501cf7b35adf4ed863d5 Mon Sep 17 00:00:00 2001 From: Pasit Sangprachathanarak Date: Wed, 22 Apr 2026 14:37:04 +0800 Subject: [PATCH 3/3] Validate and sanitize 'next' URL in login handlers Normalize and validate the parsed next-page path in admin and contest login handlers. Handle empty paths by treating them as "/", reject URLs with a scheme or netloc, and refuse path segments that are empty or contain "." or ".." to avoid unsafe redirects or path traversal. Also ensure the query string is preserved when constructing fallback URLs. These changes harden next-parameter handling and fix edge cases when urlsplit.path is empty. --- cms/server/admin/handlers/main.py | 17 ++++++++++++----- cms/server/contest/handlers/main.py | 9 ++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/cms/server/admin/handlers/main.py b/cms/server/admin/handlers/main.py index c245812b4a..ba85fee6d1 100644 --- a/cms/server/admin/handlers/main.py +++ b/cms/server/admin/handlers/main.py @@ -50,14 +50,21 @@ def post(self): if next_page is not None: error_args["next"] = next_page split = urlsplit(next_page) - if split.scheme or split.netloc or not split.path.startswith("/"): + path = split.path or "/" + if split.scheme or split.netloc or not path.startswith("/"): next_page = self.url() - elif split.path != "/": - next_page = self.url(*split.path.strip("/").split("/")) - if split.query: - next_page += "?" + split.query + elif path != "/": + path_segments = path.strip("/").split("/") + if any(segment in ("", ".", "..") for segment in path_segments): + next_page = self.url() + else: + next_page = self.url(*path_segments) + if split.query: + next_page += "?" + split.query else: next_page = self.url() + if split.query: + next_page += "?" + split.query else: next_page = self.url() error_page = self.url("login", **error_args) diff --git a/cms/server/contest/handlers/main.py b/cms/server/contest/handlers/main.py index d101727fb8..fb0111101e 100644 --- a/cms/server/contest/handlers/main.py +++ b/cms/server/contest/handlers/main.py @@ -219,10 +219,11 @@ def post(self): if next_page is not None: error_args["next"] = next_page split = urlsplit(next_page) - if split.scheme or split.netloc or not split.path.startswith("/"): + path = split.path or "/" + if split.scheme or split.netloc or not path.startswith("/"): next_page = self.contest_url() - elif split.path != "/": - path_segments = split.path.strip("/").split("/") + elif path != "/": + path_segments = path.strip("/").split("/") if any(segment in ("", ".", "..") for segment in path_segments): next_page = self.contest_url() else: @@ -231,6 +232,8 @@ def post(self): next_page += "?" + split.query else: next_page = self.url() + if split.query: + next_page += "?" + split.query else: next_page = self.contest_url() error_page = self.contest_url(**error_args)