Synapse 1.15.2 (2020-07-02)

===========================
 
 Due to the two security issues highlighted below, server administrators are
 encouraged to update Synapse. We are not aware of these vulnerabilities being
 exploited in the wild.
 
 Security advisory
 -----------------
 
 * A malicious homeserver could force Synapse to reset the state in a room to a
   small subset of the correct state. This affects all Synapse deployments which
   federate with untrusted servers. ([96e9afe6](96e9afe625))
 * HTML pages served via Synapse were vulnerable to clickjacking attacks. This
   predominantly affects homeservers with single-sign-on enabled, but all server
   administrators are encouraged to upgrade. ([ea26e9a9](ea26e9a98b))
 
   This was reported by [Quentin Gliech](https://sandhose.fr/).
 -----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEF3tZXk38tRDFVnUIM/xY9qcRMEgFAl799QkACgkQM/xY9qcR
 MEhKzQ/+JJCbIuaymKQuyZRRt4b2ylXmMjfM8LpYgwk1vEUN2z+NNt4pmbFQtvdJ
 Q1unHToDIK8b080DMagAc55MEF8GRtl8D411iGgSDeI/AqgVnsBTOW1cd7gDc0LC
 eEs3jwnL5TYDeZYZUGqu+OfoPbdGnUX8ywQYTXk8y0njELwnoJdMuHSMq8kgsMur
 eQ1cryevidpJiDQZlZFJQzlGoMrr4Aq94BZHooXfAdJnwCoIR/EVW4iie8GKSaNa
 OT5tVYg8l4KzBOWZBrtXeeIKVNh7HHie8aJRJVXAGq/3vAEDT8HTAxPNJ6Ru4DA9
 2VrflzmuRl9phxybfq2m1G1AvNkOlKu67e21YTSKK9EG/52VJoSXzKEeP9hdMfj5
 v/Xfm7v1WqolukZZMc9zyleCoAK2Znu32/0/PYGsgw/vX7wGoCORdP22/vVfuCni
 ZpUkZPlCA5XyD4QAyegzTVlp94IRI5oCErl6v1mESAaSkKyaGZ5jejTFWzOsKMuo
 TpyCLLz6ZKLCtxsU6e7nGwDV7dX2iztq8fGf9+8lFsdXCbdI0YsyzAE8reehK9lL
 rYxzl7fV+m6kzYg+pu3bfjH/YYgkPTvnV4juCOT/LQV7P3sEJAQrYBceIpAzyuS7
 t0kCWTfX4UDrt1XbouuWJnvIHAFOG5/o/BEyhkQmW1c3GvDe8Jo=
 =QQ4B
 -----END PGP SIGNATURE-----

Merge tag 'v1.15.2'

Synapse 1.15.2 (2020-07-02)
===========================

Due to the two security issues highlighted below, server administrators are
encouraged to update Synapse. We are not aware of these vulnerabilities being
exploited in the wild.

Security advisory
-----------------

* A malicious homeserver could force Synapse to reset the state in a room to a
  small subset of the correct state. This affects all Synapse deployments which
  federate with untrusted servers. ([96e9afe6](96e9afe625))
* HTML pages served via Synapse were vulnerable to clickjacking attacks. This
  predominantly affects homeservers with single-sign-on enabled, but all server
  administrators are encouraged to upgrade. ([ea26e9a9](ea26e9a98b))

  This was reported by [Quentin Gliech](https://sandhose.fr/).
This commit is contained in:
Patrick Cloke 2020-07-02 10:54:29 -04:00
commit fedb632d0a
14 changed files with 133 additions and 98 deletions

View file

@ -1,3 +1,23 @@
Synapse 1.15.2 (2020-07-02)
===========================
Due to the two security issues highlighted below, server administrators are
encouraged to update Synapse. We are not aware of these vulnerabilities being
exploited in the wild.
Security advisory
-----------------
* A malicious homeserver could force Synapse to reset the state in a room to a
small subset of the correct state. This affects all Synapse deployments which
federate with untrusted servers. ([96e9afe6](https://github.com/matrix-org/synapse/commit/96e9afe62500310977dc3cbc99a8d16d3d2fa15c))
* HTML pages served via Synapse were vulnerable to clickjacking attacks. This
predominantly affects homeservers with single-sign-on enabled, but all server
administrators are encouraged to upgrade. ([ea26e9a9](https://github.com/matrix-org/synapse/commit/ea26e9a98b0541fc886a1cb826a38352b7599dbe))
This was reported by [Quentin Gliech](https://sandhose.fr/).
Synapse 1.15.1 (2020-06-16) Synapse 1.15.1 (2020-06-16)
=========================== ===========================

6
debian/changelog vendored
View file

@ -1,3 +1,9 @@
matrix-synapse-py3 (1.15.2) stable; urgency=medium
* New synapse release 1.15.2.
-- Synapse Packaging team <packages@matrix.org> Thu, 02 Jul 2020 10:34:00 -0400
matrix-synapse-py3 (1.15.1) stable; urgency=medium matrix-synapse-py3 (1.15.1) stable; urgency=medium
* New synapse release 1.15.1. * New synapse release 1.15.1.

View file

@ -36,7 +36,7 @@ try:
except ImportError: except ImportError:
pass pass
__version__ = "1.15.1" __version__ = "1.15.2"
if bool(os.environ.get("SYNAPSE_TEST_PATCH_LOG_CONTEXTS", False)): if bool(os.environ.get("SYNAPSE_TEST_PATCH_LOG_CONTEXTS", False)):
# We import here so that we don't have to install a bunch of deps when # We import here so that we don't have to install a bunch of deps when

View file

@ -56,6 +56,7 @@ from synapse.http.server import (
OptionsResource, OptionsResource,
RootOptionsRedirectResource, RootOptionsRedirectResource,
RootRedirect, RootRedirect,
StaticResource,
) )
from synapse.http.site import SynapseSite from synapse.http.site import SynapseSite
from synapse.logging.context import LoggingContext from synapse.logging.context import LoggingContext
@ -228,7 +229,7 @@ class SynapseHomeServer(HomeServer):
if name in ["static", "client"]: if name in ["static", "client"]:
resources.update( resources.update(
{ {
STATIC_PREFIX: File( STATIC_PREFIX: StaticResource(
os.path.join(os.path.dirname(synapse.__file__), "static") os.path.join(os.path.dirname(synapse.__file__), "static")
) )
} }

View file

@ -38,7 +38,7 @@ from synapse.api.errors import (
from synapse.api.ratelimiting import Ratelimiter from synapse.api.ratelimiting import Ratelimiter
from synapse.handlers.ui_auth import INTERACTIVE_AUTH_CHECKERS from synapse.handlers.ui_auth import INTERACTIVE_AUTH_CHECKERS
from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
from synapse.http.server import finish_request from synapse.http.server import finish_request, respond_with_html
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
from synapse.logging.context import defer_to_thread from synapse.logging.context import defer_to_thread
from synapse.metrics.background_process_metrics import run_as_background_process from synapse.metrics.background_process_metrics import run_as_background_process
@ -1055,13 +1055,8 @@ class AuthHandler(BaseHandler):
) )
# Render the HTML and return. # Render the HTML and return.
html_bytes = self._sso_auth_success_template.encode("utf-8") html = self._sso_auth_success_template
request.setResponseCode(200) respond_with_html(request, 200, html)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
async def complete_sso_login( async def complete_sso_login(
self, self,
@ -1081,13 +1076,7 @@ class AuthHandler(BaseHandler):
# flow. # flow.
deactivated = await self.store.get_user_deactivated_status(registered_user_id) deactivated = await self.store.get_user_deactivated_status(registered_user_id)
if deactivated: if deactivated:
html_bytes = self._sso_account_deactivated_template.encode("utf-8") respond_with_html(request, 403, self._sso_account_deactivated_template)
request.setResponseCode(403)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
return return
self._complete_sso_login(registered_user_id, request, client_redirect_url) self._complete_sso_login(registered_user_id, request, client_redirect_url)
@ -1128,17 +1117,12 @@ class AuthHandler(BaseHandler):
# URL we redirect users to. # URL we redirect users to.
redirect_url_no_params = client_redirect_url.split("?")[0] redirect_url_no_params = client_redirect_url.split("?")[0]
html_bytes = self._sso_redirect_confirm_template.render( html = self._sso_redirect_confirm_template.render(
display_url=redirect_url_no_params, display_url=redirect_url_no_params,
redirect_url=redirect_url, redirect_url=redirect_url,
server_name=self._server_name, server_name=self._server_name,
).encode("utf-8") )
respond_with_html(request, 200, html)
request.setResponseCode(200)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
@staticmethod @staticmethod
def add_query_param_to_url(url: str, param_name: str, param: Any): def add_query_param_to_url(url: str, param_name: str, param: Any):

View file

@ -238,7 +238,7 @@ class FederationHandler(BaseHandler):
logger.debug("[%s %s] min_depth: %d", room_id, event_id, min_depth) logger.debug("[%s %s] min_depth: %d", room_id, event_id, min_depth)
prevs = set(pdu.prev_event_ids()) prevs = set(pdu.prev_event_ids())
seen = await self.store.have_seen_events(prevs) seen = await self.store.have_events_in_timeline(prevs)
if min_depth is not None and pdu.depth < min_depth: if min_depth is not None and pdu.depth < min_depth:
# This is so that we don't notify the user about this # This is so that we don't notify the user about this
@ -278,7 +278,7 @@ class FederationHandler(BaseHandler):
# Update the set of things we've seen after trying to # Update the set of things we've seen after trying to
# fetch the missing stuff # fetch the missing stuff
seen = await self.store.have_seen_events(prevs) seen = await self.store.have_events_in_timeline(prevs)
if not prevs - seen: if not prevs - seen:
logger.info( logger.info(
@ -423,7 +423,7 @@ class FederationHandler(BaseHandler):
room_id = pdu.room_id room_id = pdu.room_id
event_id = pdu.event_id event_id = pdu.event_id
seen = await self.store.have_seen_events(prevs) seen = await self.store.have_events_in_timeline(prevs)
if not prevs - seen: if not prevs - seen:
return return

View file

@ -35,7 +35,7 @@ from typing_extensions import TypedDict
from twisted.web.client import readBody from twisted.web.client import readBody
from synapse.config import ConfigError from synapse.config import ConfigError
from synapse.http.server import finish_request from synapse.http.server import respond_with_html
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable from synapse.logging.context import make_deferred_yieldable
from synapse.push.mailer import load_jinja2_templates from synapse.push.mailer import load_jinja2_templates
@ -144,15 +144,10 @@ class OidcHandler:
access_denied. access_denied.
error_description: A human-readable description of the error. error_description: A human-readable description of the error.
""" """
html_bytes = self._error_template.render( html = self._error_template.render(
error=error, error_description=error_description error=error, error_description=error_description
).encode("utf-8") )
respond_with_html(request, 400, html)
request.setResponseCode(400)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%i" % len(html_bytes))
request.write(html_bytes)
finish_request(request)
def _validate_metadata(self): def _validate_metadata(self):
"""Verifies the provider metadata. """Verifies the provider metadata.

View file

@ -30,7 +30,7 @@ from twisted.internet import defer
from twisted.python import failure from twisted.python import failure
from twisted.web import resource from twisted.web import resource
from twisted.web.server import NOT_DONE_YET, Request from twisted.web.server import NOT_DONE_YET, Request
from twisted.web.static import NoRangeStaticProducer from twisted.web.static import File, NoRangeStaticProducer
from twisted.web.util import redirectTo from twisted.web.util import redirectTo
import synapse.events import synapse.events
@ -202,12 +202,7 @@ def return_html_error(
else: else:
body = error_template.render(code=code, msg=msg) body = error_template.render(code=code, msg=msg)
body_bytes = body.encode("utf-8") respond_with_html(request, code, body)
request.setResponseCode(code)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%i" % (len(body_bytes),))
request.write(body_bytes)
finish_request(request)
def wrap_async_request_handler(h): def wrap_async_request_handler(h):
@ -420,6 +415,18 @@ class DirectServeResource(resource.Resource):
return NOT_DONE_YET return NOT_DONE_YET
class StaticResource(File):
"""
A resource that represents a plain non-interpreted file or directory.
Differs from the File resource by adding clickjacking protection.
"""
def render_GET(self, request: Request):
set_clickjacking_protection_headers(request)
return super().render_GET(request)
def _options_handler(request): def _options_handler(request):
"""Request handler for OPTIONS requests """Request handler for OPTIONS requests
@ -530,7 +537,7 @@ def respond_with_json_bytes(
code (int): The HTTP response code. code (int): The HTTP response code.
json_bytes (bytes): The json bytes to use as the response body. json_bytes (bytes): The json bytes to use as the response body.
send_cors (bool): Whether to send Cross-Origin Resource Sharing headers send_cors (bool): Whether to send Cross-Origin Resource Sharing headers
http://www.w3.org/TR/cors/ https://fetch.spec.whatwg.org/#http-cors-protocol
Returns: Returns:
twisted.web.server.NOT_DONE_YET""" twisted.web.server.NOT_DONE_YET"""
@ -568,6 +575,59 @@ def set_cors_headers(request):
) )
def respond_with_html(request: Request, code: int, html: str):
"""
Wraps `respond_with_html_bytes` by first encoding HTML from a str to UTF-8 bytes.
"""
respond_with_html_bytes(request, code, html.encode("utf-8"))
def respond_with_html_bytes(request: Request, code: int, html_bytes: bytes):
"""
Sends HTML (encoded as UTF-8 bytes) as the response to the given request.
Note that this adds clickjacking protection headers and finishes the request.
Args:
request: The http request to respond to.
code: The HTTP response code.
html_bytes: The HTML bytes to use as the response body.
"""
# could alternatively use request.notifyFinish() and flip a flag when
# the Deferred fires, but since the flag is RIGHT THERE it seems like
# a waste.
if request._disconnected:
logger.warning(
"Not sending response to request %s, already disconnected.", request
)
return
request.setResponseCode(code)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
# Ensure this content cannot be embedded.
set_clickjacking_protection_headers(request)
request.write(html_bytes)
finish_request(request)
def set_clickjacking_protection_headers(request: Request):
"""
Set headers to guard against clickjacking of embedded content.
This sets the X-Frame-Options and Content-Security-Policy headers which instructs
browsers to not allow the HTML of the response to be embedded onto another
page.
Args:
request: The http request to add the headers to.
"""
request.setHeader(b"X-Frame-Options", b"DENY")
request.setHeader(b"Content-Security-Policy", b"frame-ancestors 'none';")
def finish_request(request): def finish_request(request):
""" Finish writing the response to the request. """ Finish writing the response to the request.

View file

@ -16,7 +16,7 @@
import logging import logging
from synapse.api.errors import Codes, StoreError, SynapseError from synapse.api.errors import Codes, StoreError, SynapseError
from synapse.http.server import finish_request from synapse.http.server import respond_with_html_bytes
from synapse.http.servlet import ( from synapse.http.servlet import (
RestServlet, RestServlet,
assert_params_in_dict, assert_params_in_dict,
@ -177,13 +177,9 @@ class PushersRemoveRestServlet(RestServlet):
self.notifier.on_new_replication_data() self.notifier.on_new_replication_data()
request.setResponseCode(200) respond_with_html_bytes(
request.setHeader(b"Content-Type", b"text/html; charset=utf-8") request, 200, PushersRemoveRestServlet.SUCCESS_HTML,
request.setHeader(
b"Content-Length", b"%d" % (len(PushersRemoveRestServlet.SUCCESS_HTML),)
) )
request.write(PushersRemoveRestServlet.SUCCESS_HTML)
finish_request(request)
return None return None
def on_OPTIONS(self, _): def on_OPTIONS(self, _):

View file

@ -21,7 +21,7 @@ from six.moves import http_client
from synapse.api.constants import LoginType from synapse.api.constants import LoginType
from synapse.api.errors import Codes, SynapseError, ThreepidValidationError from synapse.api.errors import Codes, SynapseError, ThreepidValidationError
from synapse.config.emailconfig import ThreepidBehaviour from synapse.config.emailconfig import ThreepidBehaviour
from synapse.http.server import finish_request from synapse.http.server import finish_request, respond_with_html
from synapse.http.servlet import ( from synapse.http.servlet import (
RestServlet, RestServlet,
assert_params_in_dict, assert_params_in_dict,
@ -199,16 +199,15 @@ class PasswordResetSubmitTokenServlet(RestServlet):
# Otherwise show the success template # Otherwise show the success template
html = self.config.email_password_reset_template_success_html html = self.config.email_password_reset_template_success_html
request.setResponseCode(200) status_code = 200
except ThreepidValidationError as e: except ThreepidValidationError as e:
request.setResponseCode(e.code) status_code = e.code
# Show a failure page with a reason # Show a failure page with a reason
template_vars = {"failure_reason": e.msg} template_vars = {"failure_reason": e.msg}
html = self.failure_email_template.render(**template_vars) html = self.failure_email_template.render(**template_vars)
request.write(html.encode("utf-8")) respond_with_html(request, status_code, html)
finish_request(request)
class PasswordRestServlet(RestServlet): class PasswordRestServlet(RestServlet):
@ -571,16 +570,15 @@ class AddThreepidEmailSubmitTokenServlet(RestServlet):
# Otherwise show the success template # Otherwise show the success template
html = self.config.email_add_threepid_template_success_html_content html = self.config.email_add_threepid_template_success_html_content
request.setResponseCode(200) status_code = 200
except ThreepidValidationError as e: except ThreepidValidationError as e:
request.setResponseCode(e.code) status_code = e.code
# Show a failure page with a reason # Show a failure page with a reason
template_vars = {"failure_reason": e.msg} template_vars = {"failure_reason": e.msg}
html = self.failure_email_template.render(**template_vars) html = self.failure_email_template.render(**template_vars)
request.write(html.encode("utf-8")) respond_with_html(request, status_code, html)
finish_request(request)
class AddThreepidMsisdnSubmitTokenServlet(RestServlet): class AddThreepidMsisdnSubmitTokenServlet(RestServlet):

View file

@ -16,7 +16,7 @@
import logging import logging
from synapse.api.errors import AuthError, SynapseError from synapse.api.errors import AuthError, SynapseError
from synapse.http.server import finish_request from synapse.http.server import respond_with_html
from synapse.http.servlet import RestServlet from synapse.http.servlet import RestServlet
from ._base import client_patterns from ._base import client_patterns
@ -26,9 +26,6 @@ logger = logging.getLogger(__name__)
class AccountValidityRenewServlet(RestServlet): class AccountValidityRenewServlet(RestServlet):
PATTERNS = client_patterns("/account_validity/renew$") PATTERNS = client_patterns("/account_validity/renew$")
SUCCESS_HTML = (
b"<html><body>Your account has been successfully renewed.</body><html>"
)
def __init__(self, hs): def __init__(self, hs):
""" """
@ -59,11 +56,7 @@ class AccountValidityRenewServlet(RestServlet):
status_code = 404 status_code = 404
response = self.failure_html response = self.failure_html
request.setResponseCode(status_code) respond_with_html(request, status_code, response)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(response),))
request.write(response.encode("utf8"))
finish_request(request)
class AccountValiditySendMailServlet(RestServlet): class AccountValiditySendMailServlet(RestServlet):

View file

@ -18,7 +18,7 @@ import logging
from synapse.api.constants import LoginType from synapse.api.constants import LoginType
from synapse.api.errors import SynapseError from synapse.api.errors import SynapseError
from synapse.api.urls import CLIENT_API_PREFIX from synapse.api.urls import CLIENT_API_PREFIX
from synapse.http.server import finish_request from synapse.http.server import respond_with_html
from synapse.http.servlet import RestServlet, parse_string from synapse.http.servlet import RestServlet, parse_string
from ._base import client_patterns from ._base import client_patterns
@ -200,13 +200,7 @@ class AuthRestServlet(RestServlet):
raise SynapseError(404, "Unknown auth stage type") raise SynapseError(404, "Unknown auth stage type")
# Render the HTML and return. # Render the HTML and return.
html_bytes = html.encode("utf8") respond_with_html(request, 200, html)
request.setResponseCode(200)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
return None return None
async def on_POST(self, request, stagetype): async def on_POST(self, request, stagetype):
@ -263,13 +257,7 @@ class AuthRestServlet(RestServlet):
raise SynapseError(404, "Unknown auth stage type") raise SynapseError(404, "Unknown auth stage type")
# Render the HTML and return. # Render the HTML and return.
html_bytes = html.encode("utf8") respond_with_html(request, 200, html)
request.setResponseCode(200)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%d" % (len(html_bytes),))
request.write(html_bytes)
finish_request(request)
return None return None
def on_OPTIONS(self, _): def on_OPTIONS(self, _):

View file

@ -38,7 +38,7 @@ from synapse.config.ratelimiting import FederationRateLimitConfig
from synapse.config.registration import RegistrationConfig from synapse.config.registration import RegistrationConfig
from synapse.config.server import is_threepid_reserved from synapse.config.server import is_threepid_reserved
from synapse.handlers.auth import AuthHandler from synapse.handlers.auth import AuthHandler
from synapse.http.server import finish_request from synapse.http.server import finish_request, respond_with_html
from synapse.http.servlet import ( from synapse.http.servlet import (
RestServlet, RestServlet,
assert_params_in_dict, assert_params_in_dict,
@ -306,17 +306,15 @@ class RegistrationSubmitTokenServlet(RestServlet):
# Otherwise show the success template # Otherwise show the success template
html = self.config.email_registration_template_success_html_content html = self.config.email_registration_template_success_html_content
status_code = 200
request.setResponseCode(200)
except ThreepidValidationError as e: except ThreepidValidationError as e:
request.setResponseCode(e.code) status_code = e.code
# Show a failure page with a reason # Show a failure page with a reason
template_vars = {"failure_reason": e.msg} template_vars = {"failure_reason": e.msg}
html = self.failure_email_template.render(**template_vars) html = self.failure_email_template.render(**template_vars)
request.write(html.encode("utf-8")) respond_with_html(request, status_code, html)
finish_request(request)
class UsernameAvailabilityRestServlet(RestServlet): class UsernameAvailabilityRestServlet(RestServlet):

View file

@ -29,7 +29,7 @@ from synapse.api.errors import NotFoundError, StoreError, SynapseError
from synapse.config import ConfigError from synapse.config import ConfigError
from synapse.http.server import ( from synapse.http.server import (
DirectServeResource, DirectServeResource,
finish_request, respond_with_html,
wrap_html_request_handler, wrap_html_request_handler,
) )
from synapse.http.servlet import parse_string from synapse.http.servlet import parse_string
@ -197,12 +197,8 @@ class ConsentResource(DirectServeResource):
template_html = self._jinja_env.get_template( template_html = self._jinja_env.get_template(
path.join(TEMPLATE_LANGUAGE, template_name) path.join(TEMPLATE_LANGUAGE, template_name)
) )
html_bytes = template_html.render(**template_args).encode("utf8") html = template_html.render(**template_args)
respond_with_html(request, 200, html)
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
request.setHeader(b"Content-Length", b"%i" % len(html_bytes))
request.write(html_bytes)
finish_request(request)
def _check_hash(self, userid, userhmac): def _check_hash(self, userid, userhmac):
""" """