# Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2020 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENSE.TXT import html from urllib.parse import urlsplit from clwpos import gettext as _ from clwpos.cl_wpos_exceptions import WposError # Characters that could break out of the double-quoted ``href="%(url)s"`` # attribute or inject a tag in the anchor body. A url free of these is safe in # the HTML anchor while remaining a valid plain-text value for the # ``curl %(url)s`` fix_tip sink. A single quote ``'`` is intentionally NOT in # this set: inside the double-quoted href attribute (and the anchor body) it is # inert, yet it is a valid RFC-3986 sub-delim that legitimately appears in # ``response.url`` (post-redirect target, checkers.py); rejecting it would blank # a legitimate diagnostic url for no XSS gain. _URL_BREAKING_CHARS = ('"', '<', '>') def _safe_url(url): """Validate a caller-supplied url shared between the HTML templates and the plain-text ``curl %(url)s`` fix_tip. The same value feeds an HTML sink (the anchor in MESSAGE_TEMPLATE) and a plain-text sink (the curl fix_tip), so it must not be html-escaped: that would corrupt a legitimate url (e.g. ``?a=1&b=2`` -> ``?a=1&b=2``) in the copy-pasteable command. Instead validate-and-reject: keep the url byte-for-byte when its scheme is http/https and it contains no attribute/tag-breaking characters; otherwise drop it to an empty string. A scheme-validated url with no breaking chars is safe in the HTML anchor (``&`` alone cannot break an attribute or inject script) and correct in the curl command. """ if not isinstance(url, str): return '' # Reject raw whitespace / ASCII control bytes (CR, LF, tab, space, NUL, # DEL, ...) up front. A real http/https url percent-encodes these, so a # raw control byte is never a legitimate diagnostic url and must not land # verbatim in the href / the plain-text ``curl %(url)s`` tip. (A double- # quoted href attribute is not actually terminated by a newline -- only by # ``"`` -- but a url validator should not pass control characters through, # and it keeps urlsplit from having to cope with embedded controls.) if any(ch.isspace() or ord(ch) < 0x20 or ord(ch) == 0x7f for ch in url): return '' scheme = urlsplit(url).scheme.lower() if scheme not in ('http', 'https'): return '' if any(ch in url for ch in _URL_BREAKING_CHARS): return '' return url # Context key whose value is a url shared between the HTML ``description`` and # the plain-text ``curl %(url)s`` FIX_TIP. It must be validate-and-rejected # (``_safe_url``) rather than html-escaped so the same byte-for-byte value stays # correct in the copy-pasteable curl command while remaining safe in the anchor. _URL_CONTEXT_KEY = 'url' class WebsiteCheckError(WposError): """ Generic base class for all website post checks. Every context value reaching the HTML ``description`` is neutralised here, centrally, so no subclass, future key, or direct base-class raise (e.g. the case-4 ``error_desc`` in wp_utils._process_post_check) can leak raw caller/site-influenced markup into the panel HTML sink. The ``url`` key is validate-and-rejected via ``_safe_url`` (kept raw-but-safe so it stays a valid ``curl %(url)s`` command in the plain-text FIX_TIP); every other value is html-escaped. ``self.context`` therefore holds values that are simultaneously safe in the HTML ``description`` and intact enough for the plain-text FIX_TIP (which only ever interpolates ``url``/``timeout``). """ def __init__(self, *, header, description, fix_tip, context, details=None): super(WebsiteCheckError, self).__init__( message=description, context=self._neutralise_context(context) ) self.header = header self.fix_tip = fix_tip self.details = details @staticmethod def _neutralise_context(context): """Return a copy of ``context`` safe for the HTML ``description`` sink: the ``url`` value validate-and-rejected via ``_safe_url`` (so the same value remains correct in the plain-text ``curl %(url)s`` FIX_TIP), and every other value html-escaped. Escaping is harmless on server-built values (paths, fixed ints render unchanged).""" if not context: return context return { key: _safe_url(value) if key == _URL_CONTEXT_KEY else html.escape(str(value), quote=True) for key, value in context.items() } class PostCheckRequestException(WebsiteCheckError): HEADER = _('Http request failed') MESSAGE_TEMPLATE = _( 'Fatal error on attempt to request website main page: ' '%(url)s. ' 'Error message: %(error)s') FIX_TIP = _( 'Manually check website responds on http requests' ) def __init__(self, url, error): super(PostCheckRequestException, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( url=url, error=error ), fix_tip=self.FIX_TIP ) class WebsiteCheckBadHttpCode(WebsiteCheckError): """ Happens when we get unexpected status code from website. Usually user must go check logs and fix this issue himself. """ HEADER = _('Bad http status code response') MESSAGE_TEMPLATE = _( 'Unsuccessful attempt to request website main page: ' '%(url)s. ' 'Webserver responded with http status code %(http_code)s.') FIX_TIP = _( 'Search your webserver logs for errors and manually ' 'check that WordPress site is working properly.' ) def __init__(self, url, http_code): super(WebsiteCheckBadHttpCode, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( url=url, http_code=http_code ), fix_tip=self.FIX_TIP ) class WebsiteTimeout(WebsiteCheckError): """ Happens when website does not respond in hardcoded timeout. """ HEADER = _('Response timeout reached') MESSAGE_TEMPLATE = _( 'Unsuccessful attempt to request website main page: ' '%(url)s. ' 'Webserver responds longer than %(timeout)s seconds ' '(most likely it is not working).') FIX_TIP = _( 'Make sure that WordPress site is working properly and ' 'specified url gives response in %(timeout)s seconds. ' 'Search your webserver logs for errors that may cause the timeout issue.' ) def __init__(self, url, timeout): super(WebsiteTimeout, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( url=url, timeout=timeout ), fix_tip=self.FIX_TIP ) class WebsiteHttpsBroken(WebsiteCheckError): """ Happens when website has invalid or outdated https certificate. """ HEADER = _('Broken https certificate') MESSAGE_TEMPLATE = _( 'Unsuccessful attempt to request website main page: ' '%(url)s. ' 'Webserver redirected http request to https, but the last ' 'one does not have a valid certificate.') FIX_TIP = _( 'Make sure that https certificate is valid and up to date or ' 'disable http to https redirection in your control panel.' ) def __init__(self, url, details): super(WebsiteHttpsBroken, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( url=url ), fix_tip=self.FIX_TIP, details=details ) class WebsiteNotResponding(WebsiteCheckError): """ Happens when website does not have working webserver at all. """ HEADER = _('Website it not responding') MESSAGE_TEMPLATE = _( 'Unsuccessful attempt to request website main page: ' '%(url)s. ' 'Webserver did not return any response.') FIX_TIP = _( 'Make sure that webserver is working and your website is accessible: ' 'you can use `curl %(url)s` to check response.' ) def __init__(self, url, details): super(WebsiteNotResponding, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( url=url ), fix_tip=self.FIX_TIP, details=details ) class RollbackException(WposError): MESSAGE_TEMPLATE = _( "Action failed and rolled back. Here is why." ) def __init__(self, *reasons: WebsiteCheckError): self.errors = [ dict( header=reason.header, description=reason.message, fix_tip=reason.fix_tip, type='post_check', context=reason.context, details=reason.details ) for reason in reasons ] super(RollbackException, self).__init__(message='rollback') class PhpLogErrorsFound(WebsiteCheckError): HEADER = _('Website server error') MESSAGE_TEMPLATE = _( 'We found some errors in the website log file: %(error_log_path)s. ' 'Here is some context: \n' '%(log_record)s') FIX_TIP = _( 'Check found errors and decide whether they are critical or not. ' 'Contact your system administrator for help if needed.' ) def __init__(self, error_log_path, log_record): super(PhpLogErrorsFound, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( error_log_path=error_log_path, log_record=log_record ), fix_tip=self.FIX_TIP ) class CDNActivationFailed(WebsiteCheckError): """ Happens when website has invalid CDN configuration for some reason """ HEADER = _('CDN malfunction') MESSAGE_TEMPLATE = _( "CDN feature was not activated properly. " "Changes were reverted and CDN module is now disabled.\n" "Event is logged to file: '%(logger_path)s' with stdout and stderr recorded.") FIX_TIP = _( 'Activate feature again. If the issue persists, ' 'ignore error and check your website configuration ' 'with enabled CDN feature. ' 'Contact your hoster\'s support for help.' ) def __init__(self, logger_path): super(CDNActivationFailed, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, fix_tip=self.FIX_TIP, context=dict(logger_path=logger_path) ) class JSCssCheckBadHttpCode(WebsiteCheckError): """ Happens when we get unexpected status code from website. Usually user must go check logs and fix this issue himself. """ HEADER = _('Bad http status code response') MESSAGE_TEMPLATE = _( 'Unsuccessful attempt to request website styles and scripts. ' 'Webserver responded with http status code %(http_code)s.') FIX_TIP = _( 'Search your webserver logs for errors and manually ' 'check that WordPress site is working properly.' ) def __init__(self, http_code): super(JSCssCheckBadHttpCode, self).__init__( header=self.HEADER, description=self.MESSAGE_TEMPLATE, context=dict( http_code=http_code ), fix_tip=self.FIX_TIP )