wsgi.py 35.3 KB
Newer Older
1
# This file is part of caucase
2
# Copyright (C) 2017-2021  Nexedi SA
3 4 5
#     Alain Takoudjou <alain.takoudjou@nexedi.com>
#     Vincent Pelletier <vincent@nexedi.com>
#
6 7 8
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
9
#
10 11 12 13 14
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
15
#
16 17 18 19 20
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
Vincent Pelletier's avatar
Vincent Pelletier committed
21 22 23 24
"""
Caucase - Certificate Authority for Users, Certificate Authority for SErvices
"""
from __future__ import absolute_import
25 26 27 28 29 30 31
try: # pragma: no cover
  from http.cookies import SimpleCookie, CookieError
  import http.client as http_client
except ImportError: # pragma: no cover
  # BBB: py2.7
  from Cookie import SimpleCookie, CookieError
  import httplib as http_client
Vincent Pelletier's avatar
Vincent Pelletier committed
32
import json
33
import os
34
import sys
35 36
import threading
import time
37 38 39 40 41 42 43
try: # pragma: no cover
  from urllib.parse import quote, urlencode
  from urllib.parse import parse_qs
except ImportError: # pragma: no cover
  # BBB: py2.7
  from urllib import quote, urlencode
  from urlparse import parse_qs
44 45
from wsgiref.util import application_uri, request_uri
import jwt
Vincent Pelletier's avatar
Vincent Pelletier committed
46 47
from . import utils
from . import exceptions
48

49
# pylint: disable=import-error,no-name-in-module
50
if sys.version_info[0] >= 3: # pragma: no cover
51 52
  from html import escape
else: # pragma: no cover
53
  # BBB: py2.7
54
  from cgi import escape
55
# pylint: enable=import-error,no-name-in-module
56

57 58 59
__all__ = ('Application', 'CORSTokenManager')

# TODO: l10n
60
CORS_FORM_TEMPLATE = b'''\
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
<html>
<head>
  <title>Caucase CORS access</title>
</head>
<body>
  <form action="." method="post">
    <input type="hidden" name="return_to" value="%(return_to)s"/>
    <input type="hidden" name="origin" value="%(origin)s"/>
    Your browser is trying to access caucase at <b>%(caucase)s</b>
    under the control of <b>%(origin)s</b>.<br/>
    Do you wish to grant it the permission to use your credentials ?<br/>
    <a href="%(return_to)s">Go back</a>
    <button name="grant" value="0">Deny access</button>
    <button name="grant" value="1">Grant access</button><br/>
    If you already authorised this origin and you still get redirected here,
    you may need to enable 3rd-party cookies in your browser.
  </form>
</body>
</html>
'''
CORS_FORM_ORIGIN_PARAMETER = 'origin'
CORS_FORM_RETURN_PARAMETER = 'return'
CORS_POLICY_ALWAYS_DENY = object()
CORS_POLICY_ALWAYS_ALLOW = object()
# If neither policy is set: ask user
86

87 88 89 90
SUBPATH_FORBIDDEN = object()
SUBPATH_REQUIRED = object()
SUBPATH_OPTIONAL = object()

91 92 93 94 95
CORS_COOKIE_ACCESS_KEY = 'a' # Whether user decided to grant access.
CORS_COOKIE_ORIGIN_KEY = 'o' # Prevent an origin from stealing another's token.

A_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 # Roughly a year

Vincent Pelletier's avatar
Vincent Pelletier committed
96
def _getStatus(code):
97
  return '%i %s' % (code, http_client.responses[code])
98

Vincent Pelletier's avatar
Vincent Pelletier committed
99 100 101 102
class ApplicationError(Exception):
  """
  WSGI HTTP error base class.
  """
103
  status = _getStatus(http_client.INTERNAL_SERVER_ERROR)
104 105
  _response_headers = []

106 107
  @property
  def response_headers(self):
Vincent Pelletier's avatar
Vincent Pelletier committed
108 109 110
    """
    Get a copy of error's response headers.
    """
111
    return self._response_headers[:]
Vincent Pelletier's avatar
Vincent Pelletier committed
112 113 114 115 116

class BadRequest(ApplicationError):
  """
  HTTP bad request error
  """
117
  status = _getStatus(http_client.BAD_REQUEST)
118

119 120 121 122
class Unauthorized(ApplicationError):
  """
  HTTP unauthorized error
  """
123
  status = _getStatus(http_client.UNAUTHORIZED)
124 125 126 127 128

class SSLUnauthorized(Unauthorized):
  """
  Authentication failed because of SSL credentials (missing or incorrect)
  """
129
  _response_headers = [
Vincent Pelletier's avatar
Vincent Pelletier committed
130
    # Note: non standard scheme, suggested in
131 132 133 134
    # https://www.ietf.org/mail-archive/web/httpbisa/current/msg03764.html
    ('WWW-Authenticate', 'transport'),
  ]

135 136 137 138 139 140 141 142 143 144 145 146 147 148 149
class OriginUnauthorized(Unauthorized):
  """
  Authentication failed because "Origin" header is not authorised by user.
  AKA, CORS protection
  """
  def __init__(self, login_url, *args, **kw):
    super(OriginUnauthorized, self).__init__(*args, **kw)
    self._response_headers = [
      ('WWW-Authenticate', 'cors url=' + quote(login_url)),
    ]

class Forbidden(ApplicationError):
  """
  HTTP forbidden error
  """
150
  status = _getStatus(http_client.FORBIDDEN)
151

Vincent Pelletier's avatar
Vincent Pelletier committed
152 153 154 155
class NotFound(ApplicationError):
  """
  HTTP not found error
  """
156
  status = _getStatus(http_client.NOT_FOUND)
157

Vincent Pelletier's avatar
Vincent Pelletier committed
158 159 160
class BadMethod(ApplicationError):
  """
  HTTP bad method error
161
  """
162
  status = _getStatus(http_client.METHOD_NOT_ALLOWED)
Vincent Pelletier's avatar
Vincent Pelletier committed
163

164 165 166 167 168 169
  def __init__(self, allowed_list):
    super(BadMethod, self).__init__(allowed_list)
    self._response_headers = [
      ('Allow', ', '.join(allowed_list)),
    ]

Vincent Pelletier's avatar
Vincent Pelletier committed
170 171 172 173
class Conflict(ApplicationError):
  """
  HTTP conflict
  """
174
  status = _getStatus(http_client.CONFLICT)
Vincent Pelletier's avatar
Vincent Pelletier committed
175 176 177 178 179

class TooLarge(ApplicationError):
  """
  HTTP too large error
  """
180
  status = _getStatus(http_client.REQUEST_ENTITY_TOO_LARGE)
Vincent Pelletier's avatar
Vincent Pelletier committed
181 182

class InsufficientStorage(ApplicationError):
183
  """
Vincent Pelletier's avatar
Vincent Pelletier committed
184 185
  No storage slot available (not necessarily out of disk space)
  """
186 187 188
  # python2.7's httplib lacks the textual description for 507, although it
  # has the constant.
  # And modern pylint on python3 complain that
189
  # http_client.INSUFFICIENT_STORAGE, an enum item, is not suitable for %i
190 191 192
  # (spoiler: it is suitable).
  # Also, older pylint (last version suppoting 2.7 ?) does not support
  # bad-string-format-type but does not detect anything wrong here.
193
  # pylint: disable=bad-string-format-type
194
  status = '%i Insufficient Storage' % (http_client.INSUFFICIENT_STORAGE, )
195
  # pylint: enable=bad-string-format-type
Vincent Pelletier's avatar
Vincent Pelletier committed
196

197 198 199 200
STATUS_OK = _getStatus(http_client.OK)
STATUS_CREATED = _getStatus(http_client.CREATED)
STATUS_NO_CONTENT = _getStatus(http_client.NO_CONTENT)
STATUS_FOUND = _getStatus(http_client.FOUND)
Vincent Pelletier's avatar
Vincent Pelletier committed
201 202
MAX_BODY_LENGTH = 10 * 1024 * 1024 # 10 MB

203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
class CORSTokenManager(object):
  """
  CORS token producer and validator.
  Handles generating the secret needed to sign tokens, and its seamless
  renewal.
  """
  _secret_validity_period = A_YEAR_IN_SECONDS

  def __init__(self, secret_list=(), onNewKey=lambda _: None):
    """
    secret_list (list of opaque)
      Values that onNewKey received on previous instance.
    onNewKey (callable)
      Called when a new key has been generated, with the updated
      secret list as argument.
    """
    self._secret_list = sorted(secret_list, key=lambda x: x[0])
    self._onNewKey = onNewKey
    self._lock = threading.Lock()

  def sign(self, payload):
    """
    payload (any json-friendly data structure)
      The value to sign.
    Returns signed token as a string.
    """
    now = time.time()
    with self._lock:
      secret_list = self._secret_list = [
        x
        for x in self._secret_list
        if x[0] > now
      ]
      if secret_list:
        until, key = secret_list[-1]
        if until - now < self._secret_validity_period // 2:
          # Generate a new secret well ahead of previous secret's expiration.
          key = None
      else:
        key = None
      if key is None:
        key = os.urandom(32)
        secret_list.append((now + self._secret_validity_period, key))
        self._onNewKey(secret_list)
247
    return utils.toUnicode(jwt.encode(
248 249 250
      payload={'p': payload},
      key=key,
      algorithm='HS256',
251
    ))
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272

  def verify(self, token, default=None):
    """
    token (str)
      Signed tokrn to validate.
    Returns token's payload if it passes checks.
    Otherwise, returns default.
    """
    for _, key in self._secret_list:
      # Note: not enforcing secret expiration at this level, as tokens should
      # expire well before any secret expires.
      try:
        return jwt.decode(
          jwt=token,
          key=key,
          algorithms=['HS256'],
        )['p']
      except jwt.InvalidTokenError:
        pass
    return default

Vincent Pelletier's avatar
Vincent Pelletier committed
273 274 275 276 277 278
class Application(object):
  """
  WSGI application class

  Thread- and process-safe (locks handled by sqlite3).
  """
279 280 281 282 283 284 285 286 287 288
  def __init__(
    self,
    cau,
    cas,
    http_url,
    https_url,
    cors_token_manager,
    cors_cookie_id='cors',
    cors_whitelist=(),
  ):
Vincent Pelletier's avatar
Vincent Pelletier committed
289 290 291 292 293 294 295 296
    """
    cau (caucase.ca.CertificateAuthority)
      CA for users.
      Will be hosted under /cau

    cas (caucase.ca.CertificateAuthority)
      CA for services.
      Will be hosted under /cas
297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315

    http_url (str)
      HTTP URL the application is hosted under.
      Used to derive HATEOAS URLs.

    https_url (str)
      HTTPS URL the application is hosted under.
      Used to derive HATEOAS URLs.

    cors_cookie_id (str)
      Cookie name to use to store CORS token.

    cors_token_manager (CORSTokenManager)
      Generates CORS token secrets.
      Application wrapper should handle some form of persistence for best user
      experience (so token survive server restarts).

    cors_whitelist (list of strings)
      List of Origin values to always trust.
Vincent Pelletier's avatar
Vincent Pelletier committed
316 317
    """
    self._cau = cau
318 319 320 321 322
    self._http_url = http_url.rstrip('/')
    self._https_url = https_url.rstrip('/')
    self._cors_cookie_id = cors_cookie_id
    self._cors_token_manager = cors_token_manager
    self._cors_whitelist = cors_whitelist
323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
    # Routing dict structure:

    # path entry dict:
    # "method": method dict
    # "context": any object
    # "routing": routing dict

    # routing dict:
    # key: path entry (ie, everything but slashes)
    # value: path entry dict

    # method dict:
    # key: HTTP method ("GET", "POST", ...)
    # value: action dict

    # action dict:
    # "do": callable for the action
    #   If "subpath" forbidden:
    #     (context, environ) -> (status, header_list, iterator)
    #   Otherwise:
    #     (context, environ, subpath) -> (status, header_list, iterator)
    # - context is the value of the nearest path entry dict's "context", None
    #   by default.
    # - environ: wsgi environment
    # - subpath: trailing path component list
    # - status: HTTP status code & reason
    # - header_list: HTTP reponse header list (see wsgi specs)
    # - iterator: HTTP response body generator (see wsgi specs)
351 352 353 354
    # "cors": CORS policy (default: ask)
    # "descriptor": list of descriptor dicts.
    # "context_is_routing": whether context should be set to routing dict for
    #   current path, instead of nearest context dict. (default: False)
355 356 357
    # "subpath": whether a subpath is expected, forbidden, or optional
    #   (default: forbidden)

358 359 360 361 362 363 364 365
    # descriptor dict:
    # NON-AUTORITATIVE ! Only for HAL API auto-description generation.
    # "name": HAL action or link name (required)
    # "title": HAL title (required)
    # "subpath": HAL href trailer, must be an URL template piece (default: None)
    # "authenticated": whether the action/link requires authentication
    #   (default: False)

366
    caucase_routing_dict = {
367
      'crl': {
368 369 370
        'method': {
          'GET': {
            'do': self.getCertificateRevocationList,
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388
            'subpath': SUBPATH_OPTIONAL,
            'descriptor': [
              {
                'name': 'getCertificateRevocationListList',
                'title': (
                  'Retrieve latest certificate revocation list for all valid '
                  'authorities.'
                ),
              },
              {
                'name': 'getCertificateRevocationList',
                'title': (
                  'Retrieve latest certificate revocation list for given '
                  'decimal representation of the authority identifier.'
                ),
                'subpath': '{+authority_key_id}',
              },
            ],
389
          },
390
        },
391 392
      },
      'csr': {
393 394 395 396
        'method': {
          'GET': {
            'do': self.getCSR,
            'subpath': SUBPATH_OPTIONAL,
397 398 399 400 401 402 403 404 405
            'descriptor': [{
              'name': 'getPendingCertificateRequestList',
              'title': 'List pending certificate signing requests.',
              'authenticated': True
            }, {
              'name': 'getCertificateSigningRequest',
              'title': 'Retrieve a pending certificate signing request.',
              'subpath': '{+csr_id}',
            }],
406 407 408
          },
          'PUT': {
            'do': self.createCertificateSigningRequest,
409 410 411 412
            'descriptor': [{
              'name': 'createCertificateSigningRequest',
              'title': 'Request a new certificate signature.',
            }],
413 414 415 416
          },
          'DELETE': {
            'do': self.deletePendingCertificateRequest,
            'subpath': SUBPATH_REQUIRED,
417 418 419 420 421 422
            'descriptor': [{
              'name': 'deletePendingCertificateRequest',
              'title': 'Reject a pending certificate signing request.',
              'subpath': '{+csr_id}',
              'authenticated': True,
            }],
423
          },
424
        },
425 426 427 428 429 430 431
      },
      'crt': {
        'routing': {
          'ca.crt.pem': {
            'method': {
              'GET': {
                'do': self.getCACertificate,
432 433 434 435
                'descriptor': [{
                  'name': 'getCACertificate',
                  'title': 'Retrieve current CA certificate.',
                }],
436 437 438 439 440 441 442
              },
            },
          },
          'ca.crt.json': {
            'method': {
              'GET': {
                'do': self.getCACertificateChain,
443 444 445 446
                'descriptor': [{
                  'name': 'getCACertificateChain',
                  'title': 'Retrieve current CA certificate trust chain.',
                }],
447 448 449 450 451 452 453
              },
            },
          },
          'revoke': {
            'method': {
              'PUT': {
                'do': self.revokeCertificate,
454 455 456 457
                'descriptor': [{
                  'name': 'revokeCertificate',
                  'title': 'Revoke a certificate',
                }],
458 459 460 461 462 463 464
              },
            },
          },
          'renew': {
            'method': {
              'PUT': {
                'do': self.renewCertificate,
465 466 467 468
                'descriptor': [{
                  'name': 'renewCertificate',
                  'title': 'Renew a certificate',
                }],
469 470 471
              },
            },
          },
472
        },
473 474 475 476
        'method': {
          'GET': {
            'do': self.getCertificate,
            'subpath': SUBPATH_REQUIRED,
477 478 479 480 481 482
            'descriptor': [{
              'name': 'getCertificate',
              'subpath': '{+csr_id}',
              'templated': True,
              'title': 'Retrieve a signed certificate.',
            }],
483 484 485 486
          },
          'PUT': {
            'do': self.createCertificate,
            'subpath': SUBPATH_REQUIRED,
487 488 489 490 491 492 493
            'descriptor': [{
              'name': 'createCertificate',
              'subpath': '{+crt_id}',
              'title': 'Accept pending certificate signing request',
              'templated': True,
              'authenticated': True,
             }],
494
          },
495
        },
496
      },
497
    }
498 499 500 501 502 503 504 505 506 507 508
    getHALMethodDict = lambda name, title: {
      'GET': {
        'do': self.getHAL,
        'context_is_routing': True,
        'cors': CORS_POLICY_ALWAYS_ALLOW,
        'descriptor': [{
          'name': name,
          'title': title,
        }],
      },
    }
509
    self._root_dict = {
510 511 512 513 514 515 516 517
      'method': {
        'GET': {
          # XXX: Use full-recursion getHAL instead ?
          'do': self.getTopHAL,
          'context_is_routing': True,
          'cors': CORS_POLICY_ALWAYS_ALLOW,
        },
      },
518
      'routing': {
519 520 521 522 523 524 525 526 527 528 529
        'cors': {
          'method': {
            'GET': {
              'do': self.getCORSForm,
            },
            'POST': {
              'do': self.postCORSForm,
              'cors': CORS_POLICY_ALWAYS_DENY,
            },
          },
        },
530
        'cas': {
531
          'method': getHALMethodDict('getCASHAL', 'cas'),
532 533
          'context': cas,
          'routing': caucase_routing_dict,
534
        },
535
        'cau': {
536
          'method': getHALMethodDict('getCAUHAL', 'cau'),
537 538
          'context': cau,
          'routing': caucase_routing_dict,
539
        },
540 541
      },
    }
Vincent Pelletier's avatar
Vincent Pelletier committed
542 543 544 545 546

  def __call__(self, environ, start_response):
    """
    WSGI entry point
    """
547
    cors_header_list = []
548 549
    try: # Convert ApplicationError subclasses into error responses
      try: # Convert exceptions into ApplicationError subclass exceptions
550 551 552 553 554 555 556 557 558 559 560 561 562 563
        path_item_list = [
          x
          for x in environ.get('PATH_INFO', '').split('/')
          if x
        ]
        path_entry_dict = self._root_dict
        context = None
        while path_item_list:
          context = path_entry_dict.get('context', context)
          try:
            path_entry_dict = path_entry_dict['routing'][path_item_list[0]]
          except KeyError:
            break
          del path_item_list[0]
564
        # If this raises, it means the routing dict is inconsistent.
565
        method_dict = path_entry_dict['method']
566
        request_method = environ['REQUEST_METHOD']
567 568 569 570 571 572 573
        try:
          action_dict = method_dict[request_method]
        except KeyError:
          if request_method == 'OPTIONS':
            status = STATUS_NO_CONTENT
            header_list = []
            result = []
574 575 576 577 578 579 580 581 582 583
            self._checkCORSAccess(
              environ=environ,
              # Pre-flight is always allowed.
              policy=CORS_POLICY_ALWAYS_ALLOW,
              header_list=cors_header_list,
              preflight=True,
            )
            if cors_header_list:
              # CORS headers added, add more
              self._optionAddCORSHeaders(method_dict, cors_header_list)
584
          else:
585
            raise BadMethod(list(method_dict.keys()) + ['OPTIONS'])
586 587 588 589 590 591 592
        else:
          subpath = action_dict.get('subpath', SUBPATH_FORBIDDEN)
          if (
            subpath is SUBPATH_FORBIDDEN and path_item_list or
            subpath is SUBPATH_REQUIRED and not path_item_list
          ):
            raise NotFound
593 594 595 596 597 598
          self._checkCORSAccess(
            environ=environ,
            policy=action_dict.get('cors'),
            header_list=cors_header_list,
            preflight=False,
          )
599 600 601 602 603 604 605 606 607
          if action_dict.get('context_is_routing'):
            context = path_entry_dict.get('routing')
          kw = {
            'context': context,
            'environ': environ,
          }
          if subpath != SUBPATH_FORBIDDEN:
            kw['subpath'] = path_item_list
          status, header_list, result = action_dict['do'](**kw)
Vincent Pelletier's avatar
Vincent Pelletier committed
608 609 610 611 612 613 614 615
      except ApplicationError:
        raise
      except exceptions.NotFound:
        raise NotFound
      except exceptions.Found:
        raise Conflict
      except exceptions.NoStorage:
        raise InsufficientStorage
616
      except exceptions.NotJSON:
617
        raise BadRequest(b'Invalid json payload')
618
      except exceptions.CertificateAuthorityException as e:
Vincent Pelletier's avatar
Vincent Pelletier committed
619 620
        raise BadRequest(str(e))
      except Exception:
Vincent Pelletier's avatar
Vincent Pelletier committed
621 622 623 624 625
        utils.log_exception(
          error_file=environ['wsgi.errors'],
          exc_info=sys.exc_info(),
          client_address=environ.get('REMOTE_ADDR', ''),
        )
Vincent Pelletier's avatar
Vincent Pelletier committed
626
        raise ApplicationError
627
    except ApplicationError as e:
628 629
      status = e.status
      header_list = e.response_headers
630
      result = [utils.toBytes(str(x)) for x in e.args]
631 632 633
    # Note: header_list and cors_header_list are expected to contain
    # distinct header sets. This may not always stay true for "Vary".
    header_list.extend(cors_header_list)
634
    header_list.append(('Date', utils.timestamp2IMFfixdate(time.time())))
635
    start_response(status, header_list)
Vincent Pelletier's avatar
Vincent Pelletier committed
636 637
    return result

638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654
  @staticmethod
  def _returnFile(data, content_type, header_list=None):
    if header_list is None:
      header_list = []
    header_list.append(('Content-Type', content_type))
    header_list.append(('Content-Length', str(len(data))))
    return (STATUS_OK, header_list, [data])

  @staticmethod
  def _getCSRID(subpath):
    try:
      crt_id, = subpath
    except ValueError:
      raise NotFound
    try:
      return int(crt_id)
    except ValueError:
655
      raise BadRequest(b'Invalid integer')
656

Vincent Pelletier's avatar
Vincent Pelletier committed
657 658 659 660 661 662 663 664 665
  @staticmethod
  def _read(environ):
    """
    Read the entire request body.

    Raises BadRequest if request Content-Length cannot be parsed.
    Raises TooLarge if Content-Length if over MAX_BODY_LENGTH.
    If Content-Length is not set, reads at most MAX_BODY_LENGTH bytes.
    """
666 667 668 669 670 671
    content_length = environ.get('CONTENT_LENGTH')
    if not content_length:
      result = environ['wsgi.input'].read(MAX_BODY_LENGTH)
      if environ['wsgi.input'].read(1):
        raise TooLarge(b'Content-Length limit exceeded')
      return result
672
    try:
673
      length = int(content_length, 10)
674
    except ValueError:
675
      raise BadRequest(b'Invalid Content-Length')
676
    if length > MAX_BODY_LENGTH:
677
      raise TooLarge(b'Content-Length limit exceeded')
678
    return environ['wsgi.input'].read(length)
Vincent Pelletier's avatar
Vincent Pelletier committed
679

680
  def _authenticate(self, environ, header_list):
Vincent Pelletier's avatar
Vincent Pelletier committed
681 682 683
    """
    Verify user authentication.

Vincent Pelletier's avatar
Vincent Pelletier committed
684
    Raises SSLUnauthorized if authentication does not pass checks.
685
    On success, appends a "Cache-Control" header.
Vincent Pelletier's avatar
Vincent Pelletier committed
686 687 688 689 690 691
    """
    try:
      ca_list = self._cau.getCACertificateList()
      utils.load_certificate(
        environ.get('SSL_CLIENT_CERT', b''),
        trusted_cert_list=ca_list,
692 693
        crl_list=[
          utils.load_crl(x, ca_list)
694
          for x in self._cau.getCertificateRevocationListDict().values()
695
        ],
Vincent Pelletier's avatar
Vincent Pelletier committed
696 697
      )
    except (exceptions.CertificateVerificationError, ValueError):
698
      raise SSLUnauthorized
699
    header_list.append(('Cache-Control', 'private'))
Vincent Pelletier's avatar
Vincent Pelletier committed
700 701 702 703 704 705 706 707 708

  def _readJSON(self, environ):
    """
    Read request body and convert to json object.

    Raises BadRequest if request Content-Type is not 'application/json', or if
    json decoding fails.
    """
    if environ.get('CONTENT_TYPE') != 'application/json':
709
      raise BadRequest(b'Bad Content-Type')
Vincent Pelletier's avatar
Vincent Pelletier committed
710 711
    data = self._read(environ)
    try:
712 713
      return json.loads(data.decode('utf-8'))
    except (ValueError, UnicodeDecodeError):
714
      raise BadRequest(b'Invalid json')
715

716 717 718 719 720 721 722 723 724 725 726 727
  def _createCORSCookie(self, environ, value):
    """
    Create a new CORS cookie with given content.

    environ (dict)
      To decide cookie's scope (path).
    value (string)
      Cookie's raw value.

    Returns a Morsel instance.
    """
    cookie = SimpleCookie({self._cors_cookie_id: value})[self._cors_cookie_id]
728
    cookie['path'] = environ.get('SCRIPT_NAME') or '/'
729
    cookie['max-age'] = A_YEAR_IN_SECONDS
730 731 732 733 734 735 736 737 738 739 740 741 742 743 744
    # No "secure" flag: cookie is not secret, and is protected against
    # tampering on client side.
    # No "httponly" flag: cookie is protected against tampering on client side,
    # and this allows a GUI to list allowed origins and let user delete some
    # (which may not prevent a hostile client from restoring its access for
    # the validity period of their entry - a year by default).
    return cookie

  @staticmethod
  def _optionAddCORSHeaders(method_dict, header_list):
    header_list.append((
      'Access-Control-Allow-Methods',
      ', '.join(
        [
          x
745
          for x, y in method_dict.items()
746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776
          if y.get('cors') is not CORS_POLICY_ALWAYS_DENY
        ] + ['OPTIONS'],
      ),
    ))
    header_list.append((
      'Access-Control-Allow-Headers',
      # Only list values which are not:
      # - safelisted names for their safe values
      # - forbidden names (handled by user agent, not controlled by script)
      'Content-Type, User-Agent',
    ))

  def _checkCORSAccess(
    self,
    environ,
    policy,
    header_list,
    preflight,
  ):
    """
    Check whether access should be allowed, based on origin:
    - allow (return)
    - deny (raise Forbidden)
    - request user approval (raise OriginUnauthorized)
    When allowing, populate header_list with CORS header when in a cross-origin
    context.
    "null" origin (aka "sensitive origin") always gets Forbidden instead of
    OriginUnauthorized.
    header_list may be modified before raising OriginUnauthorized, in order to
    give client an opportunity to clean stale/broken values.
    """
777
    my_origin = application_uri(environ).split('/', 1)[0]
778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826
    origin = environ.get('HTTP_ORIGIN', my_origin)
    if origin == my_origin:
      # Not a CORS request
      return
    if (
      policy is CORS_POLICY_ALWAYS_ALLOW or
      origin in self._cors_whitelist
    ):
      access = True
    elif policy is CORS_POLICY_ALWAYS_DENY or origin == 'null':
      access = False
    else:
      cookie = SimpleCookie(environ.get('HTTP_COOKIE', ''))
      try:
        origin_control_dict = json.loads(cookie[self._cors_cookie_id].value)
        access_dict = origin_control_dict[origin]
      except KeyError:
        # Missing cookie or origin
        access = None
      except ValueError:
        # Malformed cookie, tell client to discard it
        cookie = self._createCORSCookie(environ, '')
        cookie['expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT'
        header_list.append(
          ('Set-Cookie', cookie.OutputString()),
        )
        access = None
      else:
        access_dict = self._cors_token_manager.verify(access_dict, {})
        if access_dict.get(CORS_COOKIE_ORIGIN_KEY) == origin:
          access = access_dict.get(CORS_COOKIE_ACCESS_KEY)
        else:
          # Invalid or expired entry for origin, tell client to store
          # a new cookie without it.
          access = None
          del origin_control_dict[origin]
          header_list.append(
            (
              'Set-Cookie',
              self._createCORSCookie(
                environ,
                json.dumps(origin_control_dict),
              ).OutputString(),
            ),
          )
      if access is None:
        # Missing or malformed cookie, missing or expired or invalid entry
        # for origin: require authentication via cors form.
        raise OriginUnauthorized(
827
          self._https_url + '/cors?' +
828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881
          urlencode([(CORS_FORM_ORIGIN_PARAMETER, origin)]) +
          '{&' + CORS_FORM_RETURN_PARAMETER + '}',
        )
    if access:
      header_list.append(('Access-Control-Allow-Credentials', 'true'))
      header_list.append(('Access-Control-Allow-Origin', origin))
      if not preflight:
        header_list.append((
          'Access-Control-Expose-Headers',
          # Only list values which are not:
          # - safelisted names for their safe values
          # - forbidden names (handled by user agent, not controlled by script)
          'Location, WWW-Authenticate',
        ))
      header_list.append(('Vary', 'Origin'))
    else:
      raise Forbidden

  def getTopHAL(self, context, environ):
    """
    Handle GET / .
    """
    return self.getHAL(context, environ, recurse=False)

  def getHAL(self, context, environ, recurse=True):
    """
    Handle GET /{,context} .
    """
    https_url = self._https_url
    http_url = (
      # Do not advertise http URLs when accessed in https: client already
      # decided to trust our certificate, do not lead them away.
      https_url
      if environ['wsgi.url_scheme'] == 'https' else
      self._http_url
    )
    hal = {
      '_links': {
        'self': {
          'href': request_uri(environ, include_query=False).rstrip('/'),
        },
      },
    }
    path_info = environ.get('PATH_INFO', '').rstrip('/')
    if path_info:
      hal['_links']['home'] = {
        'href': application_uri(environ),
      }
    routing_dict_list = [(
      (environ.get('SCRIPT_NAME', '') + path_info) or '/',
      context,
    )]
    while routing_dict_list:
      routing_path, routing_dict = routing_dict_list.pop()
882
      for component, path_entry_dict in routing_dict.items():
883 884 885 886 887 888
        component_path = routing_path + '/' + component
        if recurse and 'routing' in path_entry_dict:
          routing_dict_list.append((
            component_path,
            path_entry_dict['routing'],
          ))
889
        for method, action_dict in path_entry_dict['method'].items():
890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911
          for action in action_dict.get('descriptor', ()):
            descriptor_dict = {
              'title': action['title'],
            }
            action_url = (
                https_url
                if action.get('authenticated') else
                http_url
            ) + component_path
            if 'subpath' in action:
              action_url += '/' + action['subpath']
              descriptor_dict['templated'] = True
            descriptor_dict['href'] = action_url
            if method == 'GET':
              hal_section_id = '_links'
            else:
              descriptor_dict['method'] = method
              hal_section_id = '_actions'
            hal_section_dict = hal.setdefault(hal_section_id, {})
            name = action['name']
            assert name not in hal_section_dict, name
            hal_section_dict[name] = descriptor_dict
912
    return self._returnFile(
913
      json.dumps(hal).encode('utf-8'),
914 915
      'application/hal+json',
    )
916

917
  def getCORSForm(self, context, environ):
918 919 920
    """
    Handle GET /cors .
    """
921
    _ = context # Silence pylint
922 923 924 925
    if environ['wsgi.url_scheme'] != 'https':
      return (
        STATUS_FOUND,
        [
926
          ('Location', self._https_url),
927 928 929 930 931 932 933 934 935 936 937
        ],
        [],
      )
    try:
      query = parse_qs(environ['QUERY_STRING'], strict_parsing=True)
      origin, = query[CORS_FORM_ORIGIN_PARAMETER]
      return_to, = query[CORS_FORM_RETURN_PARAMETER]
    except (KeyError, ValueError):
      raise BadRequest
    return self._returnFile(
      CORS_FORM_TEMPLATE % {
938 939 940
        b'caucase': utils.toBytes(escape(self._http_url, quote=True)),
        b'return_to': utils.toBytes(escape(return_to, quote=True)),
        b'origin': utils.toBytes(escape(origin, quote=True)),
941 942 943 944 945 946 947 948 949 950 951
      },
      'text/html',
      [
        # Anti-clickjacking headers
        # Standard, apparently not widespread yet
        ('Content-Security-Policy', "frame-ancestors 'none'"),
        # BBB
        ('X-Frame-Options', 'DENY'),
      ],
    )

952
  def postCORSForm(self, context, environ):
953 954 955
    """
    Handle POST /cors .
    """
956
    _ = context # Silence pylint
957 958 959
    if environ['wsgi.url_scheme'] != 'https':
      raise NotFound
    if environ.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
960
      raise BadRequest(b'Unhandled Content-Type')
961
    try:
962 963 964 965
      form_dict = parse_qs(
        self._read(environ).decode('ascii'),
        strict_parsing=True,
      )
966 967 968 969
      origin, = form_dict['origin']
      return_to, = form_dict['return_to']
      grant, = form_dict['grant']
      grant = bool(int(grant))
970
    except (KeyError, ValueError, TypeError, UnicodeDecodeError):
971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996
      raise BadRequest
    try:
      origin_control_dict = json.loads(
        SimpleCookie(environ['HTTP_COOKIE'])[self._cors_cookie_id].value,
      )
    except (CookieError, KeyError, ValueError):
      origin_control_dict = {}
    origin_control_dict[origin] = self._cors_token_manager.sign({
      CORS_COOKIE_ACCESS_KEY: grant,
      CORS_COOKIE_ORIGIN_KEY: origin,
    })
    return (
      STATUS_FOUND,
      [
        ('Location', return_to),
        (
          'Set-Cookie',
          self._createCORSCookie(
            environ,
            json.dumps(origin_control_dict),
          ).OutputString(),
        ),
      ],
      [],
    )

997
  def getCertificateRevocationList(self, context, environ, subpath):
Vincent Pelletier's avatar
Vincent Pelletier committed
998
    """
999
    Handle GET /{context}/crl and GET /{context}/crl/{authority_key_id} .
Vincent Pelletier's avatar
Vincent Pelletier committed
1000
    """
1001
    _ = environ # Silence pylint
1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013
    crl_dict = context.getCertificateRevocationListDict()
    if subpath:
      try:
        authority_key_id, = subpath
        authority_key_id = int(authority_key_id, 10)
      except ValueError:
        raise NotFound
      try:
        crl = crl_dict[authority_key_id]
      except KeyError:
        raise NotFound
    else:
1014
      crl = b'\n'.join(crl_dict.values())
1015
    return self._returnFile(crl, 'application/pkix-crl')
1016

1017
  def getCSR(self, context, environ, subpath):
Vincent Pelletier's avatar
Vincent Pelletier committed
1018
    """
1019
    Handle GET /{context}/csr/{csr_id} and GET /{context}/csr.
Vincent Pelletier's avatar
Vincent Pelletier committed
1020
    """
1021
    if subpath:
1022 1023 1024 1025 1026 1027 1028
      return self._returnFile(
        context.getCertificateSigningRequest(self._getCSRID(subpath)),
        'application/pkcs10',
      )
    header_list = []
    self._authenticate(environ, header_list)
    return self._returnFile(
1029
      json.dumps(context.getCertificateRequestList()).encode('utf-8'),
1030 1031 1032
      'application/json',
      header_list,
    )
1033

1034
  def createCertificateSigningRequest(self, context, environ):
1035 1036 1037
    """
    Handle PUT /{context}/csr .
    """
1038 1039 1040
    try:
      csr_id = context.appendCertificateSigningRequest(self._read(environ))
    except exceptions.NotACertificateSigningRequest:
1041
      raise BadRequest(b'Not a valid certificate signing request')
1042
    return (STATUS_CREATED, [('Location', str(csr_id))], [])
1043

1044
  def deletePendingCertificateRequest(self, context, environ, subpath):
1045 1046 1047
    """
    Handle DELETE /{context}/csr/{csr_id} .
    """
1048 1049 1050
    # Note: single-use variable to verify subpath before allocating more
    # resources to this request
    csr_id = self._getCSRID(subpath)
1051 1052
    header_list = []
    self._authenticate(environ, header_list)
1053 1054 1055 1056
    try:
      context.deletePendingCertificateSigningRequest(csr_id)
    except exceptions.NotFound:
      raise NotFound
1057
    return (STATUS_NO_CONTENT, header_list, [])
1058

1059
  def getCACertificate(self, context, environ):
1060 1061 1062
    """
    Handle GET /{context}/crt/ca.crt.pem urls.
    """
1063
    _ = environ # Silence pylint
1064 1065 1066 1067 1068
    return self._returnFile(
      context.getCACertificate(),
      'application/x-x509-ca-cert',
    )

1069
  def getCACertificateChain(self, context, environ):
1070 1071 1072
    """
    Handle GET /{context}/crt/ca.crt.json urls.
    """
1073
    _ = environ # Silence pylint
1074
    return self._returnFile(
1075
      json.dumps(context.getValidCACertificateChain()).encode('utf-8'),
1076 1077 1078
      'application/json',
    )

1079
  def getCertificate(self, context, environ, subpath):
1080 1081 1082
    """
    Handle GET /{context}/crt/{crt_id} urls.
    """
1083
    _ = environ # Silence pylint
1084 1085 1086
    return self._returnFile(
      context.getCertificate(self._getCSRID(subpath)),
      'application/pkix-cert',
1087
    )
1088

1089
  def revokeCertificate(self, context, environ):
Vincent Pelletier's avatar
Vincent Pelletier committed
1090
    """
1091
    Handle PUT /{context}/crt/revoke .
Vincent Pelletier's avatar
Vincent Pelletier committed
1092
    """
1093 1094 1095 1096 1097 1098 1099 1100 1101
    header_list = []
    data = self._readJSON(environ)
    if data['digest'] is None:
      self._authenticate(environ, header_list)
      payload = utils.nullUnwrap(data)
      if 'revoke_crt_pem' not in payload:
        context.revokeSerial(payload['revoke_serial'])
        return (STATUS_NO_CONTENT, header_list, [])
    else:
1102
      payload = utils.unwrap(
1103 1104
        data,
        lambda x: x['revoke_crt_pem'],
1105 1106
        context.digest_list,
      )
1107
    context.revoke(
1108
      crt_pem=utils.toBytes(payload['revoke_crt_pem']),
1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122
    )
    return (STATUS_NO_CONTENT, header_list, [])

  def renewCertificate(self, context, environ):
    """
    Handle PUT /{context}/crt/renew .
    """
    payload = utils.unwrap(
      self._readJSON(environ),
      lambda x: x['crt_pem'],
      context.digest_list,
    )
    return self._returnFile(
      context.renew(
1123 1124
        crt_pem=utils.toBytes(payload['crt_pem']),
        csr_pem=utils.toBytes(payload['renew_csr_pem']),
1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140
      ),
      'application/pkix-cert',
    )

  def createCertificate(self, context, environ, subpath):
    """
    Handle PUT /{context}/crt/{crt_id} urls.
    """
    # Note: single-use variable to verify subpath before allocating more
    # resources to this request
    crt_id = self._getCSRID(subpath)
    body = self._read(environ)
    if not body:
      template_csr = None
    elif environ.get('CONTENT_TYPE') == 'application/pkcs10':
      template_csr = utils.load_certificate_request(body)
Vincent Pelletier's avatar
Vincent Pelletier committed
1141
    else:
1142
      raise BadRequest(b'Bad Content-Type')
1143 1144 1145 1146 1147 1148 1149
    header_list = []
    self._authenticate(environ, header_list)
    context.createCertificate(
      csr_id=crt_id,
      template_csr=template_csr,
    )
    return (STATUS_NO_CONTENT, header_list, [])