Make getCaptchaImage return (bytes, str).
[tor-bridgedb.git] / bridgedb / distributors / https / server.py
blob98b31d0e9babc605773edb02f941e421b1a9f17c
1 # -*- coding: utf-8 ; test-case-name: bridgedb.test.test_https_server -*-
3 # This file is part of BridgeDB, a Tor bridge distribution system.
5 # :authors: please see included AUTHORS file
6 # :copyright: (c) 2007-2017, The Tor Project, Inc.
7 # (c) 2013-2017, Isis Lovecruft
8 # :license: see LICENSE for licensing information
10 """
11 .. py:module:: bridgedb.distributors.https.server
12 :synopsis: Servers which interface with clients and distribute bridges
13 over HTTP(S).
15 bridgedb.distributors.https.server
16 =====================
18 Servers which interface with clients and distribute bridges over HTTP(S).
20 .. inheritance-diagram:: TranslatedTemplateResource IndexResource OptionsResource HowtoResource CaptchaProtectedResource GimpCaptchaProtectedResource ReCaptchaProtectedResource BridgesResource
21 :parts: 1
22 """
24 import base64
25 import gettext
26 import logging
27 import random
28 import re
29 import time
30 import os
31 import operator
33 from functools import partial
35 from ipaddr import IPv4Address
37 import mako.exceptions
38 from mako.template import Template
39 from mako.lookup import TemplateLookup
41 import babel.core
43 from twisted.internet import defer
44 from twisted.internet import reactor
45 from twisted.internet import task
46 from twisted.internet.error import CannotListenError
47 from twisted.web import resource
48 from twisted.web import static
49 from twisted.web.server import NOT_DONE_YET
50 from twisted.web.server import Site
51 from twisted.web.util import redirectTo
53 from bridgedb import captcha
54 from bridgedb import crypto
55 from bridgedb import strings
56 from bridgedb import translations
57 from bridgedb import txrecaptcha
58 from bridgedb import metrics
59 from bridgedb import antibot
60 from bridgedb.distributors.common.http import setFQDN
61 from bridgedb.distributors.common.http import getFQDN
62 from bridgedb.distributors.common.http import getClientIP
63 from bridgedb.distributors.https.request import HTTPSBridgeRequest
64 from bridgedb.parse import headers
65 from bridgedb.parse.addr import isIPAddress
66 from bridgedb.qrcodes import generateQR
67 from bridgedb.safelog import logSafely
68 from bridgedb.schedule import Unscheduled
69 from bridgedb.schedule import ScheduledInterval
70 from bridgedb.util import replaceControlChars
73 #: The path to the HTTPS distributor's web templates. (Should be the
74 #: "templates" directory in the same directory as this file.)
75 TEMPLATE_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'templates')
77 # Setting `filesystem_checks` to False is recommended for production servers,
78 # due to potential speed increases. This means that the atimes of the Mako
79 # template files aren't rechecked every time the template is requested
80 # (otherwise, if they are checked, and the atime is newer, the template is
81 # recompiled). `collection_size` sets the number of compiled templates which
82 # are cached before the least recently used ones are removed. See:
83 # http://docs.makotemplates.org/en/latest/usage.html#using-templatelookup
84 lookup = TemplateLookup(directories=[TEMPLATE_DIR],
85 output_encoding='utf-8',
86 filesystem_checks=False,
87 collection_size=500)
88 logging.debug("Set template root to %s" % TEMPLATE_DIR)
90 #: A list of supported language tuples. Use getSortedLangList() to read this variable.
91 supported_langs = []
93 # We use our metrics singleton to keep track of BridgeDB metrics such as
94 # "number of failed HTTPS bridge requests."
95 metrix = metrics.HTTPSMetrics()
98 def stringifyRequestArgs(args):
99 """Turn the given HTTP request arguments from bytes to str.
101 :param dict args: A dictionary of request arguments.
102 :rtype: dict
103 :returns: A dictionary of request arguments.
106 # Convert all key/value pairs from bytes to str.
107 str_args = {}
108 for arg, values in args.items():
109 arg = arg if isinstance(arg, str) else arg.decode("utf-8")
110 values = [value.decode("utf-8") if isinstance(value, bytes)
111 else value for value in values]
112 str_args[arg] = values
114 return str_args
117 def replaceErrorPage(request, error, template_name=None, html=True):
118 """Create a general error page for displaying in place of tracebacks.
120 Log the error to BridgeDB's logger, and then display a very plain "Sorry!
121 Something went wrong!" page to the client.
123 :type request: :api:`twisted.web.http.Request`
124 :param request: A ``Request`` object containing the HTTP method, full
125 URI, and any URL/POST arguments and headers present.
126 :type error: :exc:`Exception`
127 :param error: Any exeption which has occurred while attempting to retrieve
128 a template, render a page, or retrieve a resource.
129 :param str template_name: A string describing which template/page/resource
130 was being used when the exception occurred, i.e. ``'index.html'``.
131 :param bool html: If ``True``, return one of two HTML error pages. First,
132 we attempt to render a fancier error page. If that rendering failed,
133 or if **html** is ``False``, then we return a very simple HTML page
134 (without CSS, Javascript, images, etc.) which simply says
135 ``"Sorry! Something went wrong with your request."``
136 :rtype: bytes
137 :returns: A bytes object containing some content to serve to the client
138 (rather than serving a Twisted traceback).
140 logging.error("Error while attempting to render %s: %s"
141 % (template_name or 'template',
142 mako.exceptions.text_error_template().render()))
144 # TRANSLATORS: Please DO NOT translate the following words and/or phrases in
145 # any string (regardless of capitalization and/or punctuation):
147 # "BridgeDB"
148 # "pluggable transport"
149 # "pluggable transports"
150 # "obfs4"
151 # "Tor"
152 # "Tor Browser"
154 errorMessage = _("Sorry! Something went wrong with your request.")
156 if not html:
157 return errorMessage.encode("utf-8")
159 try:
160 rendered = resource500.render(request)
161 except Exception as err:
162 logging.exception(err)
163 rendered = errorMessage.encode("utf-8")
165 return rendered
168 def redirectMaliciousRequest(request):
169 '''Redirect the client to a "daring work of art" which "in true
170 post-modern form, […] tends to raise more questions than answers."
172 logging.debug("Redirecting %s to a daring work of art..." % getClientIP(request))
173 request.write(redirectTo(base64.b64decode("aHR0cDovLzJnaXJsczFjdXAuY2Ev"), request))
174 request.finish()
175 return request
178 def getSortedLangList(rebuild=False):
180 Build and return a list of tuples that contains all of BridgeDB's supported
181 languages, e.g.: [("az", "Azərbaycan"), ("ca", "Català"), ..., ].
183 :param rebuild bool: Force a rebuild of ``supported_langs`` if the argument
184 is set to ``True``. The default is ``False``.
185 :rtype: list
186 :returns: A list of tuples of the form (language-locale, language). The
187 list is sorted alphabetically by language. We use this list to
188 provide a language switcher in BridgeDB's web interface.
191 # If we already compiled our languages, return them right away.
192 global supported_langs
193 if supported_langs and not rebuild:
194 return supported_langs
195 logging.debug("Building supported languages for language switcher.")
197 langDict = {}
198 for l in translations.getSupportedLangs():
200 # We don't support 'en_GB', and 'en' and 'en_US' are the same. 'zh_HK'
201 # is very similar to 'zh_TW' and we also lack translators for it, so we
202 # drop the locale: <https://bugs.torproject.org/26543#comment:17>
203 if l in ("en_GB", "en_US", "zh_HK"):
204 continue
206 try:
207 langDict[l] = "%s" % (babel.core.Locale.parse(l).display_name.capitalize())
208 except Exception as err:
209 logging.warning("Failed to create language switcher option for %s: %s" % (l, err))
211 # Sort languages alphabetically.
212 supported_langs = sorted(langDict.items(), key=operator.itemgetter(1))
214 return supported_langs
217 class MaliciousRequest(Exception):
218 """Raised when we received a possibly malicious request."""
221 class CSPResource(resource.Resource):
222 """A resource which adds a ``'Content-Security-Policy:'`` header.
224 :vartype reportViolations: bool
225 :var reportViolations: Use the Content Security Policy in `report-only`_
226 mode, causing CSP violations to be reported back to the server (at
227 :attr:`reportURI`, where the details of the violation will be logged).
228 (default: ``False``)
229 :vartype reportURI: str
230 :var reportURI: If :attr:`reportViolations` is ``True``, Content Security
231 Policy violations will be sent as JSON-encoded POST request to this
232 URI. (default: ``'csp-violation'``)
234 .. _report-only:
235 https://w3c.github.io/webappsec/specs/content-security-policy/#content-security-policy-report-only-header-field
237 reportViolations = False
238 reportURI = 'csp-violation'
240 def __init__(self, includeSelf=False, enabled=True, reportViolations=False,
241 useForwardedHeader=False):
242 """Create a new :api:`twisted.web.resource.Resource` which adds a
243 ``'Content-Security-Policy:'`` header.
245 If enabled, the default Content Security Policy is::
247 default-src 'none' ;
248 base-uri FQDN ;
249 script-src FQDN ;
250 style-src FQDN ;
251 img-src FQDN data: ;
252 font-src FQDN ;
254 where ``FQDN`` the value returned from the :func:`getFQDN` function
255 (which uses the ``SERVER_PUBLIC_FQDN`` config file option).
257 If the **includeSelf** parameter is enabled, then ``"'self'"``
258 (literally, a string containing the word ``self``, surrounded by
259 single-quotes) will be appended to the ``FQDN``.
261 :param str fqdn: The public, fully-qualified domain name
262 of the HTTP server that will serve this resource.
263 :param bool includeSelf: Append ``'self'`` after the **fqdn** in the
264 Content Security Policy.
265 :param bool enabled: If ``False``, all Content Security Policy
266 headers, including those used in report-only mode, will not be
267 sent. If ``True``, Content Security Policy headers (regardless of
268 whether report-only mode is dis-/en-abled) will be sent.
269 (default: ``True``)
270 :param bool reportViolations: Use the Content Security Policy in
271 report-only mode, causing CSP violations to be reported back to
272 the server (at :attr:`reportURI`, where the details of the
273 violation will be logged). (default: ``False``)
274 :param bool useForwardedHeader: If ``True``, then we will attempt to
275 obtain the client's IP address from the ``X-Forwarded-For`` HTTP
276 header. This *only* has an effect if **reportViolations** is also
277 set to ``True`` — the client's IP address is logged along with any
278 CSP violation reports which the client sent via HTTP POST requests
279 to our :attr:`reportURI`. (default: ``False``)
281 resource.Resource.__init__(self)
283 self.fqdn = getFQDN()
284 self.enabled = enabled
285 self.useForwardedHeader = useForwardedHeader
286 self.csp = ("default-src 'none'; "
287 "base-uri {0}; "
288 "script-src {0}; "
289 "style-src {0}; "
290 "img-src {0} data:; "
291 "font-src {0}; ")
293 if includeSelf:
294 self.fqdn = " ".join([self.fqdn, "'self'"])
296 if reportViolations:
297 self.reportViolations = reportViolations
299 def setCSPHeader(self, request):
300 """Set the CSP header for a **request**.
302 If this :class:`CSPResource` is :attr:`enabled`, then use
303 :api:`twisted.web.http.Request.setHeader` to send an HTTP
304 ``'Content-Security-Policy:'`` header for any response made to the
305 **request** (or a ``'Content-Security-Policy-Report-Only:'`` header,
306 if :attr:`reportViolations` is enabled).
308 :type request: :api:`twisted.web.http.Request`
309 :param request: A ``Request`` object for :attr:`reportViolationURI`.
311 self.fqdn = self.fqdn or getFQDN() # Update the FQDN if it changed.
313 if self.enabled and self.fqdn:
314 if not self.reportViolations:
315 request.setHeader("Content-Security-Policy",
316 self.csp.format(self.fqdn))
317 else:
318 logging.debug("Sending report-only CSP header...")
319 request.setHeader("Content-Security-Policy-Report-Only",
320 self.csp.format(self.fqdn) +
321 "report-uri /%s" % self.reportURI)
323 def render_POST(self, request):
324 """If we're in debug mode, log a Content Security Policy violation.
326 :type request: :api:`twisted.web.http.Request`
327 :param request: A ``Request`` object for :attr:`reportViolationURI`.
329 try:
330 client = getClientIP(request, self.useForwardedHeader)
331 report = request.content.read(2048)
333 logging.warning("Content-Security-Policy violation report from %s: %r"
334 % (client or "UNKNOWN CLIENT", report))
335 except Exception as err:
336 logging.error("Error while attempting to log CSP report: %s" % err)
338 # Redirect back to the original resource after the report was logged:
339 return redirectTo(request.uri, request)
342 class ErrorResource(CSPResource):
343 """A resource which explains that BridgeDB is undergoing maintenance, or
344 that some other (unexpected) error has occured.
346 isLeaf = True
348 def __init__(self, template=None, code=200):
349 """Create a :api:`twisted.web.resource.Resource` for an error page."""
350 CSPResource.__init__(self)
351 self.template = template
352 self.code = code
354 def render_GET(self, request):
355 self.setCSPHeader(request)
356 request.setHeader("Content-Type", "text/html; charset=utf-8")
357 request.setResponseCode(self.code)
358 request.args = stringifyRequestArgs(request.args)
360 try:
361 template = lookup.get_template(self.template)
362 rendered = template.render()
363 except Exception as err:
364 rendered = replaceErrorPage(request, err, html=False)
366 return rendered
368 render_POST = render_GET
371 resource404 = ErrorResource('error-404.html', code=404)
372 resource500 = ErrorResource('error-500.html', code=500)
373 maintenance = ErrorResource('error-503.html', code=503)
376 class CustomErrorHandlingResource(resource.Resource):
377 """A :api:`twisted.web.resource.Resource` which wraps the
378 :api:`twisted.web.resource.Resource.getChild` method in order to use
379 custom error handling pages.
381 def getChild(self, path, request):
382 logging.debug("[404] %s" % request.uri)
383 return resource404
386 class TranslatedTemplateResource(CustomErrorHandlingResource, CSPResource):
387 """A generalised resource which uses gettext translations and Mako
388 templates.
390 isLeaf = True
392 def __init__(self, template=None):
393 """Create a new :api:`Resource <twisted.web.resource.Resource>` for a
394 Mako-templated webpage.
396 gettext.install("bridgedb")
397 CSPResource.__init__(self)
398 self.template = template
400 def render_GET(self, request):
401 self.setCSPHeader(request)
402 request.args = stringifyRequestArgs(request.args)
403 rtl = False
404 try:
405 langs = translations.getLocaleFromHTTPRequest(request)
406 rtl = translations.usingRTLLang(langs)
407 template = lookup.get_template(self.template)
408 rendered = template.render(strings,
409 getSortedLangList(),
410 rtl=rtl,
411 lang=langs[0],
412 langOverride=translations.isLangOverridden(request))
413 except Exception as err: # pragma: no cover
414 rendered = replaceErrorPage(request, err)
415 request.setHeader("Content-Type", "text/html; charset=utf-8")
416 return rendered
418 render_POST = render_GET
421 class IndexResource(TranslatedTemplateResource):
422 """The parent resource of all other documents hosted by the webserver."""
424 def __init__(self):
425 """Create a :api:`twisted.web.resource.Resource` for the index page."""
426 TranslatedTemplateResource.__init__(self, 'index.html')
429 class OptionsResource(TranslatedTemplateResource):
430 """A resource with additional options which a client may use to specify the
431 which bridge types should be returned by :class:`BridgesResource`.
433 def __init__(self):
434 """Create a :api:`twisted.web.resource.Resource` for the options page."""
435 TranslatedTemplateResource.__init__(self, 'options.html')
438 class HowtoResource(TranslatedTemplateResource):
439 """A resource which explains how to use bridges."""
441 def __init__(self):
442 """Create a :api:`twisted.web.resource.Resource` for the HowTo page."""
443 TranslatedTemplateResource.__init__(self, 'howto.html')
446 class CaptchaProtectedResource(CustomErrorHandlingResource, CSPResource):
447 """A general resource protected by some form of CAPTCHA."""
449 isLeaf = True
451 def __init__(self, publicKey=None, secretKey=None,
452 useForwardedHeader=False, protectedResource=None):
453 CSPResource.__init__(self)
454 self.publicKey = publicKey
455 self.secretKey = secretKey
456 self.useForwardedHeader = useForwardedHeader
457 self.resource = protectedResource
459 def getClientIP(self, request):
460 """Get the client's IP address from the ``'X-Forwarded-For:'``
461 header, or from the :api:`request <twisted.web.server.Request>`.
463 :type request: :api:`twisted.web.http.Request`
464 :param request: A ``Request`` for a
465 :api:`twisted.web.resource.Resource`.
466 :rtype: ``None`` or :any:`str`
467 :returns: The client's IP address, if it was obtainable.
469 return getClientIP(request, self.useForwardedHeader)
471 def getCaptchaImage(self, request=None):
472 """Get a CAPTCHA image.
474 :rtype: tuple
475 :returns: A 2-tuple of ``(image, challenge)``, where ``image`` is a
476 JPEG-encoded image of type bytes, and ``challenge`` is a unique
477 string. If unable to retrieve a CAPTCHA, returns a tuple
478 containing (b'', '').
480 return (b'', '')
482 def extractClientSolution(self, request):
483 """Extract the client's CAPTCHA solution from a POST request.
485 This is used after receiving a POST request from a client (which
486 should contain their solution to the CAPTCHA), to extract the solution
487 and challenge strings.
489 :type request: :api:`twisted.web.http.Request`
490 :param request: A ``Request`` object for 'bridges.html'.
491 :returns: A redirect for a request for a new CAPTCHA if there was a
492 problem. Otherwise, returns a 2-tuple of bytes, the first is the
493 client's CAPTCHA solution from the text input area, and the second
494 is the challenge string.
496 try:
497 challenge = request.args['captcha_challenge_field'][0]
498 response = request.args['captcha_response_field'][0]
499 except Exception as error:
500 raise MaliciousRequest(
501 ("Client CAPTCHA solution to HTTPS distributor server "
502 "didn't include correct HTTP arguments: %s" % error))
503 return (challenge, response)
505 def checkSolution(self, request):
506 """Override this method to check a client's CAPTCHA solution.
508 :rtype: bool
509 :returns: ``True`` if the client correctly solved the CAPTCHA;
510 ``False`` otherwise.
512 return False
514 def render_GET(self, request):
515 """Retrieve a CAPTCHA and serve it to the client.
517 :type request: :api:`twisted.web.http.Request`
518 :param request: A ``Request`` object for a page which should be
519 protected by a CAPTCHA.
520 :rtype: bytes
521 :returns: A rendered HTML page containing a CAPTCHA challenge image
522 for the client to solve.
524 self.setCSPHeader(request)
525 request.args = stringifyRequestArgs(request.args)
527 rtl = False
528 image, challenge = self.getCaptchaImage(request)
530 try:
531 langs = translations.getLocaleFromHTTPRequest(request)
532 rtl = translations.usingRTLLang(langs)
533 # TODO: this does not work for versions of IE < 8.0
534 imgstr = b'data:image/jpeg;base64,%s' % base64.b64encode(image)
535 template = lookup.get_template('captcha.html')
536 rendered = template.render(strings,
537 getSortedLangList(),
538 rtl=rtl,
539 lang=langs[0],
540 langOverride=translations.isLangOverridden(request),
541 imgstr=imgstr.decode("utf-8"),
542 challenge_field=challenge)
543 except Exception as err:
544 rendered = replaceErrorPage(request, err, 'captcha.html')
546 request.setHeader("Content-Type", "text/html; charset=utf-8")
547 return rendered
549 def render_POST(self, request):
550 """Process a client's CAPTCHA solution.
552 If the client's CAPTCHA solution is valid (according to
553 :meth:`checkSolution`), process and serve their original
554 request. Otherwise, redirect them back to a new CAPTCHA page.
556 :type request: :api:`twisted.web.http.Request`
557 :param request: A ``Request`` object, including POST arguments which
558 should include two key/value pairs: one key being
559 ``'captcha_challenge_field'``, and the other,
560 ``'captcha_response_field'``. These POST arguments should be
561 obtained from :meth:`render_GET`.
562 :rtype: str
563 :returns: A rendered HTML page containing a ReCaptcha challenge image
564 for the client to solve.
566 self.setCSPHeader(request)
567 request.setHeader("Content-Type", "text/html; charset=utf-8")
568 request.args = stringifyRequestArgs(request.args)
570 try:
571 if self.checkSolution(request) is True:
572 metrix.recordValidHTTPSRequest(request)
573 return self.resource.render(request)
574 except ValueError as err:
575 logging.debug(str(err))
576 except MaliciousRequest as err:
577 logging.debug(str(err))
578 # Make them wait a bit, then redirect them to a "daring
579 # work of art" as pennance for their sins.
580 d = task.deferLater(reactor, 1, lambda: request)
581 d.addCallback(redirectMaliciousRequest)
582 metrix.recordInvalidHTTPSRequest(request)
583 return NOT_DONE_YET
584 except Exception as err:
585 logging.debug(str(err))
586 metrix.recordInvalidHTTPSRequest(request)
587 return replaceErrorPage(request, err)
589 metrix.recordInvalidHTTPSRequest(request)
590 logging.debug("Client failed a CAPTCHA; returning redirect to %s"
591 % request.uri)
592 return redirectTo(request.uri, request)
595 class GimpCaptchaProtectedResource(CaptchaProtectedResource):
596 """A web resource which uses a local cache of CAPTCHAs, generated with
597 gimp-captcha_, to protect another resource.
599 .. _gimp-captcha: https://github.com/isislovecruft/gimp-captcha
602 def __init__(self, hmacKey=None, captchaDir='', **kwargs):
603 """Protect a resource via this one, using a local CAPTCHA cache.
605 :param str secretkey: A PKCS#1 OAEP-padded, private RSA key, used for
606 verifying the client's solution to the CAPTCHA. See
607 :func:`bridgedb.crypto.getRSAKey` and the
608 ``GIMP_CAPTCHA_RSA_KEYFILE`` config setting.
609 :param str publickey: A PKCS#1 OAEP-padded, public RSA key, used for
610 creating the ``captcha_challenge_field`` string to give to a
611 client.
612 :param bytes hmacKey: The master HMAC key, used for validating CAPTCHA
613 challenge strings in :meth:`captcha.GimpCaptcha.check`. The file
614 where this key is stored can be set via the
615 ``GIMP_CAPTCHA_HMAC_KEYFILE`` option in the config file.
616 :param str captchaDir: The directory where the cached CAPTCHA images
617 are stored. See the ``GIMP_CAPTCHA_DIR`` config setting.
618 :param bool useForwardedHeader: If ``True``, obtain the client's IP
619 address from the ``X-Forwarded-For`` HTTP header.
620 :type protectedResource: :api:`twisted.web.resource.Resource`
621 :param protectedResource: The resource to serve if the client
622 successfully passes the CAPTCHA challenge.
623 :param str serverPublicFQDN: The public, fully-qualified domain name
624 of the HTTP server that will serve this resource.
626 CaptchaProtectedResource.__init__(self, **kwargs)
627 self.hmacKey = hmacKey
628 self.captchaDir = captchaDir
630 def checkSolution(self, request):
631 """Process a solved CAPTCHA via :meth:`bridgedb.captcha.GimpCaptcha.check`.
633 :type request: :api:`twisted.web.http.Request`
634 :param request: A ``Request`` object, including POST arguments which
635 should include two key/value pairs: one key being
636 ``'captcha_challenge_field'``, and the other,
637 ``'captcha_response_field'``. These POST arguments should be
638 obtained from :meth:`render_GET`.
639 :rtupe: bool
640 :returns: True, if the CAPTCHA solution was valid; False otherwise.
642 valid = False
643 challenge, solution = self.extractClientSolution(request)
644 clientIP = self.getClientIP(request)
645 clientHMACKey = crypto.getHMAC(self.hmacKey, clientIP)
647 try:
648 valid = captcha.GimpCaptcha.check(challenge, solution,
649 self.secretKey, clientHMACKey)
650 except captcha.CaptchaExpired as error:
651 logging.warn(error)
652 valid = False
654 logging.debug("%sorrect captcha from %r: %r."
655 % ("C" if valid else "Inc", clientIP, solution))
656 return valid
658 def getCaptchaImage(self, request):
659 """Get a random CAPTCHA image from our **captchaDir**.
661 Creates a :class:`~bridgedb.captcha.GimpCaptcha`, and calls its
662 :meth:`~bridgedb.captcha.GimpCaptcha.get` method to return a random
663 CAPTCHA and challenge string.
665 :type request: :api:`twisted.web.http.Request`
666 :param request: A client's initial request for some other resource
667 which is protected by this one (i.e. protected by a CAPTCHA).
668 :returns: A 2-tuple of ``(image, challenge)``, where::
669 - ``image`` is a string holding a binary, JPEG-encoded image.
670 - ``challenge`` is a unique string associated with the request.
672 # Create a new HMAC key, specific to requests from this client:
673 clientIP = self.getClientIP(request)
674 clientHMACKey = crypto.getHMAC(self.hmacKey, clientIP)
675 capt = captcha.GimpCaptcha(self.publicKey, self.secretKey,
676 clientHMACKey, self.captchaDir)
677 try:
678 capt.get()
679 except captcha.GimpCaptchaError as error:
680 logging.error(error)
681 except Exception as error: # pragma: no cover
682 logging.error("Unhandled error while retrieving Gimp captcha!")
683 logging.exception(error)
685 return (capt.image, capt.challenge)
687 def render_GET(self, request):
688 """Get a random CAPTCHA from our local cache directory and serve it to
689 the client.
691 :type request: :api:`twisted.web.http.Request`
692 :param request: A ``Request`` object for a page which should be
693 protected by a CAPTCHA.
694 :rtype: str
695 :returns: A rendered HTML page containing a ReCaptcha challenge image
696 for the client to solve.
698 return CaptchaProtectedResource.render_GET(self, request)
700 def render_POST(self, request):
701 """Process a client's CAPTCHA solution.
703 If the client's CAPTCHA solution is valid (according to
704 :meth:`checkSolution`), process and serve their original
705 request. Otherwise, redirect them back to a new CAPTCHA page.
707 :type request: :api:`twisted.web.http.Request`
708 :param request: A ``Request`` object, including POST arguments which
709 should include two key/value pairs: one key being
710 ``'captcha_challenge_field'``, and the other,
711 ``'captcha_response_field'``. These POST arguments should be
712 obtained from :meth:`render_GET`.
713 :rtype: str
714 :returns: A rendered HTML page containing a ReCaptcha challenge image
715 for the client to solve.
717 return CaptchaProtectedResource.render_POST(self, request)
720 class ReCaptchaProtectedResource(CaptchaProtectedResource):
721 """A web resource which uses the reCaptcha_ service.
723 .. _reCaptcha: http://www.google.com/recaptcha
726 def __init__(self, remoteIP=None, **kwargs):
727 CaptchaProtectedResource.__init__(self, **kwargs)
728 self.remoteIP = remoteIP
730 def _renderDeferred(self, checkedRequest):
731 """Render this resource asynchronously.
733 :type checkedRequest: tuple
734 :param checkedRequest: A tuple of ``(bool, request)``, as returned
735 from :meth:`checkSolution`.
737 try:
738 valid, request = checkedRequest
739 except Exception as err:
740 logging.error("Error in _renderDeferred(): %s" % err)
741 return
743 logging.debug("Attemping to render %svalid request %r"
744 % ('' if valid else 'in', request))
745 if valid is True:
746 try:
747 rendered = self.resource.render(request)
748 except Exception as err: # pragma: no cover
749 rendered = replaceErrorPage(request, err)
750 else:
751 logging.info("Client failed a CAPTCHA; redirecting to %s"
752 % request.uri)
753 rendered = redirectTo(request.uri, request)
755 try:
756 request.write(rendered.encode('utf-8') if isinstance(rendered, str) else rendered)
757 request.finish()
758 except Exception as err: # pragma: no cover
759 logging.exception(err)
761 return request
763 def getCaptchaImage(self, request):
764 """Get a CAPTCHA image from the remote reCaptcha server.
766 :type request: :api:`twisted.web.http.Request`
767 :param request: A client's initial request for some other resource
768 which is protected by this one (i.e. protected by a CAPTCHA).
769 :returns: A 2-tuple of ``(image, challenge)``, where::
770 - ``image`` is a string holding a binary, JPEG-encoded image.
771 - ``challenge`` is a unique string associated with the request.
773 capt = captcha.ReCaptcha(self.publicKey, self.secretKey)
775 try:
776 capt.get()
777 except Exception as error:
778 logging.fatal("Connection to Recaptcha server failed: %s" % error)
780 if capt.image is None:
781 logging.warn("No CAPTCHA image received from ReCaptcha server!")
783 return (capt.image, capt.challenge)
785 def getRemoteIP(self):
786 """Mask the client's real IP address with a faked one.
788 The fake client IP address is sent to the reCaptcha server, and it is
789 either the public IP address of bridges.torproject.org (if the config
790 option ``RECAPTCHA_REMOTE_IP`` is configured), or a random IP.
792 :rtype: str
793 :returns: A fake IP address to report to the reCaptcha API server.
795 if self.remoteIP:
796 remoteIP = self.remoteIP
797 else:
798 # generate a random IP for the captcha submission
799 remoteIP = IPv4Address(random.randint(0, 2**32-1)).compressed
801 return remoteIP
803 def checkSolution(self, request):
804 """Process a solved CAPTCHA by sending it to the ReCaptcha server.
806 The client's IP address is not sent to the ReCaptcha server; instead,
807 a completely random IP is generated and sent instead.
809 :type request: :api:`twisted.web.http.Request`
810 :param request: A ``Request`` object, including POST arguments which
811 should include two key/value pairs: one key being
812 ``'captcha_challenge_field'``, and the other,
813 ``'captcha_response_field'``. These POST arguments should be
814 obtained from :meth:`render_GET`.
815 :rtupe: :api:`twisted.internet.defer.Deferred`
816 :returns: A deferred which will callback with a tuple in the following
817 form:
818 (:type:`bool`, :api:`twisted.web.server.Request`)
819 If the CAPTCHA solution was valid, a tuple will contain::
820 (True, Request)
821 Otherwise, it will contain::
822 (False, Request)
824 challenge, response = self.extractClientSolution(request)
825 clientIP = self.getClientIP(request)
826 remoteIP = self.getRemoteIP()
828 logging.debug("Captcha from %r. Parameters: %r"
829 % (clientIP, request.args))
831 def checkResponse(solution, request):
832 """Check the :class:`txrecaptcha.RecaptchaResponse`.
834 :type solution: :class:`txrecaptcha.RecaptchaResponse`.
835 :param solution: The client's CAPTCHA solution, after it has been
836 submitted to the reCaptcha API server.
838 # This valid CAPTCHA result from this function cannot be reliably
839 # unittested, because it's callbacked to from the deferred
840 # returned by ``txrecaptcha.submit``, the latter of which would
841 # require networking (as well as automated CAPTCHA
842 # breaking). Hence, the 'no cover' pragma.
843 if solution.is_valid: # pragma: no cover
844 logging.info("Valid CAPTCHA solution from %r." % clientIP)
845 metrix.recordValidHTTPSRequest(request)
846 return (True, request)
847 else:
848 logging.info("Invalid CAPTCHA solution from %r: %r"
849 % (clientIP, solution.error_code))
850 metrix.recordInvalidHTTPSRequest(request)
851 return (False, request)
853 d = txrecaptcha.submit(challenge, response, self.secretKey,
854 remoteIP).addCallback(checkResponse, request)
855 return d
857 def render_GET(self, request):
858 """Retrieve a ReCaptcha from the API server and serve it to the client.
860 :type request: :api:`twisted.web.http.Request`
861 :param request: A ``Request`` object for 'bridges.html'.
862 :rtype: str
863 :returns: A rendered HTML page containing a ReCaptcha challenge image
864 for the client to solve.
866 return CaptchaProtectedResource.render_GET(self, request)
868 def render_POST(self, request):
869 """Process a client's CAPTCHA solution.
871 If the client's CAPTCHA solution is valid (according to
872 :meth:`checkSolution`), process and serve their original
873 request. Otherwise, redirect them back to a new CAPTCHA page.
875 :type request: :api:`twisted.web.http.Request`
876 :param request: A ``Request`` object, including POST arguments which
877 should include two key/value pairs: one key being
878 ``'captcha_challenge_field'``, and the other,
879 ``'captcha_response_field'``. These POST arguments should be
880 obtained from :meth:`render_GET`.
881 :returns: :api:`twisted.web.server.NOT_DONE_YET`, in order to handle
882 the ``Deferred`` returned from :meth:`checkSolution`. Eventually,
883 when the ``Deferred`` request is done being processed,
884 :meth:`_renderDeferred` will handle rendering and displaying the
885 HTML to the client.
887 self.setCSPHeader(request)
888 request.args = stringifyRequestArgs(request.args)
889 d = self.checkSolution(request)
890 d.addCallback(self._renderDeferred)
891 return NOT_DONE_YET
894 class BridgesResource(CustomErrorHandlingResource, CSPResource):
895 """This resource displays bridge lines in response to a request."""
897 isLeaf = True
899 def __init__(self, distributor, schedule, N=1, useForwardedHeader=False,
900 includeFingerprints=True):
901 """Create a new resource for displaying bridges to a client.
903 :type distributor: :class:`HTTPSDistributor`
904 :param distributor: The mechanism to retrieve bridges for this
905 distributor.
906 :type schedule: :class:`~bridgedb.schedule.ScheduledInterval`
907 :param schedule: The time period used to tweak the bridge selection
908 procedure.
909 :param int N: The number of bridges to hand out per query.
910 :param bool useForwardedHeader: Whether or not we should use the the
911 X-Forwarded-For header instead of the source IP address.
912 :param bool includeFingerprints: Do we include the bridge's
913 fingerprint in the response?
915 gettext.install("bridgedb")
916 CSPResource.__init__(self)
917 self.distributor = distributor
918 self.schedule = schedule
919 self.nBridgesToGive = N
920 self.useForwardedHeader = useForwardedHeader
921 self.includeFingerprints = includeFingerprints
923 def render(self, request):
924 """Render a response for a client HTTP request.
926 Presently, this method merely wraps :meth:`getBridgeRequestAnswer` to
927 catch any unhandled exceptions which occur (otherwise the server will
928 display the traceback to the client). If an unhandled exception *does*
929 occur, the client will be served the default "No bridges currently
930 available" HTML response page.
932 :type request: :api:`twisted.web.http.Request`
933 :param request: A ``Request`` object containing the HTTP method, full
934 URI, and any URL/POST arguments and headers present.
935 :rtype: bytes
936 :returns: A plaintext or HTML response to serve.
938 self.setCSPHeader(request)
939 request.args = stringifyRequestArgs(request.args)
941 try:
942 response = self.getBridgeRequestAnswer(request)
943 except Exception as err:
944 logging.exception(err)
945 response = self.renderAnswer(request)
947 return response.encode('utf-8') if isinstance(response, str) else response
949 def getClientIP(self, request):
950 """Get the client's IP address from the ``'X-Forwarded-For:'``
951 header, or from the :api:`request <twisted.web.server.Request>`.
953 :type request: :api:`twisted.web.http.Request`
954 :param request: A ``Request`` object for a
955 :api:`twisted.web.resource.Resource`.
956 :rtype: ``None`` or :any:`str`
957 :returns: The client's IP address, if it was obtainable.
959 return getClientIP(request, self.useForwardedHeader)
961 def getBridgeRequestAnswer(self, request):
962 """Respond to a client HTTP request for bridges.
964 :type request: :api:`twisted.web.http.Request`
965 :param request: A ``Request`` object containing the HTTP method, full
966 URI, and any URL/POST arguments and headers present.
967 :rtype: str
968 :returns: A plaintext or HTML response to serve.
970 bridgeLines = None
971 interval = self.schedule.intervalStart(time.time())
972 ip = self.getClientIP(request)
974 logging.info("Replying to web request from %s. Parameters were %r"
975 % (ip, request.args))
977 # Convert all key/value pairs from bytes to str.
978 str_args = {}
979 for arg, values in request.args.items():
980 arg = arg if isinstance(arg, str) else arg.decode("utf-8")
981 values = [value.decode("utf-8") if isinstance(value, bytes) else value for value in values]
982 str_args[arg] = values
983 request.args = str_args
985 if ip:
986 bridgeRequest = HTTPSBridgeRequest()
987 bridgeRequest.client = ip
988 bridgeRequest.isValid(True)
989 bridgeRequest.withIPversion(request.args)
990 bridgeRequest.withPluggableTransportType(request.args)
991 bridgeRequest.withoutBlockInCountry(request)
992 bridgeRequest.generateFilters()
994 bridges = self.distributor.getBridges(bridgeRequest, interval)
995 bridgeLines = [replaceControlChars(bridge.getBridgeLine(
996 bridgeRequest, self.includeFingerprints)) for bridge in bridges]
998 if antibot.isRequestFromBot(request):
999 transports = bridgeRequest.transports
1000 # Return either a decoy bridge or no bridge.
1001 if len(transports) > 2:
1002 logging.warning("More than one transport requested")
1003 return self.renderAnswer(request)
1004 ttype = "vanilla" if len(transports) == 0 else transports[0]
1005 return self.renderAnswer(request, antibot.getDecoyBridge(ttype, bridgeRequest.ipVersion))
1007 return self.renderAnswer(request, bridgeLines)
1009 def getResponseFormat(self, request):
1010 """Determine the requested format for the response.
1012 :type request: :api:`twisted.web.http.Request`
1013 :param request: A ``Request`` object containing the HTTP method, full
1014 URI, and any URL/POST arguments and headers present.
1015 :rtype: ``None`` or str
1016 :returns: The argument of the first occurence of the ``format=`` HTTP
1017 GET parameter, if any were present. (The only one which currently
1018 has any effect is ``format=plain``, see note in
1019 :meth:`renderAnswer`.) Otherwise, returns ``None``.
1021 format = request.args.get("format", None)
1022 if format and len(format):
1023 format = format[0] # Choose the first arg
1024 return format
1026 def renderAnswer(self, request, bridgeLines=None):
1027 """Generate a response for a client which includes **bridgesLines**.
1029 .. note: The generated response can be plain or HTML. A plain response
1030 looks like::
1032 voltron 1.2.3.4:1234 ABCDEF01234567890ABCDEF01234567890ABCDEF
1033 voltron 5.5.5.5:5555 0123456789ABCDEF0123456789ABCDEF01234567
1035 That is, there is no HTML, what you see is what you get, and what
1036 you get is suitable for pasting directly into Tor Launcher (or
1037 into a torrc, if you prepend ``"Bridge "`` to each line). The
1038 plain format can be requested from BridgeDB's web service by
1039 adding an ``&format=plain`` HTTP GET parameter to the URL. Also
1040 note that you won't get a QRCode, usage instructions, error
1041 messages, or any other fanciness if you use the plain format.
1043 :type request: :api:`twisted.web.http.Request`
1044 :param request: A ``Request`` object containing the HTTP method, full
1045 URI, and any URL/POST arguments and headers present.
1046 :type bridgeLines: list or None
1047 :param bridgeLines: A list of strings used to configure a Tor client
1048 to use a bridge. If ``None``, then the returned page will instead
1049 explain that there were no bridges of the type they requested,
1050 with instructions on how to proceed.
1051 :rtype: bytes
1052 :returns: A plaintext or HTML response to serve.
1054 rtl = False
1055 format = self.getResponseFormat(request)
1057 if format == 'plain':
1058 request.setHeader("Content-Type", "text/plain")
1059 try:
1060 rendered = '\n'.join(bridgeLines).encode('utf-8')
1061 except Exception as err:
1062 rendered = replaceErrorPage(request, err, html=False)
1063 else:
1064 request.setHeader("Content-Type", "text/html; charset=utf-8")
1065 qrcode = None
1066 qrjpeg = generateQR(bridgeLines)
1068 if qrjpeg:
1069 qrcode = 'data:image/jpeg;base64,%s' % base64.b64encode(qrjpeg)
1070 try:
1071 langs = translations.getLocaleFromHTTPRequest(request)
1072 rtl = translations.usingRTLLang(langs)
1073 template = lookup.get_template('bridges.html')
1074 rendered = template.render(strings,
1075 getSortedLangList(),
1076 rtl=rtl,
1077 lang=langs[0],
1078 langOverride=translations.isLangOverridden(request),
1079 answer=bridgeLines,
1080 qrcode=qrcode)
1081 except Exception as err:
1082 rendered = replaceErrorPage(request, err)
1084 return rendered.encode("utf-8") if isinstance(rendered, str) else rendered
1087 def addWebServer(config, distributor):
1088 """Set up a web server for HTTP(S)-based bridge distribution.
1090 :type config: :class:`bridgedb.persistent.Conf`
1091 :param config: A configuration object from
1092 :mod:`bridgedb.main`. Currently, we use these options::
1093 HTTP_UNENCRYPTED_PORT
1094 HTTP_UNENCRYPTED_BIND_IP
1095 HTTP_USE_IP_FROM_FORWARDED_HEADER
1096 HTTPS_N_BRIDGES_PER_ANSWER
1097 HTTPS_INCLUDE_FINGERPRINTS
1098 HTTPS_KEY_FILE
1099 HTTPS_CERT_FILE
1100 HTTPS_PORT
1101 HTTPS_BIND_IP
1102 HTTPS_USE_IP_FROM_FORWARDED_HEADER
1103 HTTPS_ROTATION_PERIOD
1104 RECAPTCHA_ENABLED
1105 RECAPTCHA_PUB_KEY
1106 RECAPTCHA_SEC_KEY
1107 RECAPTCHA_REMOTEIP
1108 GIMP_CAPTCHA_ENABLED
1109 GIMP_CAPTCHA_DIR
1110 GIMP_CAPTCHA_HMAC_KEYFILE
1111 GIMP_CAPTCHA_RSA_KEYFILE
1112 SERVER_PUBLIC_FQDN
1113 CSP_ENABLED
1114 CSP_REPORT_ONLY
1115 CSP_INCLUDE_SELF
1116 :type distributor: :class:`bridgedb.distributors.https.distributor.HTTPSDistributor`
1117 :param distributor: A bridge distributor.
1118 :raises SystemExit: if the servers cannot be started.
1119 :rtype: :api:`twisted.web.server.Site`
1120 :returns: A webserver.
1122 captcha = None
1123 fwdHeaders = config.HTTP_USE_IP_FROM_FORWARDED_HEADER
1124 numBridges = config.HTTPS_N_BRIDGES_PER_ANSWER
1125 fprInclude = config.HTTPS_INCLUDE_FINGERPRINTS
1127 logging.info("Starting web servers...")
1129 setFQDN(config.SERVER_PUBLIC_FQDN)
1131 index = IndexResource()
1132 options = OptionsResource()
1133 howto = HowtoResource()
1134 robots = static.File(os.path.join(TEMPLATE_DIR, 'robots.txt'))
1135 assets = static.File(os.path.join(TEMPLATE_DIR, 'assets/'))
1136 keys = static.Data(strings.BRIDGEDB_OPENPGP_KEY.encode('utf-8'), 'text/plain')
1137 csp = CSPResource(enabled=config.CSP_ENABLED,
1138 includeSelf=config.CSP_INCLUDE_SELF,
1139 reportViolations=config.CSP_REPORT_ONLY,
1140 useForwardedHeader=fwdHeaders)
1142 root = CustomErrorHandlingResource()
1143 root.putChild(b'', index)
1144 root.putChild(b'robots.txt', robots)
1145 root.putChild(b'keys', keys)
1146 root.putChild(b'assets', assets)
1147 root.putChild(b'options', options)
1148 root.putChild(b'howto', howto)
1149 root.putChild(b'maintenance', maintenance)
1150 root.putChild(b'error', resource500)
1151 root.putChild(CSPResource.reportURI, csp)
1153 if config.RECAPTCHA_ENABLED:
1154 publicKey = config.RECAPTCHA_PUB_KEY
1155 secretKey = config.RECAPTCHA_SEC_KEY
1156 captcha = partial(ReCaptchaProtectedResource,
1157 remoteIP=config.RECAPTCHA_REMOTEIP)
1158 elif config.GIMP_CAPTCHA_ENABLED:
1159 # Get the master HMAC secret key for CAPTCHA challenges, and then
1160 # create a new HMAC key from it for use on the server.
1161 captchaKey = crypto.getKey(config.GIMP_CAPTCHA_HMAC_KEYFILE)
1162 hmacKey = crypto.getHMAC(captchaKey, "Captcha-Key")
1163 # Load or create our encryption keys:
1164 secretKey, publicKey = crypto.getRSAKey(config.GIMP_CAPTCHA_RSA_KEYFILE)
1165 captcha = partial(GimpCaptchaProtectedResource,
1166 hmacKey=hmacKey,
1167 captchaDir=config.GIMP_CAPTCHA_DIR)
1169 if config.HTTPS_ROTATION_PERIOD:
1170 count, period = config.HTTPS_ROTATION_PERIOD.split()
1171 sched = ScheduledInterval(int(count), period)
1172 else:
1173 sched = Unscheduled()
1175 bridges = BridgesResource(distributor, sched, numBridges, fwdHeaders,
1176 includeFingerprints=fprInclude)
1177 if captcha:
1178 # Protect the 'bridges' page with a CAPTCHA, if configured to do so:
1179 protected = captcha(publicKey=publicKey,
1180 secretKey=secretKey,
1181 useForwardedHeader=fwdHeaders,
1182 protectedResource=bridges)
1183 root.putChild(b'bridges', protected)
1184 logging.info("Protecting resources with %s." % captcha.func.__name__)
1185 else:
1186 root.putChild(b'bridges', bridges)
1188 site = Site(root)
1189 site.displayTracebacks = False
1191 if config.HTTP_UNENCRYPTED_PORT: # pragma: no cover
1192 ip = config.HTTP_UNENCRYPTED_BIND_IP or ""
1193 port = config.HTTP_UNENCRYPTED_PORT or 80
1194 try:
1195 reactor.listenTCP(port, site, interface=ip)
1196 except CannotListenError as error:
1197 raise SystemExit(error)
1198 logging.info("Started HTTP server on %s:%d" % (str(ip), int(port)))
1200 if config.HTTPS_PORT: # pragma: no cover
1201 ip = config.HTTPS_BIND_IP or ""
1202 port = config.HTTPS_PORT or 443
1203 try:
1204 from twisted.internet.ssl import DefaultOpenSSLContextFactory
1205 factory = DefaultOpenSSLContextFactory(config.HTTPS_KEY_FILE,
1206 config.HTTPS_CERT_FILE)
1207 reactor.listenSSL(port, site, factory, interface=ip)
1208 except CannotListenError as error:
1209 raise SystemExit(error)
1210 logging.info("Started HTTPS server on %s:%d" % (str(ip), int(port)))
1212 return site