File CVE-2022-39348-do-not-echo-host-header.patch of Package python3-Twisted.34928

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

---
 docs/web/howto/web-in-60/error-handling.rst |   26 +----
 src/twisted/web/_auth/wrapper.py            |    8 -
 src/twisted/web/_template_util.py           |    6 -
 src/twisted/web/distrib.py                  |    7 -
 src/twisted/web/newsfragments/11716.bugfix  |    1 
 src/twisted/web/newsfragments/11716.feature |    1 
 src/twisted/web/newsfragments/11716.removal |    1 
 src/twisted/web/pages.py                    |  134 ++++++++++++++++++++++++++++
 src/twisted/web/resource.py                 |   75 ++++++++++++---
 src/twisted/web/script.py                   |   14 +-
 src/twisted/web/server.py                   |   11 +-
 src/twisted/web/static.py                   |    6 -
 src/twisted/web/test/test_pages.py          |  113 +++++++++++++++++++++++
 src/twisted/web/test/test_resource.py       |   51 +++++++++-
 src/twisted/web/test/test_vhost.py          |   19 +++
 src/twisted/web/vhost.py                    |   11 +-
 16 files changed, 416 insertions(+), 68 deletions(-)
 create mode 100644 src/twisted/web/newsfragments/11716.feature
 create mode 100644 src/twisted/web/newsfragments/11716.removal

--- 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
 
 
 
-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
 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 r
             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)
 
--- a/src/twisted/web/_auth/wrapper.py
+++ b/src/twisted/web/_auth/wrapper.py
@@ -21,7 +21,7 @@ from twisted.cred.credentials import Ano
 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 @@ class UnauthorizedResource:
             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 @@ class HTTPAuthSessionWrapper:
             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 @@ class HTTPAuthSessionWrapper:
                 "unexpected error",
                 failure=result,
             )
-            return ErrorPage(500, None, None)
+            return _UnsafeErrorPage(500, "Internal Error", "")
 
     def _selectParseHeader(self, header):
         """
--- 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.
--- a/src/twisted/web/distrib.py
+++ b/src/twisted/web/distrib.py
@@ -127,9 +127,10 @@ class Issue:
         # 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 @@ class UserDirectory(resource.Resource):
                 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 @@ class UserDirectory(resource.Resource):
         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)
--- /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 (CVE-2022-39348, GHSA-vg46-2rrj-3647).
--- /dev/null
+++ b/src/twisted/web/newsfragments/11716.feature
@@ -0,0 +1 @@
+The twisted.web.pages.errorPage, notFound, and forbidden each return an IResource that displays an HTML error pages safely rendered using twisted.web.template.
--- /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.
--- /dev/null
+++ b/src/twisted/web/pages.py
@@ -0,0 +1,134 @@
+# -*- 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 typing import cast
+
+from twisted.web import http
+from twisted.web.iweb import IRenderable, IRequest
+from twisted.web.resource import IResource, 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 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.
+
+    @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:
+        super().__init__()
+        self._code: int = code
+        self._brief: str = brief
+        self._detail: str = detail
+
+    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")
+        return renderElement(
+            request,
+            # 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)),
+                ),
+            ),
+        )
+
+    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 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.",
+) -> IResource:
+    """
+    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{IResource}
+    """
+    return _ErrorPage(http.NOT_FOUND, brief, message)
+
+
+def forbidden(
+    brief: str = "Forbidden Resource", message: str = "Sorry, resource is forbidden."
+) -> IResource:
+    """
+    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{IResource}
+    """
+    return _ErrorPage(http.FORBIDDEN, brief, message)
--- 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 @@ import warnings
 
 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
@@ -178,7 +183,7 @@ class Resource:
         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):
         """
@@ -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.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
         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,24 +352,59 @@ class ErrorPage(Resource):
         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.web.pages.notFound} instead.
     """
 
     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 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.web.pages.forbidden} instead.
     """
 
     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.
+ErrorPage = _UnsafeErrorPage
+NoResource = _UnsafeNoResource
+ForbiddenResource = _UnsafeForbiddenResource
+
+deprecatedModuleAttribute(
+    Version("Twisted", "NEXT", 0, 0),
+    "Use twisted.web.pages.errorPage instead, which properly escapes HTML.",
+    __name__,
+    "ErrorPage",
+)
+
+deprecatedModuleAttribute(
+    Version("Twisted", "NEXT", 0, 0),
+    "Use twisted.web.pages.notFound instead, which properly escapes HTML.",
+    __name__,
+    "NoResource",
+)
+
+deprecatedModuleAttribute(
+    Version("Twisted", "NEXT", 0, 0),
+    "Use twisted.web.pages.forbidden instead, which properly escapes HTML.",
+    __name__,
+    "ForbiddenResource",
+)
 
 
 class _IEncodingResource(Interface):
--- a/src/twisted/web/script.py
+++ b/src/twisted/web/script.py
@@ -49,7 +49,7 @@ class CacheScanner:
         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 @@ class ResourceScriptDirectory(resource.R
             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 @@ class PythonScript(resource.Resource):
         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)
--- a/src/twisted/web/server.py
+++ b/src/twisted/web/server.py
@@ -334,10 +334,12 @@ class Request(Copyable, http.Request, co
                         "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."
@@ -349,10 +351,11 @@ class Request(Copyable, http.Request, co
         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: "
@@ -606,7 +609,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):
--- a/src/twisted/web/static.py
+++ b/src/twisted/web/static.py
@@ -31,7 +31,7 @@ from twisted.python.util import Insensit
 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 @@ class File(resource.Resource, filepath.F
         """
         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):
         """
--- /dev/null
+++ b/src/twisted/web/test/test_pages.py
@@ -0,0 +1,113 @@
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+"""
+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.resource import IResource
+from twisted.web.test.requesthelper import DummyRequest
+
+
+def _render(resource: IResource) -> 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""])
+    # 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
+
+
+class ErrorPageTests(SynchronousTestCase):
+    """
+    Test L{twisted.web.pages._ErrorPage} and its public aliases L{errorPage},
+    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 the resource returned by 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>"
+            ),
+        )
--- a/src/twisted/web/test/test_resource.py
+++ b/src/twisted/web/test/test_resource.py
@@ -11,10 +11,10 @@ from twisted.web.http_headers import Hea
 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 @@ from twisted.web.test.requesthelper impo
 
 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.web.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.web.pages.notFound", 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.web.pages.forbidden", warning["message"])
+
     def test_getChild(self):
         """
         The C{getChild} method of L{ErrorPage} returns the L{ErrorPage} it is
--- a/src/twisted/web/test/test_vhost.py
+++ b/src/twisted/web/test/test_vhost.py
@@ -66,7 +66,7 @@ class NameVirtualHostTests(TestCase):
         """
         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 @@ class NameVirtualHostTests(TestCase):
         header in the request.
         """
         virtualHostResource = NameVirtualHost()
-        request = DummyRequest([""])
+        request = DummyRequest([b""])
         d = _render(virtualHostResource, request)
 
         def cbRendered(ignored):
@@ -140,7 +140,7 @@ class NameVirtualHostTests(TestCase):
         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 @@ class NameVirtualHostTests(TestCase):
         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
--- a/src/twisted/web/vhost.py
+++ b/src/twisted/web/vhost.py
@@ -9,7 +9,7 @@ I am a virtual hosts implementation.
 
 # 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 @@ class NameVirtualHost(resource.Resource)
     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):
openSUSE Build Service is sponsored by