From 869fbe6b2cc1f7b803085c51b69e0d1f23a6d80b Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Thu, 20 Oct 2022 23:19:53 -0700
Subject: [PATCH 01/12] Deprecate twisted.web.resource.ErrorPage and spawn

---
 src/twisted/web/newsfragments/11716.feature |  1 +
 src/twisted/web/newsfragments/11716.removal |  1 +
 src/twisted/web/resource.py                 | 69 +++++++++++++++++----
 src/twisted/web/test/test_resource.py       | 51 +++++++++++++--
 4 files changed, 106 insertions(+), 16 deletions(-)
 create mode 100644 src/twisted/web/newsfragments/11716.feature
 create mode 100644 src/twisted/web/newsfragments/11716.removal

diff --git a/src/twisted/web/newsfragments/11716.feature b/src/twisted/web/newsfragments/11716.feature
new file mode 100644
index 00000000000..5693458b403
--- /dev/null
+++ b/src/twisted/web/newsfragments/11716.feature
@@ -0,0 +1 @@
+The twisted.web.pages.ErrorPage, NotFoundPage, and ForbiddenPage IResource implementations provide HTML error pages rendered safely using twisted.web.template.
diff --git a/src/twisted/web/newsfragments/11716.removal b/src/twisted/web/newsfragments/11716.removal
new file mode 100644
index 00000000000..f4d2b36f415
--- /dev/null
+++ b/src/twisted/web/newsfragments/11716.removal
@@ -0,0 +1 @@
+The twisted.web.resource.ErrorPage, NoResource, and ForbiddenResource classes have been deprecated in favor of new implementations twisted.web.pages module because they permit HTML injection.
diff --git a/src/twisted/web/resource.py b/src/twisted/web/resource.py
index 5e6bd83f908..93c780740fc 100644
--- a/src/twisted/web/resource.py
+++ b/src/twisted/web/resource.py
@@ -1,9 +1,11 @@
-# -*- test-case-name: twisted.web.test.test_web -*-
+# -*- test-case-name: twisted.web.test.test_web, twisted.web.test.test_resource -*-
 # Copyright (c) Twisted Matrix Laboratories.
 # See LICENSE for details.
 
 """
 Implementation of the lowest-level Resource class.
+
+See L{twisted.web.pages} for some utility implementations.
 """
 
 
@@ -21,8 +23,11 @@
 
 from zope.interface import Attribute, Interface, implementer
 
+from incremental import Version
+
 from twisted.python.compat import nativeString
 from twisted.python.components import proxyForInterface
+from twisted.python.deprecate import deprecatedModuleAttribute
 from twisted.python.reflect import prefixedMethodNames
 from twisted.web._responses import FORBIDDEN, NOT_FOUND
 from twisted.web.error import UnsupportedMethod
@@ -286,20 +291,25 @@ def _computeAllowedMethods(resource):
     return allowedMethods
 
 
-class ErrorPage(Resource):
+class _UnsafeErrorPage(Resource):
     """
-    L{ErrorPage} is a resource which responds with a particular
+    L{_UnsafeErrorPage}, publicly available via the deprecated alias
+    C{ErrorPage}, is a resource which responds with a particular
     (parameterized) status and a body consisting of HTML containing some
     descriptive text.  This is useful for rendering simple error pages.
 
+    Deprecated in Twisted NEXT because it permits HTML injection; use
+    L{twisted.pages.ErrorPage} instead.
+
     @ivar template: A native string which will have a dictionary interpolated
         into it to generate the response body.  The dictionary has the following
         keys:
 
-          - C{"code"}: The status code passed to L{ErrorPage.__init__}.
-          - C{"brief"}: The brief description passed to L{ErrorPage.__init__}.
+          - C{"code"}: The status code passed to L{_UnsafeErrorPage.__init__}.
+          - C{"brief"}: The brief description passed to
+            L{_UnsafeErrorPage.__init__}.
           - C{"detail"}: The detailed description passed to
-            L{ErrorPage.__init__}.
+            L{_UnsafeErrorPage.__init__}.
 
     @ivar code: An integer status code which will be used for the response.
     @type code: C{int}
@@ -342,26 +352,61 @@ def getChild(self, chnam, request):
         return self
 
 
-class NoResource(ErrorPage):
+class _UnsafeNoResource(_UnsafeErrorPage):
     """
-    L{NoResource} is a specialization of L{ErrorPage} which returns the HTTP
-    response code I{NOT FOUND}.
+    L{_UnsafeNoResource}, publicly available via the deprecated alias
+    C{NoResource}, is a specialization of L{_UnsafeErrorPage} which
+    returns the HTTP response code I{NOT FOUND}.
+
+    Deprecated in Twisted NEXT because it permits HTML injection; use
+    L{twisted.pages.NotFoundPage} instead.
     """
 
     def __init__(self, message="Sorry. No luck finding that resource."):
         ErrorPage.__init__(self, NOT_FOUND, "No Such Resource", message)
 
 
-class ForbiddenResource(ErrorPage):
+class _UnsafeForbiddenResource(_UnsafeErrorPage):
     """
-    L{ForbiddenResource} is a specialization of L{ErrorPage} which returns the
-    I{FORBIDDEN} HTTP response code.
+    L{_UnsafeForbiddenResource}, publicly available via the deprecated alias
+    C{ForbiddenResource} is a specialization of L{_UnsafeErrorPage} which
+    returns the I{FORBIDDEN} HTTP response code.
+
+    Deprecated in Twisted NEXT because it permits HTML injection; use
+    L{twisted.pages.ForbiddenPage} instead.
     """
 
     def __init__(self, message="Sorry, resource is forbidden."):
         ErrorPage.__init__(self, FORBIDDEN, "Forbidden Resource", message)
 
 
+# Deliberately undocumented public aliases. See GHSA-vg46-2rrj-3647.
+ErrorPage = _UnsafeErrorPage
+NoResource = _UnsafeNoResource
+ForbiddenResource = _UnsafeForbiddenResource
+
+deprecatedModuleAttribute(
+    Version("Twisted", "NEXT", 0, 0),
+    "Use twisted.pages.ErrorPage instead, which properly escapes HTML.",
+    __name__,
+    "ErrorPage",
+)
+
+deprecatedModuleAttribute(
+    Version("Twisted", "NEXT", 0, 0),
+    "Use twisted.pages.NotFoundPage instead, which properly escapes HTML.",
+    __name__,
+    "NoResource",
+)
+
+deprecatedModuleAttribute(
+    Version("Twisted", "NEXT", 0, 0),
+    "Use twisted.pages.ForbiddenPage instead, which properly escapes HTML.",
+    __name__,
+    "ForbiddenResource",
+)
+
+
 class _IEncodingResource(Interface):
     """
     A resource which knows about L{_IRequestEncoderFactory}.
diff --git a/src/twisted/web/test/test_resource.py b/src/twisted/web/test/test_resource.py
index bd2f90887da..3e83d0efdc2 100644
--- a/src/twisted/web/test/test_resource.py
+++ b/src/twisted/web/test/test_resource.py
@@ -11,10 +11,10 @@
 from twisted.web.resource import (
     FORBIDDEN,
     NOT_FOUND,
-    ErrorPage,
-    ForbiddenResource,
-    NoResource,
     Resource,
+    _UnsafeErrorPage as ErrorPage,
+    _UnsafeForbiddenResource as ForbiddenResource,
+    _UnsafeNoResource as NoResource,
     getChildForRequest,
 )
 from twisted.web.test.requesthelper import DummyRequest
@@ -22,13 +22,56 @@
 
 class ErrorPageTests(TestCase):
     """
-    Tests for L{ErrorPage}, L{NoResource}, and L{ForbiddenResource}.
+    Tests for L{_UnafeErrorPage}, L{_UnsafeNoResource}, and
+    L{_UnsafeForbiddenResource}.
     """
 
     errorPage = ErrorPage
     noResource = NoResource
     forbiddenResource = ForbiddenResource
 
+    def test_deprecatedErrorPage(self):
+        """
+        The public C{twisted.web.resource.ErrorPage} alias for the
+        corresponding C{_Unsafe} class produces a deprecation warning when
+        imported.
+        """
+        from twisted.web.resource import ErrorPage
+
+        self.assertIs(ErrorPage, self.errorPage)
+
+        [warning] = self.flushWarnings()
+        self.assertEqual(warning["category"], DeprecationWarning)
+        self.assertIn("twisted.pages.ErrorPage", warning["message"])
+
+    def test_deprecatedNoResource(self):
+        """
+        The public C{twisted.web.resource.NoResource} alias for the
+        corresponding C{_Unsafe} class produces a deprecation warning when
+        imported.
+        """
+        from twisted.web.resource import NoResource
+
+        self.assertIs(NoResource, self.noResource)
+
+        [warning] = self.flushWarnings()
+        self.assertEqual(warning["category"], DeprecationWarning)
+        self.assertIn("twisted.pages.NotFoundPage", warning["message"])
+
+    def test_deprecatedForbiddenResource(self):
+        """
+        The public C{twisted.web.resource.ForbiddenResource} alias for the
+        corresponding C{_Unsafe} class produce a deprecation warning when
+        imported.
+        """
+        from twisted.web.resource import ForbiddenResource
+
+        self.assertIs(ForbiddenResource, self.forbiddenResource)
+
+        [warning] = self.flushWarnings()
+        self.assertEqual(warning["category"], DeprecationWarning)
+        self.assertIn("twisted.pages.ForbiddenPage", warning["message"])
+
     def test_getChild(self):
         """
         The C{getChild} method of L{ErrorPage} returns the L{ErrorPage} it is

From 5ce023c4d735a895a03f2eb4a622a2322b8990ec Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Thu, 20 Oct 2022 23:38:19 -0700
Subject: [PATCH 02/12] Implement twisted.web.pages

---
 src/twisted/web/_template_util.py           |   6 +-
 src/twisted/web/newsfragments/11716.feature |   2 +-
 src/twisted/web/pages.py                    | 108 ++++++++++++++++++++
 src/twisted/web/resource.py                 |  10 +-
 src/twisted/web/test/test_pages.py          | 106 +++++++++++++++++++
 src/twisted/web/test/test_resource.py       |   4 +-
 6 files changed, 225 insertions(+), 11 deletions(-)
 create mode 100644 src/twisted/web/pages.py
 create mode 100644 src/twisted/web/test/test_pages.py

diff --git a/src/twisted/web/_template_util.py b/src/twisted/web/_template_util.py
index bd081bd54ab..38ebbed1d5b 100644
--- a/src/twisted/web/_template_util.py
+++ b/src/twisted/web/_template_util.py
@@ -1034,9 +1034,9 @@ class _TagFactory:
     """
     A factory for L{Tag} objects; the implementation of the L{tags} object.
 
-    This allows for the syntactic convenience of C{from twisted.web.html import
-    tags; tags.a(href="linked-page.html")}, where 'a' can be basically any HTML
-    tag.
+    This allows for the syntactic convenience of C{from twisted.web.template
+    import tags; tags.a(href="linked-page.html")}, where 'a' can be basically
+    any HTML tag.
 
     The class is not exposed publicly because you only ever need one of these,
     and we already made it for you.
diff --git a/src/twisted/web/newsfragments/11716.feature b/src/twisted/web/newsfragments/11716.feature
index 5693458b403..e8ba00b7ef2 100644
--- a/src/twisted/web/newsfragments/11716.feature
+++ b/src/twisted/web/newsfragments/11716.feature
@@ -1 +1 @@
-The twisted.web.pages.ErrorPage, NotFoundPage, and ForbiddenPage IResource implementations provide HTML error pages rendered safely using twisted.web.template.
+The twisted.web.pages.ErrorPage, notFound, and forbidden IResource implementations provide HTML error pages safely rendered using twisted.web.template.
diff --git a/src/twisted/web/pages.py b/src/twisted/web/pages.py
new file mode 100644
index 00000000000..8f37b3e45a8
--- /dev/null
+++ b/src/twisted/web/pages.py
@@ -0,0 +1,108 @@
+# -*- test-case-name: twisted.web.test.test_pages -*-
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+"""
+Utility implementations of L{IResource}.
+"""
+
+__all__ = (
+    "ErrorPage",
+    "notFound",
+    "forbidden",
+)
+
+
+from twisted.web import http
+from twisted.web.iweb import IRequest
+from twisted.web.resource import Resource
+from twisted.web.template import renderElement, tags
+
+
+class ErrorPage(Resource):
+    """
+    L{ErrorPage} is a resource that responds to all requests with a particular
+    (parameterized) HTTP status code and a body consisting of HTML containing
+    some descriptive text. This is useful for rendering simple error pages.
+
+    @ivar _code: An integer HTTP status code which will be used for the
+        response.
+
+    @ivar _brief: A short string which will be included in the response body as
+        the page title.
+
+    @ivar _detail: A longer string which will be included in the response body.
+    """
+
+    def __init__(self, code: int, brief: str, detail: str) -> None:
+        """
+        @param code: An integer HTTP status code which will be used for the
+            response.
+
+        @param brief: A short string which will be included in the response
+            body as the page title.
+
+        @param detail: A longer string which will be included in the
+            response body.
+        """
+        super().__init__()
+        self._code: int = code
+        self._brief: str = brief
+        self._detail: str = detail
+
+    def render(self, request: IRequest) -> None:
+        """
+        Respond to all requests with the given HTTP status code and an HTML
+        document containing the explanatory strings.
+        """
+        request.setResponseCode(self._code)
+        request.setHeader(b"content-type", b"text/html; charset=utf-8")
+        renderElement(
+            request,
+            tags.html(
+                tags.head(tags.title(f"{self._code} - {self._brief}")),
+                tags.body(tags.h1(self._brief), tags.p(self._detail)),
+            ),
+        )
+
+    def getChild(self, path: bytes, request: IRequest) -> Resource:
+        """
+        Handle all requests for which L{ErrorPage} lacks a child by returning
+        this error page.
+
+        @param path: A path segment.
+
+        @param request: HTTP request
+        """
+        return self
+
+
+def notFound(
+    brief: str = "No Such Resource",
+    message: str = "Sorry. No luck finding that resource.",
+) -> ErrorPage:
+    """
+    Generate an L{ErrorPage} with a 404 Not Found status code.
+
+    @param brief: A short string displayed as the page title.
+
+    @param brief: A longer string displayed in the page body.
+
+    @returns: An L{ErrorPage}
+    """
+    return ErrorPage(http.NOT_FOUND, brief, message)
+
+
+def forbidden(
+    brief: str = "Forbidden Resource", message: str = "Sorry, resource is forbidden."
+) -> ErrorPage:
+    """
+    Generate an L{ErrorPage} with a 403 Forbidden status code.
+
+    @param brief: A short string displayed as the page title.
+
+    @param brief: A longer string displayed in the page body.
+
+    @returns: An L{ErrorPage}
+    """
+    return ErrorPage(http.FORBIDDEN, brief, message)
diff --git a/src/twisted/web/resource.py b/src/twisted/web/resource.py
index 93c780740fc..09fc74a89fd 100644
--- a/src/twisted/web/resource.py
+++ b/src/twisted/web/resource.py
@@ -183,7 +183,7 @@ def getChild(self, path, request):
         Parameters and return value have the same meaning and requirements as
         those defined by L{IResource.getChildWithDefault}.
         """
-        return NoResource("No such child resource.")
+        return _UnsafeNoResource()
 
     def getChildWithDefault(self, path, request):
         """
@@ -359,7 +359,7 @@ class _UnsafeNoResource(_UnsafeErrorPage):
     returns the HTTP response code I{NOT FOUND}.
 
     Deprecated in Twisted NEXT because it permits HTML injection; use
-    L{twisted.pages.NotFoundPage} instead.
+    L{twisted.pages.notFound} instead.
     """
 
     def __init__(self, message="Sorry. No luck finding that resource."):
@@ -373,7 +373,7 @@ class _UnsafeForbiddenResource(_UnsafeErrorPage):
     returns the I{FORBIDDEN} HTTP response code.
 
     Deprecated in Twisted NEXT because it permits HTML injection; use
-    L{twisted.pages.ForbiddenPage} instead.
+    L{twisted.pages.forbidden} instead.
     """
 
     def __init__(self, message="Sorry, resource is forbidden."):
@@ -394,14 +394,14 @@ def __init__(self, message="Sorry, resource is forbidden."):
 
 deprecatedModuleAttribute(
     Version("Twisted", "NEXT", 0, 0),
-    "Use twisted.pages.NotFoundPage instead, which properly escapes HTML.",
+    "Use twisted.pages.notFound instead, which properly escapes HTML.",
     __name__,
     "NoResource",
 )
 
 deprecatedModuleAttribute(
     Version("Twisted", "NEXT", 0, 0),
-    "Use twisted.pages.ForbiddenPage instead, which properly escapes HTML.",
+    "Use twisted.pages.forbidden instead, which properly escapes HTML.",
     __name__,
     "ForbiddenResource",
 )
diff --git a/src/twisted/web/test/test_pages.py b/src/twisted/web/test/test_pages.py
new file mode 100644
index 00000000000..d83e0eba3e2
--- /dev/null
+++ b/src/twisted/web/test/test_pages.py
@@ -0,0 +1,106 @@
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+"""
+Test L{twisted.web.pages}
+"""
+
+from twisted.trial.unittest import SynchronousTestCase
+from twisted.web.http_headers import Headers
+from twisted.web.pages import ErrorPage, forbidden, notFound
+from twisted.web.test.requesthelper import DummyRequest
+
+
+def _render(resource: ErrorPage) -> DummyRequest:
+    """
+    Render a response using the given resource.
+
+    @param resource: The resource to use to handle the request.
+
+    @returns: The request that the resource handled,
+    """
+    request = DummyRequest([b""])
+    resource.render(request)
+    return request
+
+
+class ErrorPageTests(SynchronousTestCase):
+    """
+    Test L{twisted.web.pages.ErrorPage} and its convencience helpers
+    L{notFound} and L{forbidden}.
+    """
+
+    maxDiff = None
+
+    def assertResponse(self, request: DummyRequest, code: int, body: bytes) -> None:
+        self.assertEqual(request.responseCode, code)
+        self.assertEqual(
+            request.responseHeaders,
+            Headers({b"content-type": [b"text/html; charset=utf-8"]}),
+        )
+        self.assertEqual(
+            # Decode to str because unittest somehow still doesn't diff bytes
+            # without truncating them in 2022.
+            b"".join(request.written).decode("latin-1"),
+            body.decode("latin-1"),
+        )
+
+    def test_escapesHTML(self):
+        """
+        The I{brief} and I{detail} parameters are HTML-escaped on render.
+        """
+        self.assertResponse(
+            _render(ErrorPage(400, "A & B", "<script>alert('oops!')")),
+            400,
+            (
+                b"<!DOCTYPE html>\n"
+                b"<html><head><title>400 - A &amp; B</title></head>"
+                b"<body><h1>A &amp; B</h1><p>&lt;script&gt;alert('oops!')"
+                b"</p></body></html>"
+            ),
+        )
+
+    def test_getChild(self):
+        """
+        The C{getChild} method of L{ErrorPage} returns the L{ErrorPage} it is
+        called on.
+        """
+        page = ErrorPage(404, "foo", "bar")
+        self.assertIs(
+            page.getChild(b"name", DummyRequest([b""])),
+            page,
+        )
+
+    def test_notFoundDefaults(self):
+        """
+        The default arguments to L{twisted.web.pages.notFound} produce
+        a reasonable error page.
+        """
+        self.assertResponse(
+            _render(notFound()),
+            404,
+            (
+                b"<!DOCTYPE html>\n"
+                b"<html><head><title>404 - No Such Resource</title></head>"
+                b"<body><h1>No Such Resource</h1>"
+                b"<p>Sorry. No luck finding that resource.</p>"
+                b"</body></html>"
+            ),
+        )
+
+    def test_forbiddenDefaults(self):
+        """
+        The default arguments to L{twisted.web.pages.forbidden} produce
+        a reasonable error page.
+        """
+        self.assertResponse(
+            _render(forbidden()),
+            403,
+            (
+                b"<!DOCTYPE html>\n"
+                b"<html><head><title>403 - Forbidden Resource</title></head>"
+                b"<body><h1>Forbidden Resource</h1>"
+                b"<p>Sorry, resource is forbidden.</p>"
+                b"</body></html>"
+            ),
+        )
diff --git a/src/twisted/web/test/test_resource.py b/src/twisted/web/test/test_resource.py
index 3e83d0efdc2..c039704a79d 100644
--- a/src/twisted/web/test/test_resource.py
+++ b/src/twisted/web/test/test_resource.py
@@ -56,7 +56,7 @@ def test_deprecatedNoResource(self):
 
         [warning] = self.flushWarnings()
         self.assertEqual(warning["category"], DeprecationWarning)
-        self.assertIn("twisted.pages.NotFoundPage", warning["message"])
+        self.assertIn("twisted.pages.notFound", warning["message"])
 
     def test_deprecatedForbiddenResource(self):
         """
@@ -70,7 +70,7 @@ def test_deprecatedForbiddenResource(self):
 
         [warning] = self.flushWarnings()
         self.assertEqual(warning["category"], DeprecationWarning)
-        self.assertIn("twisted.pages.ForbiddenPage", warning["message"])
+        self.assertIn("twisted.pages.forbidden", warning["message"])
 
     def test_getChild(self):
         """

From 7c48ed2b6282a49a73d31c7e952e0e115599c83f Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Sat, 22 Oct 2022 18:20:39 -0700
Subject: [PATCH 03/12] Update imports to avoid warnings

Use _UnsafeErrorPage, _UnsafeNoResource, etc. symbols instead of the
public aliases that provoke deprecation warnings.
---
 src/twisted/web/_auth/wrapper.py |  8 ++++----
 src/twisted/web/distrib.py       |  7 ++++---
 src/twisted/web/script.py        | 14 +++++++++-----
 src/twisted/web/server.py        | 11 +++++++----
 src/twisted/web/static.py        |  6 +++---
 5 files changed, 27 insertions(+), 19 deletions(-)

diff --git a/src/twisted/web/_auth/wrapper.py b/src/twisted/web/_auth/wrapper.py
index 0f71380a4d8..cffdcff66c9 100644
--- a/src/twisted/web/_auth/wrapper.py
+++ b/src/twisted/web/_auth/wrapper.py
@@ -21,7 +21,7 @@
 from twisted.logger import Logger
 from twisted.python.components import proxyForInterface
 from twisted.web import util
-from twisted.web.resource import ErrorPage, IResource
+from twisted.web.resource import IResource, _UnsafeErrorPage
 
 
 @implementer(IResource)
@@ -52,7 +52,7 @@ def generateWWWAuthenticate(scheme, challenge):
             return b" ".join([scheme, b", ".join(lst)])
 
         def quoteString(s):
-            return b'"' + s.replace(b"\\", br"\\").replace(b'"', br"\"") + b'"'
+            return b'"' + s.replace(b"\\", rb"\\").replace(b'"', rb"\"") + b'"'
 
         request.setResponseCode(401)
         for fact in self._credentialFactories:
@@ -125,7 +125,7 @@ def _authorizedResource(self, request):
             return UnauthorizedResource(self._credentialFactories)
         except BaseException:
             self._log.failure("Unexpected failure from credentials factory")
-            return ErrorPage(500, None, None)
+            return _UnsafeErrorPage(500, "Internal Error", "")
         else:
             return util.DeferredResource(self._login(credentials))
 
@@ -213,7 +213,7 @@ def _loginFailed(self, result):
                 "unexpected error",
                 failure=result,
             )
-            return ErrorPage(500, None, None)
+            return _UnsafeErrorPage(500, "Internal Error", "")
 
     def _selectParseHeader(self, header):
         """
diff --git a/src/twisted/web/distrib.py b/src/twisted/web/distrib.py
index 56f83fe2792..05665278ed8 100644
--- a/src/twisted/web/distrib.py
+++ b/src/twisted/web/distrib.py
@@ -127,9 +127,10 @@ def failed(self, failure):
         # XXX: Argh. FIXME.
         failure = str(failure)
         self.request.write(
-            resource.ErrorPage(
+            resource._UnsafeErrorPage(
                 http.INTERNAL_SERVER_ERROR,
                 "Server Connection Lost",
+                # GHSA-vg46-2rrj-3647 note: _PRE does HTML-escape the input.
                 "Connection to distributed server lost:" + util._PRE(failure),
             ).render(self.request)
         )
@@ -377,7 +378,7 @@ def getChild(self, name, request):
                 pw_shell,
             ) = self._pwd.getpwnam(username)
         except KeyError:
-            return resource.NoResource()
+            return resource._UnsafeNoResource()
         if sub:
             twistdsock = os.path.join(pw_dir, self.userSocketName)
             rs = ResourceSubscription("unix", twistdsock)
@@ -386,5 +387,5 @@ def getChild(self, name, request):
         else:
             path = os.path.join(pw_dir, self.userDirName)
             if not os.path.exists(path):
-                return resource.NoResource()
+                return resource._UnsafeNoResource()
             return static.File(path)
diff --git a/src/twisted/web/script.py b/src/twisted/web/script.py
index eaf4ab8c8fc..bc4a90f748a 100644
--- a/src/twisted/web/script.py
+++ b/src/twisted/web/script.py
@@ -49,7 +49,7 @@ def recache(self):
         self.doCache = 1
 
 
-noRsrc = resource.ErrorPage(500, "Whoops! Internal Error", rpyNoResource)
+noRsrc = resource._UnsafeErrorPage(500, "Whoops! Internal Error", rpyNoResource)
 
 
 def ResourceScript(path, registry):
@@ -81,7 +81,9 @@ def ResourceTemplate(path, registry):
 
     glob = {
         "__file__": _coerceToFilesystemEncoding("", path),
-        "resource": resource.ErrorPage(500, "Whoops! Internal Error", rpyNoResource),
+        "resource": resource._UnsafeErrorPage(
+            500, "Whoops! Internal Error", rpyNoResource
+        ),
         "registry": registry,
     }
 
@@ -133,10 +135,10 @@ def getChild(self, path, request):
             return ResourceScriptDirectory(fn, self.registry)
         if os.path.exists(fn):
             return ResourceScript(fn, self.registry)
-        return resource.NoResource()
+        return resource._UnsafeNoResource()
 
     def render(self, request):
-        return resource.NoResource().render(request)
+        return resource._UnsafeNoResource().render(request)
 
 
 class PythonScript(resource.Resource):
@@ -178,7 +180,9 @@ def render(self, request):
         except OSError as e:
             if e.errno == 2:  # file not found
                 request.setResponseCode(http.NOT_FOUND)
-                request.write(resource.NoResource("File not found.").render(request))
+                request.write(
+                    resource._UnsafeNoResource("File not found.").render(request)
+                )
         except BaseException:
             io = StringIO()
             traceback.print_exc(file=io)
diff --git a/src/twisted/web/server.py b/src/twisted/web/server.py
index d30156b895a..e8e01ec781b 100644
--- a/src/twisted/web/server.py
+++ b/src/twisted/web/server.py
@@ -335,10 +335,12 @@ def render(self, resrc):
                         "allowed": ", ".join([nativeString(x) for x in allowedMethods]),
                     }
                 )
-                epage = resource.ErrorPage(http.NOT_ALLOWED, "Method Not Allowed", s)
+                epage = resource._UnsafeErrorPage(
+                    http.NOT_ALLOWED, "Method Not Allowed", s
+                )
                 body = epage.render(self)
             else:
-                epage = resource.ErrorPage(
+                epage = resource._UnsafeErrorPage(
                     http.NOT_IMPLEMENTED,
                     "Huh?",
                     "I don't know how to treat a %s request."
@@ -350,10 +352,11 @@ def render(self, resrc):
         if body is NOT_DONE_YET:
             return
         if not isinstance(body, bytes):
-            body = resource.ErrorPage(
+            body = resource._UnsafeErrorPage(
                 http.INTERNAL_SERVER_ERROR,
                 "Request did not return bytes",
                 "Request: "
+                # GHSA-vg46-2rrj-3647 note: _PRE does HTML-escape the input.
                 + util._PRE(reflect.safe_repr(self))
                 + "<br />"
                 + "Resource: "
@@ -607,7 +610,7 @@ class GzipEncoderFactory:
     @since: 12.3
     """
 
-    _gzipCheckRegex = re.compile(br"(:?^|[\s,])gzip(:?$|[\s,])")
+    _gzipCheckRegex = re.compile(rb"(:?^|[\s,])gzip(:?$|[\s,])")
     compressLevel = 9
 
     def encoderForRequest(self, request):
diff --git a/src/twisted/web/static.py b/src/twisted/web/static.py
index 2689d3cfdab..09a2947f911 100644
--- a/src/twisted/web/static.py
+++ b/src/twisted/web/static.py
@@ -31,7 +31,7 @@
 from twisted.web import http, resource, server
 from twisted.web.util import redirectTo
 
-dangerousPathError = resource.NoResource("Invalid request URL.")
+dangerousPathError = resource._UnsafeNoResource("Invalid request URL.")
 
 
 def isDangerous(path):
@@ -255,8 +255,8 @@ def ignoreExt(self, ext):
         """
         self.ignoredExts.append(ext)
 
-    childNotFound = resource.NoResource("File not found.")
-    forbidden = resource.ForbiddenResource()
+    childNotFound = resource._UnsafeNoResource("File not found.")
+    forbidden = resource._UnsafeForbiddenResource()
 
     def directoryListing(self):
         """

From 404cbc2455c6d25d4570c50e50958c342b3591f9 Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Sat, 22 Oct 2022 18:32:12 -0700
Subject: [PATCH 04/12] Update the docs

---
 docs/web/howto/web-in-60/error-handling.rst | 26 ++++++---------------
 1 file changed, 7 insertions(+), 19 deletions(-)

diff --git a/docs/web/howto/web-in-60/error-handling.rst b/docs/web/howto/web-in-60/error-handling.rst
index 7717119f898..7cf1a789fec 100644
--- a/docs/web/howto/web-in-60/error-handling.rst
+++ b/docs/web/howto/web-in-60/error-handling.rst
@@ -32,21 +32,13 @@ As in the previous examples, we'll start with :py:class:`Site <twisted.web.serve
 
 
 
-Next, we'll add one more import. :py:class:`NoResource <twisted.web.resource.NoResource>` is one of the pre-defined error
+Next, we'll add one more import. :py:class:`notFound <twisted.web.pages.notFound>` is one of the pre-defined error
 resources provided by Twisted Web. It generates the necessary 404 response code
-and renders a simple html page telling the client there is no such resource.
-
-
-
-
+and renders a simple HTML page telling the client there is no such resource.
 
 .. code-block:: python
 
-
-    from twisted.web.resource import NoResource
-
-
-
+    from twisted.web.pages import notFound
 
 Next, we'll define a custom resource which does some dynamic URL
 dispatch. This example is going to be just like
@@ -54,10 +46,6 @@ the :doc:`previous one <dynamic-dispatch>` , where the path segment is
 interpreted as a year; the difference is that this time we'll handle requests
 which don't conform to that pattern by returning the not found response:
 
-
-
-
-
 .. code-block:: python
 
 
@@ -66,7 +54,7 @@ which don't conform to that pattern by returning the not found response:
             try:
                 year = int(name)
             except ValueError:
-                return NoResource()
+                return notFound()
             else:
                 return YearPage(year)
 
@@ -88,7 +76,7 @@ complete code for this example:
     from twisted.web.server import Site
     from twisted.web.resource import Resource
     from twisted.internet import reactor, endpoints
-    from twisted.web.resource import NoResource
+    from twisted.web.pages import notFound
 
     from calendar import calendar
 
@@ -100,14 +88,14 @@ complete code for this example:
         def render_GET(self, request):
             cal = calendar(self.year)
             return (b"<!DOCTYPE html><html><head><meta charset='utf-8'>"
-                    b"<title></title></head><body><pre>" + cal.encode('utf-8') + "</pre>")
+                    b"<title></title></head><body><pre>" + cal.encode('utf-8') + b"</pre>")
 
     class Calendar(Resource):
         def getChild(self, name, request):
             try:
                 year = int(name)
             except ValueError:
-                return NoResource()
+                return notFound()
             else:
                 return YearPage(year)
 

From 6cf64b7782e92efeafc5e17e1b59e3bfc1e70c47 Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Sat, 22 Oct 2022 18:45:06 -0700
Subject: [PATCH 05/12] Address DummyRequest MyPy issue

Filed https://github.com/twisted/twisted/issues/11719 for this.
---
 src/twisted/web/test/test_pages.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/src/twisted/web/test/test_pages.py b/src/twisted/web/test/test_pages.py
index d83e0eba3e2..1f66c16afc9 100644
--- a/src/twisted/web/test/test_pages.py
+++ b/src/twisted/web/test/test_pages.py
@@ -5,8 +5,11 @@
 Test L{twisted.web.pages}
 """
 
+from typing import cast
+
 from twisted.trial.unittest import SynchronousTestCase
 from twisted.web.http_headers import Headers
+from twisted.web.iweb import IRequest
 from twisted.web.pages import ErrorPage, forbidden, notFound
 from twisted.web.test.requesthelper import DummyRequest
 
@@ -20,7 +23,10 @@ def _render(resource: ErrorPage) -> DummyRequest:
     @returns: The request that the resource handled,
     """
     request = DummyRequest([b""])
-    resource.render(request)
+    # The cast is necessary because DummyRequest isn't annotated
+    # as an IRequest, and this can't be trivially done. See
+    # https://github.com/twisted/twisted/issues/11719
+    resource.render(cast(IRequest, request))
     return request
 
 

From 8a6437541e0a282ad13fa8ab3cb23e4cddae9bda Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Sat, 22 Oct 2022 19:25:51 -0700
Subject: [PATCH 06/12] Address IRenderable MyPy issue

---
 src/twisted/web/pages.py | 19 +++++++++++++------
 1 file changed, 13 insertions(+), 6 deletions(-)

diff --git a/src/twisted/web/pages.py b/src/twisted/web/pages.py
index 8f37b3e45a8..524148fba26 100644
--- a/src/twisted/web/pages.py
+++ b/src/twisted/web/pages.py
@@ -12,9 +12,10 @@
     "forbidden",
 )
 
+from typing import cast
 
 from twisted.web import http
-from twisted.web.iweb import IRequest
+from twisted.web.iweb import IRenderable, IRequest
 from twisted.web.resource import Resource
 from twisted.web.template import renderElement, tags
 
@@ -50,18 +51,24 @@ def __init__(self, code: int, brief: str, detail: str) -> None:
         self._brief: str = brief
         self._detail: str = detail
 
-    def render(self, request: IRequest) -> None:
+    def render(self, request: IRequest) -> object:
         """
         Respond to all requests with the given HTTP status code and an HTML
         document containing the explanatory strings.
         """
         request.setResponseCode(self._code)
         request.setHeader(b"content-type", b"text/html; charset=utf-8")
-        renderElement(
+        return renderElement(
             request,
-            tags.html(
-                tags.head(tags.title(f"{self._code} - {self._brief}")),
-                tags.body(tags.h1(self._brief), tags.p(self._detail)),
+            # cast because the type annotations here seem off; Tag isn't an
+            # IRenderable but also probably should be? See
+            # https://github.com/twisted/twisted/issues/4982
+            cast(
+                IRenderable,
+                tags.html(
+                    tags.head(tags.title(f"{self._code} - {self._brief}")),
+                    tags.body(tags.h1(self._brief), tags.p(self._detail)),
+                ),
             ),
         )
 

From a85a4904439a2f4783f0f2a85fc73c3c7837ac64 Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Sat, 22 Oct 2022 19:45:24 -0700
Subject: [PATCH 07/12] Failing test

---
 src/twisted/web/test/test_vhost.py | 19 ++++++++++++++++---
 1 file changed, 16 insertions(+), 3 deletions(-)

diff --git a/src/twisted/web/test/test_vhost.py b/src/twisted/web/test/test_vhost.py
index f26d5e5d5e9..bb66b55537a 100644
--- a/src/twisted/web/test/test_vhost.py
+++ b/src/twisted/web/test/test_vhost.py
@@ -66,7 +66,7 @@ def test_renderWithoutHost(self):
         """
         virtualHostResource = NameVirtualHost()
         virtualHostResource.default = Data(b"correct result", "")
-        request = DummyRequest([""])
+        request = DummyRequest([b""])
         self.assertEqual(virtualHostResource.render(request), b"correct result")
 
     def test_renderWithoutHostNoDefault(self):
@@ -76,7 +76,7 @@ def test_renderWithoutHostNoDefault(self):
         header in the request.
         """
         virtualHostResource = NameVirtualHost()
-        request = DummyRequest([""])
+        request = DummyRequest([b""])
         d = _render(virtualHostResource, request)
 
         def cbRendered(ignored):
@@ -140,7 +140,7 @@ def test_renderWithUnknownHostNoDefault(self):
         matching the value of the I{Host} header in the request.
         """
         virtualHostResource = NameVirtualHost()
-        request = DummyRequest([""])
+        request = DummyRequest([b""])
         request.requestHeaders.addRawHeader(b"host", b"example.com")
         d = _render(virtualHostResource, request)
 
@@ -150,6 +150,19 @@ def cbRendered(ignored):
         d.addCallback(cbRendered)
         return d
 
+    async def test_renderWithHTMLHost(self):
+        """
+        L{NameVirtualHost.render} doesn't echo unescaped HTML when present in
+        the I{Host} header.
+        """
+        virtualHostResource = NameVirtualHost()
+        request = DummyRequest([b""])
+        request.requestHeaders.addRawHeader(b"host", b"<b>example</b>.com")
+
+        await _render(virtualHostResource, request)
+
+        self.assertNotIn(b"<b>", b"".join(request.written))
+
     def test_getChild(self):
         """
         L{NameVirtualHost.getChild} returns correct I{Resource} based off

From d766a02b053d786e224b4d5449149bdd1bbedf84 Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Sat, 22 Oct 2022 20:01:50 -0700
Subject: [PATCH 08/12] Fix NameVirtualHost HTML injection vulnerability

---
 src/twisted/web/newsfragments/11716.bugfix |  1 +
 src/twisted/web/vhost.py                   | 11 ++++++-----
 2 files changed, 7 insertions(+), 5 deletions(-)
 create mode 100644 src/twisted/web/newsfragments/11716.bugfix

diff --git a/src/twisted/web/newsfragments/11716.bugfix b/src/twisted/web/newsfragments/11716.bugfix
new file mode 100644
index 00000000000..66189e685c5
--- /dev/null
+++ b/src/twisted/web/newsfragments/11716.bugfix
@@ -0,0 +1 @@
+twisted.web.vhost.NameVirtualHost no longer echoes HTML received in the Host header without escaping it (GHSA-vg46-2rrj-3647).
diff --git a/src/twisted/web/vhost.py b/src/twisted/web/vhost.py
index 2c305f94374..9576252b0f2 100644
--- a/src/twisted/web/vhost.py
+++ b/src/twisted/web/vhost.py
@@ -9,7 +9,7 @@
 
 # Twisted Imports
 from twisted.python import roots
-from twisted.web import resource
+from twisted.web import pages, resource
 
 
 class VirtualHostCollection(roots.Homogenous):
@@ -77,12 +77,13 @@ def removeHost(self, name):
     def _getResourceForRequest(self, request):
         """(Internal) Get the appropriate resource for the given host."""
         hostHeader = request.getHeader(b"host")
-        if hostHeader == None:
-            return self.default or resource.NoResource()
+        if hostHeader is None:
+            return self.default or pages.notFound()
         else:
             host = hostHeader.lower().split(b":", 1)[0]
-        return self.hosts.get(host, self.default) or resource.NoResource(
-            "host %s not in vhost map" % repr(host)
+        return self.hosts.get(host, self.default) or pages.notFound(
+            "Not Found",
+            f"host {host.decode('ascii', 'replace')!r} not in vhost map",
         )
 
     def render(self, request):

From fee019520ebe31b79c904237e4ac3a7c86d65461 Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Mon, 24 Oct 2022 21:14:07 -0700
Subject: [PATCH 09/12] Fix references to twisted.pages

---
 src/twisted/web/resource.py           | 12 ++++++------
 src/twisted/web/test/test_resource.py |  6 +++---
 2 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/src/twisted/web/resource.py b/src/twisted/web/resource.py
index 09fc74a89fd..f196c3ac6b9 100644
--- a/src/twisted/web/resource.py
+++ b/src/twisted/web/resource.py
@@ -299,7 +299,7 @@ class _UnsafeErrorPage(Resource):
     descriptive text.  This is useful for rendering simple error pages.
 
     Deprecated in Twisted NEXT because it permits HTML injection; use
-    L{twisted.pages.ErrorPage} instead.
+    L{twisted.web.pages.ErrorPage} instead.
 
     @ivar template: A native string which will have a dictionary interpolated
         into it to generate the response body.  The dictionary has the following
@@ -359,7 +359,7 @@ class _UnsafeNoResource(_UnsafeErrorPage):
     returns the HTTP response code I{NOT FOUND}.
 
     Deprecated in Twisted NEXT because it permits HTML injection; use
-    L{twisted.pages.notFound} instead.
+    L{twisted.web.pages.notFound} instead.
     """
 
     def __init__(self, message="Sorry. No luck finding that resource."):
@@ -373,7 +373,7 @@ class _UnsafeForbiddenResource(_UnsafeErrorPage):
     returns the I{FORBIDDEN} HTTP response code.
 
     Deprecated in Twisted NEXT because it permits HTML injection; use
-    L{twisted.pages.forbidden} instead.
+    L{twisted.web.pages.forbidden} instead.
     """
 
     def __init__(self, message="Sorry, resource is forbidden."):
@@ -387,21 +387,21 @@ def __init__(self, message="Sorry, resource is forbidden."):
 
 deprecatedModuleAttribute(
     Version("Twisted", "NEXT", 0, 0),
-    "Use twisted.pages.ErrorPage instead, which properly escapes HTML.",
+    "Use twisted.web.pages.ErrorPage instead, which properly escapes HTML.",
     __name__,
     "ErrorPage",
 )
 
 deprecatedModuleAttribute(
     Version("Twisted", "NEXT", 0, 0),
-    "Use twisted.pages.notFound instead, which properly escapes HTML.",
+    "Use twisted.web.pages.notFound instead, which properly escapes HTML.",
     __name__,
     "NoResource",
 )
 
 deprecatedModuleAttribute(
     Version("Twisted", "NEXT", 0, 0),
-    "Use twisted.pages.forbidden instead, which properly escapes HTML.",
+    "Use twisted.web.pages.forbidden instead, which properly escapes HTML.",
     __name__,
     "ForbiddenResource",
 )
diff --git a/src/twisted/web/test/test_resource.py b/src/twisted/web/test/test_resource.py
index c039704a79d..cb37942dbb8 100644
--- a/src/twisted/web/test/test_resource.py
+++ b/src/twisted/web/test/test_resource.py
@@ -42,7 +42,7 @@ def test_deprecatedErrorPage(self):
 
         [warning] = self.flushWarnings()
         self.assertEqual(warning["category"], DeprecationWarning)
-        self.assertIn("twisted.pages.ErrorPage", warning["message"])
+        self.assertIn("twisted.web.pages.ErrorPage", warning["message"])
 
     def test_deprecatedNoResource(self):
         """
@@ -56,7 +56,7 @@ def test_deprecatedNoResource(self):
 
         [warning] = self.flushWarnings()
         self.assertEqual(warning["category"], DeprecationWarning)
-        self.assertIn("twisted.pages.notFound", warning["message"])
+        self.assertIn("twisted.web.pages.notFound", warning["message"])
 
     def test_deprecatedForbiddenResource(self):
         """
@@ -70,7 +70,7 @@ def test_deprecatedForbiddenResource(self):
 
         [warning] = self.flushWarnings()
         self.assertEqual(warning["category"], DeprecationWarning)
-        self.assertIn("twisted.pages.forbidden", warning["message"])
+        self.assertIn("twisted.web.pages.forbidden", warning["message"])
 
     def test_getChild(self):
         """

From 09ce75e3a7ee0675812ccd7e7164fb4a39970e38 Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Mon, 24 Oct 2022 21:25:21 -0700
Subject: [PATCH 10/12] Call the superclass constructor via private alias

---
 src/twisted/web/resource.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/twisted/web/resource.py b/src/twisted/web/resource.py
index f196c3ac6b9..de3b557048a 100644
--- a/src/twisted/web/resource.py
+++ b/src/twisted/web/resource.py
@@ -363,7 +363,7 @@ class _UnsafeNoResource(_UnsafeErrorPage):
     """
 
     def __init__(self, message="Sorry. No luck finding that resource."):
-        ErrorPage.__init__(self, NOT_FOUND, "No Such Resource", message)
+        _UnsafeErrorPage.__init__(self, NOT_FOUND, "No Such Resource", message)
 
 
 class _UnsafeForbiddenResource(_UnsafeErrorPage):
@@ -377,7 +377,7 @@ class _UnsafeForbiddenResource(_UnsafeErrorPage):
     """
 
     def __init__(self, message="Sorry, resource is forbidden."):
-        ErrorPage.__init__(self, FORBIDDEN, "Forbidden Resource", message)
+        _UnsafeErrorPage.__init__(self, FORBIDDEN, "Forbidden Resource", message)
 
 
 # Deliberately undocumented public aliases. See GHSA-vg46-2rrj-3647.

From c0da7805fbb30611df8ac1bce3dffa2c6659373c Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Mon, 24 Oct 2022 21:42:30 -0700
Subject: [PATCH 11/12] =?UTF-8?q?twisted.web.pages.{ErrorPage=20=E2=86=92?=
 =?UTF-8?q?=20errorPage}?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 src/twisted/web/newsfragments/11716.feature |  2 +-
 src/twisted/web/pages.py                    | 69 +++++++++++++--------
 src/twisted/web/resource.py                 |  4 +-
 src/twisted/web/test/test_pages.py          | 15 ++---
 src/twisted/web/test/test_resource.py       |  2 +-
 5 files changed, 56 insertions(+), 36 deletions(-)

diff --git a/src/twisted/web/newsfragments/11716.feature b/src/twisted/web/newsfragments/11716.feature
index e8ba00b7ef2..bdcd36d17bd 100644
--- a/src/twisted/web/newsfragments/11716.feature
+++ b/src/twisted/web/newsfragments/11716.feature
@@ -1 +1 @@
-The twisted.web.pages.ErrorPage, notFound, and forbidden IResource implementations provide HTML error pages safely rendered using twisted.web.template.
+The twisted.web.pages.errorPage, notFound, and forbidden each return an IResource that displays an HTML error pages safely rendered using twisted.web.template.
diff --git a/src/twisted/web/pages.py b/src/twisted/web/pages.py
index 524148fba26..002b8a95d90 100644
--- a/src/twisted/web/pages.py
+++ b/src/twisted/web/pages.py
@@ -7,7 +7,7 @@
 """
 
 __all__ = (
-    "ErrorPage",
+    "errorPage",
     "notFound",
     "forbidden",
 )
@@ -16,15 +16,17 @@
 
 from twisted.web import http
 from twisted.web.iweb import IRenderable, IRequest
-from twisted.web.resource import Resource
+from twisted.web.resource import IResource, Resource
 from twisted.web.template import renderElement, tags
 
 
-class ErrorPage(Resource):
+class _ErrorPage(Resource):
     """
-    L{ErrorPage} is a resource that responds to all requests with a particular
-    (parameterized) HTTP status code and a body consisting of HTML containing
-    some descriptive text. This is useful for rendering simple error pages.
+    L{_ErrorPage} is a resource that responds to all requests with a particular
+    (parameterized) HTTP status code and an HTML body containing some
+    descriptive text. This is useful for rendering simple error pages.
+
+    @see: L{twisted.web.pages.errorPage}
 
     @ivar _code: An integer HTTP status code which will be used for the
         response.
@@ -36,16 +38,6 @@ class ErrorPage(Resource):
     """
 
     def __init__(self, code: int, brief: str, detail: str) -> None:
-        """
-        @param code: An integer HTTP status code which will be used for the
-            response.
-
-        @param brief: A short string which will be included in the response
-            body as the page title.
-
-        @param detail: A longer string which will be included in the
-            response body.
-        """
         super().__init__()
         self._code: int = code
         self._brief: str = brief
@@ -74,7 +66,7 @@ def render(self, request: IRequest) -> object:
 
     def getChild(self, path: bytes, request: IRequest) -> Resource:
         """
-        Handle all requests for which L{ErrorPage} lacks a child by returning
+        Handle all requests for which L{_ErrorPage} lacks a child by returning
         this error page.
 
         @param path: A path segment.
@@ -84,32 +76,59 @@ def getChild(self, path: bytes, request: IRequest) -> Resource:
         return self
 
 
+def errorPage(code: int, brief: str, detail: str) -> IResource:
+    """
+    Build a resource that responds to all requests with a particular HTTP
+    status code and an HTML body containing some descriptive text. This is
+    useful for rendering simple error pages.
+
+    The resource dynamically handles all paths below it. Use
+    L{IResource.putChild()} override specific path.
+
+    @param code: An integer HTTP status code which will be used for the
+        response.
+
+    @param brief: A short string which will be included in the response
+        body as the page title.
+
+    @param detail: A longer string which will be included in the
+        response body.
+
+    @returns: An L{IResource}
+    """
+    return _ErrorPage(code, brief, detail)
+
+
 def notFound(
     brief: str = "No Such Resource",
     message: str = "Sorry. No luck finding that resource.",
-) -> ErrorPage:
+) -> IResource:
     """
-    Generate an L{ErrorPage} with a 404 Not Found status code.
+    Generate an L{IResource} with a 404 Not Found status code.
+
+    @see: L{twisted.web.pages.errorPage}
 
     @param brief: A short string displayed as the page title.
 
     @param brief: A longer string displayed in the page body.
 
-    @returns: An L{ErrorPage}
+    @returns: An L{IResource}
     """
-    return ErrorPage(http.NOT_FOUND, brief, message)
+    return _ErrorPage(http.NOT_FOUND, brief, message)
 
 
 def forbidden(
     brief: str = "Forbidden Resource", message: str = "Sorry, resource is forbidden."
-) -> ErrorPage:
+) -> IResource:
     """
-    Generate an L{ErrorPage} with a 403 Forbidden status code.
+    Generate an L{IResource} with a 403 Forbidden status code.
+
+    @see: L{twisted.web.pages.errorPage}
 
     @param brief: A short string displayed as the page title.
 
     @param brief: A longer string displayed in the page body.
 
-    @returns: An L{ErrorPage}
+    @returns: An L{IResource}
     """
-    return ErrorPage(http.FORBIDDEN, brief, message)
+    return _ErrorPage(http.FORBIDDEN, brief, message)
diff --git a/src/twisted/web/resource.py b/src/twisted/web/resource.py
index de3b557048a..670940f2086 100644
--- a/src/twisted/web/resource.py
+++ b/src/twisted/web/resource.py
@@ -299,7 +299,7 @@ class _UnsafeErrorPage(Resource):
     descriptive text.  This is useful for rendering simple error pages.
 
     Deprecated in Twisted NEXT because it permits HTML injection; use
-    L{twisted.web.pages.ErrorPage} instead.
+    L{twisted.web.pages.errorPage} instead.
 
     @ivar template: A native string which will have a dictionary interpolated
         into it to generate the response body.  The dictionary has the following
@@ -387,7 +387,7 @@ def __init__(self, message="Sorry, resource is forbidden."):
 
 deprecatedModuleAttribute(
     Version("Twisted", "NEXT", 0, 0),
-    "Use twisted.web.pages.ErrorPage instead, which properly escapes HTML.",
+    "Use twisted.web.pages.errorPage instead, which properly escapes HTML.",
     __name__,
     "ErrorPage",
 )
diff --git a/src/twisted/web/test/test_pages.py b/src/twisted/web/test/test_pages.py
index 1f66c16afc9..acd9b978fe0 100644
--- a/src/twisted/web/test/test_pages.py
+++ b/src/twisted/web/test/test_pages.py
@@ -10,11 +10,12 @@
 from twisted.trial.unittest import SynchronousTestCase
 from twisted.web.http_headers import Headers
 from twisted.web.iweb import IRequest
-from twisted.web.pages import ErrorPage, forbidden, notFound
+from twisted.web.pages import errorPage, forbidden, notFound
+from twisted.web.resource import IResource
 from twisted.web.test.requesthelper import DummyRequest
 
 
-def _render(resource: ErrorPage) -> DummyRequest:
+def _render(resource: IResource) -> DummyRequest:
     """
     Render a response using the given resource.
 
@@ -32,7 +33,7 @@ def _render(resource: ErrorPage) -> DummyRequest:
 
 class ErrorPageTests(SynchronousTestCase):
     """
-    Test L{twisted.web.pages.ErrorPage} and its convencience helpers
+    Test L{twisted.web.pages._ErrorPage} and its public aliases L{errorPage},
     L{notFound} and L{forbidden}.
     """
 
@@ -56,7 +57,7 @@ def test_escapesHTML(self):
         The I{brief} and I{detail} parameters are HTML-escaped on render.
         """
         self.assertResponse(
-            _render(ErrorPage(400, "A & B", "<script>alert('oops!')")),
+            _render(errorPage(400, "A & B", "<script>alert('oops!')")),
             400,
             (
                 b"<!DOCTYPE html>\n"
@@ -68,10 +69,10 @@ def test_escapesHTML(self):
 
     def test_getChild(self):
         """
-        The C{getChild} method of L{ErrorPage} returns the L{ErrorPage} it is
-        called on.
+        The C{getChild} method of the resource returned by L{errorPage} returns
+        the L{_ErrorPage} it is called on.
         """
-        page = ErrorPage(404, "foo", "bar")
+        page = errorPage(404, "foo", "bar")
         self.assertIs(
             page.getChild(b"name", DummyRequest([b""])),
             page,
diff --git a/src/twisted/web/test/test_resource.py b/src/twisted/web/test/test_resource.py
index cb37942dbb8..72e9137c1c8 100644
--- a/src/twisted/web/test/test_resource.py
+++ b/src/twisted/web/test/test_resource.py
@@ -42,7 +42,7 @@ def test_deprecatedErrorPage(self):
 
         [warning] = self.flushWarnings()
         self.assertEqual(warning["category"], DeprecationWarning)
-        self.assertIn("twisted.web.pages.ErrorPage", warning["message"])
+        self.assertIn("twisted.web.pages.errorPage", warning["message"])
 
     def test_deprecatedNoResource(self):
         """

From 78662333e70eb1b440e7d0025f4fc376709c19ac Mon Sep 17 00:00:00 2001
From: Tom Most <twm@freecog.net>
Date: Mon, 24 Oct 2022 22:30:42 -0700
Subject: [PATCH 12/12] Add CVE to newsfragment

---
 src/twisted/web/newsfragments/11716.bugfix | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/twisted/web/newsfragments/11716.bugfix b/src/twisted/web/newsfragments/11716.bugfix
index 66189e685c5..5264c8fc202 100644
--- a/src/twisted/web/newsfragments/11716.bugfix
+++ b/src/twisted/web/newsfragments/11716.bugfix
@@ -1 +1 @@
-twisted.web.vhost.NameVirtualHost no longer echoes HTML received in the Host header without escaping it (GHSA-vg46-2rrj-3647).
+twisted.web.vhost.NameVirtualHost no longer echoes HTML received in the Host header without escaping it (CVE-2022-39348, GHSA-vg46-2rrj-3647).
