wsgi.py 35.4 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 509
    def getHALMethodDict(name, title):
      return {
        'GET': {
          'do': self.getHAL,
          'context_is_routing': True,
          'cors': CORS_POLICY_ALWAYS_ALLOW,
          'descriptor': [{
            'name': name,
            'title': title,
          }],
        },
      }
510
    self._root_dict = {
511 512 513 514 515 516 517 518
      'method': {
        'GET': {
          # XXX: Use full-recursion getHAL instead ?
          'do': self.getTopHAL,
          'context_is_routing': True,
          'cors': CORS_POLICY_ALWAYS_ALLOW,
        },
      },
519
      'routing': {
520 521 522 523 524 525 526 527 528 529 530
        'cors': {
          'method': {
            'GET': {
              'do': self.getCORSForm,
            },
            'POST': {
              'do': self.postCORSForm,
              'cors': CORS_POLICY_ALWAYS_DENY,
            },
          },
        },
531
        'cas': {
532
          'method': getHALMethodDict('getCASHAL', 'cas'),
533 534
          'context': cas,
          'routing': caucase_routing_dict,
535
        },
536
        'cau': {
537
          'method': getHALMethodDict('getCAUHAL', 'cau'),
538 539
          'context': cau,
          'routing': caucase_routing_dict,
540
        },
541 542
      },
    }
Vincent Pelletier's avatar
Vincent Pelletier committed
543 544 545 546 547

  def __call__(self, environ, start_response):
    """
    WSGI entry point
    """
548
    cors_header_list = []
549 550
    try: # Convert ApplicationError subclasses into error responses
      try: # Convert exceptions into ApplicationError subclass exceptions
551 552 553 554 555 556 557 558 559 560 561 562 563 564
        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]
565
        # If this raises, it means the routing dict is inconsistent.
566
        method_dict = path_entry_dict['method']
567
        request_method = environ['REQUEST_METHOD']
568 569 570 571 572 573 574
        try:
          action_dict = method_dict[request_method]
        except KeyError:
          if request_method == 'OPTIONS':
            status = STATUS_NO_CONTENT
            header_list = []
            result = []
575 576 577 578 579 580 581 582 583 584
            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)
585
          else:
586
            raise BadMethod(list(method_dict.keys()) + ['OPTIONS'])
587 588 589 590 591 592 593
        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
594 595 596 597 598 599
          self._checkCORSAccess(
            environ=environ,
            policy=action_dict.get('cors'),
            header_list=cors_header_list,
            preflight=False,
          )
600 601 602 603 604 605 606 607 608
          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
609 610 611 612 613 614 615 616
      except ApplicationError:
        raise
      except exceptions.NotFound:
        raise NotFound
      except exceptions.Found:
        raise Conflict
      except exceptions.NoStorage:
        raise InsufficientStorage
617
      except exceptions.NotJSON:
618
        raise BadRequest(b'Invalid json payload')
619
      except exceptions.CertificateAuthorityException as e:
Vincent Pelletier's avatar
Vincent Pelletier committed
620 621
        raise BadRequest(str(e))
      except Exception:
Vincent Pelletier's avatar
Vincent Pelletier committed
622 623 624 625 626
        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
627
        raise ApplicationError
628
    except ApplicationError as e:
629 630
      status = e.status
      header_list = e.response_headers
631
      result = [utils.toBytes(str(x)) for x in e.args]
632 633 634
    # 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)
635
    header_list.append(('Date', utils.timestamp2IMFfixdate(time.time())))
636
    start_response(status, header_list)
Vincent Pelletier's avatar
Vincent Pelletier committed
637 638
    return result

639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655
  @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:
656
      raise BadRequest(b'Invalid integer')
657

Vincent Pelletier's avatar
Vincent Pelletier committed
658 659 660 661 662 663 664 665 666
  @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.
    """
667 668 669 670 671 672
    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
673
    try:
674
      length = int(content_length, 10)
675
    except ValueError:
676
      raise BadRequest(b'Invalid Content-Length')
677
    if length > MAX_BODY_LENGTH:
678
      raise TooLarge(b'Content-Length limit exceeded')
679
    return environ['wsgi.input'].read(length)
Vincent Pelletier's avatar
Vincent Pelletier committed
680

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

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

  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':
710
      raise BadRequest(b'Bad Content-Type')
Vincent Pelletier's avatar
Vincent Pelletier committed
711 712
    data = self._read(environ)
    try:
713 714
      return json.loads(data.decode('utf-8'))
    except (ValueError, UnicodeDecodeError):
715
      raise BadRequest(b'Invalid json')
716

717 718 719 720 721 722 723 724 725 726 727 728
  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]
729
    cookie['path'] = environ.get('SCRIPT_NAME') or '/'
730
    cookie['max-age'] = A_YEAR_IN_SECONDS
731 732 733 734 735 736 737 738 739 740 741 742 743 744 745
    # 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
746
          for x, y in method_dict.items()
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 777
          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.
    """
778
    my_origin = application_uri(environ).split('/', 1)[0]
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 827
    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(
828
          self._https_url + '/cors?' +
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 882
          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()
883
      for component, path_entry_dict in routing_dict.items():
884 885 886 887 888 889
        component_path = routing_path + '/' + component
        if recurse and 'routing' in path_entry_dict:
          routing_dict_list.append((
            component_path,
            path_entry_dict['routing'],
          ))
890
        for method, action_dict in path_entry_dict['method'].items():
891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912
          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
913
    return self._returnFile(
914
      json.dumps(hal).encode('utf-8'),
915 916
      'application/hal+json',
    )
917

918
  def getCORSForm(self, context, environ):
919 920 921
    """
    Handle GET /cors .
    """
922
    _ = context # Silence pylint
923 924 925 926
    if environ['wsgi.url_scheme'] != 'https':
      return (
        STATUS_FOUND,
        [
927
          ('Location', self._https_url),
928 929 930 931 932 933 934 935 936 937 938
        ],
        [],
      )
    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 % {
939 940 941
        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)),
942 943 944 945 946 947 948 949 950 951 952
      },
      'text/html',
      [
        # Anti-clickjacking headers
        # Standard, apparently not widespread yet
        ('Content-Security-Policy', "frame-ancestors 'none'"),
        # BBB
        ('X-Frame-Options', 'DENY'),
      ],
    )

953
  def postCORSForm(self, context, environ):
954 955 956
    """
    Handle POST /cors .
    """
957
    _ = context # Silence pylint
958 959 960
    if environ['wsgi.url_scheme'] != 'https':
      raise NotFound
    if environ.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
961
      raise BadRequest(b'Unhandled Content-Type')
962
    try:
963 964 965 966
      form_dict = parse_qs(
        self._read(environ).decode('ascii'),
        strict_parsing=True,
      )
967 968 969 970
      origin, = form_dict['origin']
      return_to, = form_dict['return_to']
      grant, = form_dict['grant']
      grant = bool(int(grant))
971
    except (KeyError, ValueError, TypeError, UnicodeDecodeError):
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 997
      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(),
        ),
      ],
      [],
    )

998
  def getCertificateRevocationList(self, context, environ, subpath):
Vincent Pelletier's avatar
Vincent Pelletier committed
999
    """
1000
    Handle GET /{context}/crl and GET /{context}/crl/{authority_key_id} .
Vincent Pelletier's avatar
Vincent Pelletier committed
1001
    """
1002
    _ = environ # Silence pylint
1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014
    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:
1015
      crl = b'\n'.join(crl_dict.values())
1016
    return self._returnFile(crl, 'application/pkix-crl')
1017

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

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

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

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

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

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

1090
  def revokeCertificate(self, context, environ):
Vincent Pelletier's avatar
Vincent Pelletier committed
1091
    """
1092
    Handle PUT /{context}/crt/revoke .
Vincent Pelletier's avatar
Vincent Pelletier committed
1093
    """
1094 1095 1096 1097 1098 1099 1100 1101 1102
    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:
1103
      payload = utils.unwrap(
1104 1105
        data,
        lambda x: x['revoke_crt_pem'],
1106 1107
        context.digest_list,
      )
1108
    context.revoke(
1109
      crt_pem=utils.toBytes(payload['revoke_crt_pem']),
1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123
    )
    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(
1124 1125
        crt_pem=utils.toBytes(payload['crt_pem']),
        csr_pem=utils.toBytes(payload['renew_csr_pem']),
1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141
      ),
      '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
1142
    else:
1143
      raise BadRequest(b'Bad Content-Type')
1144 1145 1146 1147 1148 1149 1150
    header_list = []
    self._authenticate(environ, header_list)
    context.createCertificate(
      csr_id=crt_id,
      template_csr=template_csr,
    )
    return (STATUS_NO_CONTENT, header_list, [])