codacy-bot: 
![Codacy](https://www.codacy.com/assets/images/favicon.png) Here is an overview of what got changed by this pull request:


```diff

Complexity increasing per file
==============================
- cherrypy/_cperror.py  8
         
```


See the complete overview on [Codacy](https://www.codacy.com/app/cherrypy/cherrypy/pullRequest?prid=554915&bid=4292032)
        
mar10: Concerning the tests, it would go something like 
```py
    class RedirectApp(object):
        def index(self, **kwargs):
            raise cherrypy.HTTPRedirect("example.com?redir=<script>alert(1);</script>")
        index.exposed = True
    
    cherrypy.quickstart(RedirectApp())
```

and

```py
    import requests
    res = requests.get("http://127.0.0.1:8080?redir=<abc>", allow_redirects=False)
    assert res.status_code == 303
    assert '<' not in res.content
```

But I don't know where and how to add it.
webknjaz: Regarding tests, please check [`cherrypy/test/test_request_obj.py`](https://github.com/cherrypy/cherrypy/blob/master/cherrypy/test/test_request_obj.py), I don't see tests for 3xx status codes, but it should be relatively easy to add them there :)

P.S. Please note `getPage()`, `assertStatus()` and `assertBody()` helpers.
mar10: Looking at the code I would not consider this change harmful and we already invested quite a bit  of time on this one-liner ;)
But I'll see what I can do (may take a while until I find time though).
diff --git a/cherrypy/_cperror.py b/cherrypy/_cperror.py
index 6c952e91..b597c645 100644
--- a/cherrypy/_cperror.py
+++ b/cherrypy/_cperror.py
@@ -271,7 +271,7 @@ class HTTPRedirect(CherryPyException):
                 307: 'This resource has moved temporarily to ',
             }[status]
             msg += '<a href=%s>%s</a>.'
-            msgs = [msg % (saxutils.quoteattr(u), u) for u in self.urls]
+            msgs = [msg % (saxutils.quoteattr(u), escape_html(u)) for u in self.urls]
             response.body = ntob('<br />\n'.join(msgs), 'utf-8')
             # Previous code may have set C-L, so we have to reset it
             # (allow finalize to set it).

Encode URLs in redirect responses
* **I'm submitting a ...**
[x] bug report
[ ] feature request
[ ] question about the decisions made in the repository

* **What is the current behavior?**
The standard response page for 30x errors contains the target URL as `href` as well as clear text.

* **What is the expected behavior?**
Even though browsers *mostly* will not display the result, but redirect instead, you never know:
Any input from unknown sources should be html-escaped.

* **Please tell us about your environment:**
CherryPy 3.8 until 10.1

diff --git a/cherrypy/test/test_core.py b/cherrypy/test/test_core.py
index 2e590a9d..f16efd58 100644
--- a/cherrypy/test/test_core.py
+++ b/cherrypy/test/test_core.py
@@ -150,6 +150,9 @@ class CoreRequestHandlingTest(helper.CPWebCase):
             def url_with_quote(self):
                 raise cherrypy.HTTPRedirect("/some\"url/that'we/want")
 
+            def url_with_xss(self):
+                raise cherrypy.HTTPRedirect("/some<script>alert(1);</script>url/that'we/want")
+
             def url_with_unicode(self):
                 raise cherrypy.HTTPRedirect(ntou('тест', 'utf-8'))
 
@@ -435,6 +438,13 @@ class CoreRequestHandlingTest(helper.CPWebCase):
         self.assertStatus(303)
         assertValidXHTML()
 
+    def test_redirect_with_xss(self):
+        """A redirect to a URL with HTML injected should result in page contents escaped."""
+        self.getPage('/redirect/url_with_xss')
+        self.assertStatus(303)
+        assert b'<script>' not in self.body
+        assert b'&lt;script&gt;' in self.body
+
     def test_redirect_with_unicode(self):
         """
         A redirect to a URL with Unicode should return a Location

pip install -e .[testing]
parse_log_pytest
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
backports.unittest-mock==1.5.1
cheroot==10.0.1
-e git+https://github.com/cherrypy/cherrypy.git@8c635f55c3634b722260fef501ee49fb440be3ac#egg=CherryPy
coverage==7.8.0
exceptiongroup==1.2.2
iniconfig==2.1.0
jaraco.functools==4.1.0
more-itertools==10.6.0
nose==1.3.7
nose-testconfig==0.10
objgraph==3.6.2
packaging==24.2
pluggy==1.5.0
portend==3.2.0
pytest==8.3.5
python-dateutil==2.9.0.post0
six==1.17.0
tempora==5.8.0
tomli==2.2.1

name: cherrypy
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - backports-unittest-mock==1.5.1
      - cheroot==10.0.1
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - iniconfig==2.1.0
      - jaraco-functools==4.1.0
      - more-itertools==10.6.0
      - nose==1.3.7
      - nose-testconfig==0.10
      - objgraph==3.6.2
      - packaging==24.2
      - pluggy==1.5.0
      - portend==3.2.0
      - pytest==8.3.5
      - python-dateutil==2.9.0.post0
      - six==1.17.0
      - tempora==5.8.0
      - tomli==2.2.1
prefix: /opt/conda/envs/cherrypy

cherrypy/test/test_core.py::CoreRequestHandlingTest::test_redirect_with_xss
cherrypy/test/test_core.py::CoreRequestHandlingTest::testCookies cherrypy/test/test_core.py::CoreRequestHandlingTest::testDefaultContentType cherrypy/test/test_core.py::CoreRequestHandlingTest::testFavicon cherrypy/test/test_core.py::CoreRequestHandlingTest::testFlatten cherrypy/test/test_core.py::CoreRequestHandlingTest::testRanges cherrypy/test/test_core.py::CoreRequestHandlingTest::testRedirect cherrypy/test/test_core.py::CoreRequestHandlingTest::testSlashes cherrypy/test/test_core.py::CoreRequestHandlingTest::testStatus cherrypy/test/test_core.py::CoreRequestHandlingTest::test_InternalRedirect cherrypy/test/test_core.py::CoreRequestHandlingTest::test_cherrypy_url cherrypy/test/test_core.py::CoreRequestHandlingTest::test_expose_decorator cherrypy/test/test_core.py::CoreRequestHandlingTest::test_multiple_headers cherrypy/test/test_core.py::CoreRequestHandlingTest::test_on_end_resource_status cherrypy/test/test_core.py::CoreRequestHandlingTest::test_redirect_with_unicode cherrypy/test/test_core.py::CoreRequestHandlingTest::test_gc cherrypy/test/test_core.py::ErrorTests::test_contextmanager cherrypy/test/test_core.py::ErrorTests::test_start_response_error cherrypy/test/test_core.py::ErrorTests::test_gc cherrypy/test/test_core.py::TestBinding::test_bind_ephemeral_port
cherrypy/_cperror.py
cherrypy/_cperror.py