Commit 719959e0 authored by Vincent Pelletier's avatar Vincent Pelletier

wsgi: Become web-friendly

Self-describe site structure in application/hal+json format.
Add Cross-Origin Resource Sharing support: pre-flight request support,
same-origin-only origin access control minimal html page. Access control
decision is stored client-side in a signed & time-limited cookie
supporting multiple concurrent origins. Origins may be pre-allowed (ex:
when caucase GUI is served from a trusted server).
parent 7ff81404
...@@ -196,6 +196,26 @@ HTTPS. ...@@ -196,6 +196,26 @@ HTTPS.
It handles its own certificate issuance and renewal, so there is no need to use It handles its own certificate issuance and renewal, so there is no need to use
`caucase-updater`_ for this service. `caucase-updater`_ for this service.
CORS
----
caucased implements CORS protection: when receiving a cross-origin request,
it will respond with 401 Unauthorized, with the WWW-Authenticate header set to
a custom scheme ("cors") with an "url" parameter containing an URI template
with one variable field: "return" (more on it later).
Upon receiving this response, the application is expected to render the URL
template and redirect the user to resulting URL. There, the user will be
informed of the cross-origin access attempt, and offered the choice to grant or
deny access to given origin.
Once their decision is made, their browser will receive a cookie remembering
this decision, and they will be redirected to the URL received in the "return"
field received upon above-described redirection.
Then, the application should retry the original request, which will be
accompanied by that cookie.
Backups Backups
------- -------
......
...@@ -25,6 +25,7 @@ import datetime ...@@ -25,6 +25,7 @@ import datetime
from getpass import getpass from getpass import getpass
import glob import glob
import itertools import itertools
import json
import os import os
import socket import socket
from SocketServer import ThreadingMixIn from SocketServer import ThreadingMixIn
...@@ -42,7 +43,7 @@ import pem ...@@ -42,7 +43,7 @@ import pem
from . import exceptions from . import exceptions
from . import utils from . import utils
from . import version from . import version
from .wsgi import Application from .wsgi import Application, CORSTokenManager
from .ca import CertificateAuthority, UserCertificateAuthority, Extension from .ca import CertificateAuthority, UserCertificateAuthority, Extension
from .storage import SQLite3Storage from .storage import SQLite3Storage
from .http_wsgirequesthandler import WSGIRequestHandler from .http_wsgirequesthandler import WSGIRequestHandler
...@@ -356,6 +357,19 @@ def main(argv=None, until=utils.until): ...@@ -356,6 +357,19 @@ def main(argv=None, until=utils.until):
help='Path to the ssl key pair to use for https socket. ' help='Path to the ssl key pair to use for https socket. '
'default: %(default)s', 'default: %(default)s',
) )
parser.add_argument(
'--cors-key-store',
default='cors.key',
metavar='PATH',
help='Path to a file containing CORS token keys. default: %(default)s',
)
parser.add_argument(
'--cors-whitelist',
default=[],
nargs='+',
metavar='URL',
help='Origin values to always trust. default: none'
)
parser.add_argument( parser.add_argument(
'--netloc', '--netloc',
required=True, required=True,
...@@ -600,7 +614,34 @@ def main(argv=None, until=utils.until): ...@@ -600,7 +614,34 @@ def main(argv=None, until=utils.until):
ca_life_period=40, # approx. 10 years ca_life_period=40, # approx. 10 years
crt_life_time=args.service_crt_validity, crt_life_time=args.service_crt_validity,
) )
application = Application(cau=cau, cas=cas) if os.path.exists(args.cors_key_store):
with open(args.cors_key_store) as cors_key_file:
cors_secret_list = json.load(cors_key_file)
else:
cors_secret_list = []
def saveCORSKeyList(cors_secret_list):
"""
Update CORS key store when a new key was generated.
"""
with _createKey(args.cors_key_store) as cors_key_file:
json.dump(cors_secret_list, cors_key_file)
application = Application(
cau=cau,
cas=cas,
http_url=urlunsplit((
'http',
'[' + hostname + ']:' + str(http_port),
'/',
None,
None,
)),
https_url=https_base_url,
cors_token_manager=CORSTokenManager(
secret_list=cors_secret_list,
onNewKey=saveCORSKeyList,
),
cors_whitelist=args.cors_whitelist,
)
http_list = [] http_list = []
https_list = [] https_list = []
known_host_set = set() known_host_set = set()
......
...@@ -21,12 +21,15 @@ Caucase - Certificate Authority for Users, Certificate Authority for SErvices ...@@ -21,12 +21,15 @@ Caucase - Certificate Authority for Users, Certificate Authority for SErvices
Test suite Test suite
""" """
from __future__ import absolute_import from __future__ import absolute_import
from Cookie import SimpleCookie
from cStringIO import StringIO from cStringIO import StringIO
import datetime import datetime
import errno import errno
import glob import glob
import HTMLParser
import httplib import httplib
import ipaddress import ipaddress
import json
import os import os
import random import random
import shutil import shutil
...@@ -39,6 +42,7 @@ import threading ...@@ -39,6 +42,7 @@ import threading
import time import time
import traceback import traceback
import unittest import unittest
from urllib import quote, urlencode
import urlparse import urlparse
from cryptography import x509 from cryptography import x509
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
...@@ -53,6 +57,48 @@ from caucase.storage import SQLite3Storage ...@@ -53,6 +57,48 @@ from caucase.storage import SQLite3Storage
_cryptography_backend = default_backend() _cryptography_backend = default_backend()
NOT_CAUCASE_OID = '2.25.285541874270823339875695650038637483518' NOT_CAUCASE_OID = '2.25.285541874270823339875695650038637483518'
A_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 # Roughly a year
class assertHTMLNoScriptAlert(HTMLParser.HTMLParser):
"""
Raise AssertionError if it finds a <script> tag containing "alert".
"""
_in_script = False
def __init__(self, data):
HTMLParser.HTMLParser.__init__(self)
self.feed(data)
self.close()
def reset(self):
"""
Out of script tag.
"""
HTMLParser.HTMLParser.reset(self)
self._in_script = False
def handle_starttag(self, tag, attrs):
"""
Update whether a script tag was entered.
"""
assert not self._in_script
self._in_script = tag == 'script'
def handle_endtag(self, tag): # pragma: no cover
"""
Track leaving script tag.
"""
if tag == 'script':
assert self._in_script
self._in_script = False
def handle_data(self, data):
"""
Check script tag content.
"""
assert not self._in_script or 'alert' not in data, (
'<script>...alert...</script> found'
)
def canConnect(address): # pragma: no cover def canConnect(address): # pragma: no cover
""" """
...@@ -241,6 +287,7 @@ class CaucaseTest(unittest.TestCase): ...@@ -241,6 +287,7 @@ class CaucaseTest(unittest.TestCase):
self._server_db = os.path.join(server_dir, 'caucase.sqlite') self._server_db = os.path.join(server_dir, 'caucase.sqlite')
self._server_key = os.path.join(server_dir, 'server.key.pem') self._server_key = os.path.join(server_dir, 'server.key.pem')
self._server_backup_path = os.path.join(server_dir, 'backup') self._server_backup_path = os.path.join(server_dir, 'backup')
self._server_cors_store = os.path.join(server_dir, 'cors.key')
# pylint: enable=bad-whitespace # pylint: enable=bad-whitespace
os.mkdir(self._server_backup_path) os.mkdir(self._server_backup_path)
...@@ -307,6 +354,7 @@ class CaucaseTest(unittest.TestCase): ...@@ -307,6 +354,7 @@ class CaucaseTest(unittest.TestCase):
'--netloc', self._server_netloc, '--netloc', self._server_netloc,
#'--threshold', '31', #'--threshold', '31',
#'--key-len', '2048', #'--key-len', '2048',
'--cors-key-store', self._server_cors_store,
) + argv, ) + argv,
'until': until, 'until': until,
} }
...@@ -1285,6 +1333,24 @@ class CaucaseTest(unittest.TestCase): ...@@ -1285,6 +1333,24 @@ class CaucaseTest(unittest.TestCase):
'--renew-crt', user_key_path, '', '--renew-crt', user_key_path, '',
) )
def testCORSTokenManager(self):
"""
Test key expiration and onNewKey invocation.
"""
new_key_list = []
old_key_value = '\x00' * 32
wsgi.CORSTokenManager(
secret_list=(
# A secret which is still valid, but old enough to not be used.
(time.time() + A_YEAR_IN_SECONDS // 2 - 86400, old_key_value),
),
onNewKey=new_key_list.append,
).sign('')
# pylint: disable=unbalanced-tuple-unpacking
((_, old_key), (_, _)), = new_key_list
# pylint: enable=unbalanced-tuple-unpacking
self.assertEqual(old_key, old_key_value)
def testWSGI(self): def testWSGI(self):
""" """
Test wsgi class reaction to malformed requests. Test wsgi class reaction to malformed requests.
...@@ -1311,6 +1377,13 @@ class CaucaseTest(unittest.TestCase): ...@@ -1311,6 +1377,13 @@ class CaucaseTest(unittest.TestCase):
""" """
return cau_list return cau_list
@staticmethod
def getCACertificate():
"""
Return a dummy string as CA certificate
"""
return 'notreallyPEM'
@staticmethod @staticmethod
def getCertificateRevocationList(): def getCertificateRevocationList():
""" """
...@@ -1336,7 +1409,13 @@ class CaucaseTest(unittest.TestCase): ...@@ -1336,7 +1409,13 @@ class CaucaseTest(unittest.TestCase):
getCertificateSigningRequest = _placeholder getCertificateSigningRequest = _placeholder
getCertificate = _placeholder getCertificate = _placeholder
application = wsgi.Application(DummyCAU(), None) application = wsgi.Application(
DummyCAU(),
None,
'http://caucase.example.com:8000',
'https://caucase.example.com:8001',
wsgi.CORSTokenManager(),
)
def request(environ): def request(environ):
""" """
Non-standard shorthand for invoking the WSGI application. Non-standard shorthand for invoking the WSGI application.
...@@ -1365,14 +1444,110 @@ class CaucaseTest(unittest.TestCase): ...@@ -1365,14 +1444,110 @@ class CaucaseTest(unittest.TestCase):
return int(status), reason, header_dict, ''.join(body) return int(status), reason, header_dict, ''.join(body)
UNAUTHORISED_STATUS = 401 UNAUTHORISED_STATUS = 401
self.assertEqual(request({ HATEOAS_HTTP_PREFIX = u"http://caucase.example.com:8000/base/path"
'PATH_INFO': '/', HATEOAS_HTTPS_PREFIX = u"https://caucase.example.com:8001/base/path"
})[0], 404) root_hateoas_request = request({
'SCRIPT_NAME': '/base/path',
'REQUEST_METHOD': 'GET',
'wsgi.url_scheme': 'http',
'SERVER_NAME': 'caucase.example.com',
'SERVER_PORT': '8000',
})
self.maxDiff = None
self.assertEqual(root_hateoas_request[0], 200)
self.assertEqual(root_hateoas_request[2]['Content-Type'], 'application/hal+json')
self.assertEqual(json.loads(root_hateoas_request[3]), {
u"_links": {
u"getCAUHAL": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau",
u"title": u"cau",
},
u"self": {
u"href": HATEOAS_HTTP_PREFIX,
},
u"getCASHAL": {
u"href": HATEOAS_HTTP_PREFIX + u"/cas",
u"title": u"cas",
},
},
})
cau_hateoas_request = request({
'SCRIPT_NAME': '/base/path',
'PATH_INFO': '/cau/',
'REQUEST_METHOD': 'GET',
'wsgi.url_scheme': 'http',
'SERVER_NAME': 'caucase.example.com',
'SERVER_PORT': '8000',
})
self.assertEqual(cau_hateoas_request[0], 200)
self.assertEqual(cau_hateoas_request[2]['Content-Type'], 'application/hal+json')
self.assertEqual(json.loads(cau_hateoas_request[3]), {
u"_actions": {
u"createCertificate": {
u"href": HATEOAS_HTTPS_PREFIX + u"/cau/crt/{+crt_id}",
u"method": u"PUT",
u"templated": True,
u"title": u"Accept pending certificate signing request",
},
u"renewCertificate": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/crt/renew",
u"method": u"PUT",
u"title": u"Renew a certificate",
},
u"revokeCertificate": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/crt/revoke",
u"method": u"PUT",
u"title": u"Revoke a certificate",
},
u"createCertificateSigningRequest": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/csr",
u"method": u"PUT",
u"title": u"Request a new certificate signature.",
},
u"deletePendingCertificateRequest": {
u"href": HATEOAS_HTTPS_PREFIX + u"/cau/csr/{+csr_id}",
u"method": u"DELETE",
u"templated": True,
u"title": u"Reject a pending certificate signing request.",
},
},
u"_links": {
u"getCertificateRevocationList": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/crl",
u"title": u"Retrieve latest certificate revocation list.",
},
u"getCACertificate": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/crt/ca.crt.pem",
u"title": u"Retrieve current CA certificate.",
},
u"self": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau",
},
u"getCertificate": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/crt/{+csr_id}",
u"templated": True,
u"title": u"Retrieve a signed certificate.",
},
u"getPendingCertificateRequestList": {
u"href": HATEOAS_HTTPS_PREFIX + u"/cau/csr",
u"title": u"List pending certificate signing requests.",
},
u"getCACertificateChain": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/crt/ca.crt.json",
u"title": u"Retrieve current CA certificate trust chain.",
},
u"getCertificateSigningRequest": {
u"href": HATEOAS_HTTP_PREFIX + u"/cau/csr/{+csr_id}",
u"templated": True,
u"title": u"Retrieve a pending certificate signing request.",
},
u"home": {
u"href": HATEOAS_HTTP_PREFIX,
},
},
})
self.assertEqual(request({ self.assertEqual(request({
'PATH_INFO': '/foo/bar', 'PATH_INFO': '/foo/bar',
})[0], 404)
self.assertEqual(request({
'PATH_INFO': '/cau',
'REQUEST_METHOD': 'GET', 'REQUEST_METHOD': 'GET',
})[0], 404) })[0], 404)
self.assertEqual(request({ self.assertEqual(request({
...@@ -1503,6 +1678,295 @@ class CaucaseTest(unittest.TestCase): ...@@ -1503,6 +1678,295 @@ class CaucaseTest(unittest.TestCase):
'REQUEST_METHOD': 'POST', 'REQUEST_METHOD': 'POST',
})[0], 405) })[0], 405)
# CORS
cross_origin = 'https://example.com:443'
# Non-CORS OPTIONS: success without CORS headers
status, _, header_dict, _ = request({
'PATH_INFO': '/',
'REQUEST_METHOD': 'OPTIONS',
})
self.assertEqual(status, 204)
self.assertNotIn(
'Access-Control-Allow-Credentials',
header_dict,
)
self.assertNotIn(
'Access-Control-Allow-Origin',
header_dict,
)
self.assertNotIn(
'Vary',
header_dict,
)
self.assertNotIn(
'Access-Control-Allow-Methods',
header_dict,
)
self.assertNotIn(
'Access-Control-Allow-Headers',
header_dict,
)
# All origins can access /
status, _, header_dict, _ = request({
'PATH_INFO': '/',
'REQUEST_METHOD': 'OPTIONS',
'HTTP_ORIGIN': cross_origin,
})
self.assertEqual(status, 204)
self.assertEqual(
header_dict['Access-Control-Allow-Credentials'],
'true',
)
self.assertEqual(
header_dict['Access-Control-Allow-Origin'],
cross_origin,
)
self.assertEqual(
header_dict['Vary'],
'Origin',
)
self.assertItemsEqual(
[
x.strip()
for x in header_dict['Access-Control-Allow-Methods'].split(',')
],
['GET', 'OPTIONS'],
)
self.assertItemsEqual(
[
x.strip()
for x in header_dict['Access-Control-Allow-Headers'].split(',')
],
[
'Content-Type',
'User-Agent',
],
)
# User confirmation is needed to GET /cau/crt/ca.crt.pem
# ... OPTIONS succeeds and lists allowed methods
status, _, header_dict, _ = request({
'PATH_INFO': '/cau/crt/ca.crt.pem',
'REQUEST_METHOD': 'OPTIONS',
'HTTP_ORIGIN': cross_origin,
})
self.assertEqual(status, 204)
self.assertItemsEqual(
['GET', 'OPTIONS'],
[
x.strip()
for x in header_dict['Access-Control-Allow-Methods'].split(',')
],
)
# ... calling PUT tells Unauthorised with proper header
status, _, header_dict, _ = request({
'PATH_INFO': '/cau/crt/ca.crt.pem',
'REQUEST_METHOD': 'GET',
'HTTP_ORIGIN': cross_origin,
})
self.assertEqual(status, UNAUTHORISED_STATUS)
self.assertEqual(
header_dict['WWW-Authenticate'],
'cors url=' + quote(
'https://localhost:8000/cors?' +
urlencode([('origin', cross_origin)]) +
'{&return}',
),
)
# ... it also fails cleanly when called with an invalid cookie
self.assertEqual(
request({
'PATH_INFO': '/cau/crt/ca.crt.pem',
'REQUEST_METHOD': 'GET',
'HTTP_ORIGIN': cross_origin,
'HTTP_COOKIE': 'cors=a',
})[0],
UNAUTHORISED_STATUS,
)
return_url = 'http://example.com/foo?d=<script>alert("boo !")</script>'
# POST to /cors with origin is forbidden
self.assertEqual(
request({
'wsgi.url_scheme': 'https',
'PATH_INFO': '/cors',
'QUERY_STRING': urlencode((
('origin', cross_origin),
('return', return_url),
)),
'REQUEST_METHOD': 'POST',
'HTTP_ORIGIN': cross_origin,
})[0],
403,
)
# GET /cors on http redirects to https
status, _, header_dict, _ = request({
'wsgi.url_scheme': 'http',
'PATH_INFO': '/cors',
'QUERY_STRING': urlencode((
('origin', cross_origin),
('return', return_url),
)),
'REQUEST_METHOD': 'GET',
})
self.assertEqual(status, 302)
self.assertTrue(
header_dict['Location'].startswith('https://'),
header_dict['Location'],
)
# GET /cors with missing arguments (here, "return") fails visibly
self.assertEqual(
request({
'wsgi.url_scheme': 'https',
'PATH_INFO': '/cors',
'QUERY_STRING': urlencode((
('origin', cross_origin),
)),
'REQUEST_METHOD': 'GET',
})[0],
400,
)
# GET /cors return an html page with anti-clickjacking headers
status, _, header_dict, body = request({
'wsgi.url_scheme': 'https',
'PATH_INFO': '/cors',
'QUERY_STRING': urlencode((
('origin', cross_origin),
('return', return_url),
)),
'REQUEST_METHOD': 'GET',
})
self.assertEqual(status, 200)
self.assertEqual(header_dict['Content-Type'], 'text/html')
self.assertEqual(header_dict['X-Frame-Options'], 'DENY')
self.assertEqual(
header_dict['Content-Security-Policy'],
"frame-ancestors 'none'",
)
assertHTMLNoScriptAlert(body)
# POST /cors sets cookie
def getCORSPostEnvironment(kw=(), input_dict=(
('return_to', return_url),
('origin', cross_origin),
('grant', '1'),
)):
"""
Build "request"'s "environ" argument for CORS requests.
"""
base_request_reader_dict = {
'wsgi.url_scheme': 'https',
'PATH_INFO': '/cors',
'REQUEST_METHOD': 'POST',
'CONTENT_TYPE': 'application/x-www-form-urlencoded',
'wsgi.input': StringIO(urlencode(input_dict)),
}
base_request_reader_dict.update(kw)
return base_request_reader_dict
def getCORSCookie(value):
"""
Build CORS cookie with custome value
"""
return SimpleCookie({'cors': json.dumps(value)})['cors'].OutputString()
self.assertEqual(
request(getCORSPostEnvironment(kw={'wsgi.url_scheme': 'http'}))[0],
404,
)
self.assertEqual(
request(getCORSPostEnvironment(kw={'CONTENT_TYPE': 'text/plain'}))[0],
400,
)
self.assertEqual(
request(getCORSPostEnvironment(
input_dict={
'return_to': return_url,
'origin': cross_origin,
# Missing "grant"
},
))[0],
400,
)
self.assertEqual(
request(getCORSPostEnvironment(
input_dict={
'return_to': return_url,
'origin': cross_origin,
'grant': 'a', # Non-integer "grant"
},
))[0],
400,
)
status, _, header_dict, _ = request(getCORSPostEnvironment())
self.assertEqual(status, 302)
self.assertEqual(header_dict['Location'], return_url)
good_token = json.loads(
SimpleCookie(header_dict['Set-Cookie'])['cors'].value,
)[cross_origin]
# Recycling a valid token for another domain does not grant access
status, _, header_dict, _ = request(getCORSPostEnvironment(
input_dict={
'return_to': 'http://example.org',
'origin': 'http://example.org:80',
'grant': '1',
}
))
self.assertEqual(status, 302)
bad_token = json.loads(
SimpleCookie(header_dict['Set-Cookie'])['cors'].value,
)['http://example.org:80']
status, _, header_dict, _ = request({
'PATH_INFO': '/cau/crt/ca.crt.pem',
'REQUEST_METHOD': 'GET',
'HTTP_ORIGIN': cross_origin,
'HTTP_COOKIE': getCORSCookie({
cross_origin: bad_token,
}),
})
self.assertEqual(status, UNAUTHORISED_STATUS)
self.assertEqual(
SimpleCookie(header_dict['Set-Cookie'])['cors'].value,
'{}',
)
# Accessing with invalid token fails
head, body, _ = good_token.split('.')
_, _, signature = bad_token.split('.')
status, _, header_dict, _ = request({
'PATH_INFO': '/cau/crt/ca.crt.pem',
'REQUEST_METHOD': 'GET',
'HTTP_ORIGIN': cross_origin,
'HTTP_COOKIE': getCORSCookie({
cross_origin: '%s.%s.%s' % (
head,
body,
signature,
),
}),
})
self.assertEqual(status, UNAUTHORISED_STATUS)
self.assertEqual(
SimpleCookie(header_dict['Set-Cookie'])['cors'].value,
'{}',
)
# Accessing with valid token works
status, _, header_dict, _ = request({
'PATH_INFO': '/cau/crt/ca.crt.pem',
'REQUEST_METHOD': 'GET',
'HTTP_ORIGIN': cross_origin,
'HTTP_COOKIE': getCORSCookie({
cross_origin: good_token,
}),
})
self.assertEqual(status, 200)
self.assertItemsEqual(
[
x.strip()
for x in header_dict[
'Access-Control-Expose-Headers'
].split(',')
],
[
'Location',
'WWW-Authenticate',
],
)
def testProbe(self): def testProbe(self):
""" """
Exercise caucase-probe command. Exercise caucase-probe command.
......
...@@ -19,18 +19,62 @@ ...@@ -19,18 +19,62 @@
Caucase - Certificate Authority for Users, Certificate Authority for SErvices Caucase - Certificate Authority for Users, Certificate Authority for SErvices
""" """
from __future__ import absolute_import from __future__ import absolute_import
from cgi import escape
from Cookie import SimpleCookie, CookieError
import httplib import httplib
import json import json
import os
import threading
import time
import traceback import traceback
from urllib import quote, urlencode
from urlparse import parse_qs, urlparse, urlunparse
from wsgiref.util import application_uri, request_uri
import jwt
from . import utils from . import utils
from . import exceptions from . import exceptions
__all__ = ('Application', ) __all__ = ('Application', 'CORSTokenManager')
# TODO: l10n
CORS_FORM_TEMPLATE = '''\
<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
SUBPATH_FORBIDDEN = object() SUBPATH_FORBIDDEN = object()
SUBPATH_REQUIRED = object() SUBPATH_REQUIRED = object()
SUBPATH_OPTIONAL = object() SUBPATH_OPTIONAL = object()
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
toHTTPS = lambda url: urlunparse(('https', ) + urlparse(url)[1:])
def _getStatus(code): def _getStatus(code):
return '%i %s' % (code, httplib.responses[code]) return '%i %s' % (code, httplib.responses[code])
...@@ -70,6 +114,23 @@ class SSLUnauthorized(Unauthorized): ...@@ -70,6 +114,23 @@ class SSLUnauthorized(Unauthorized):
('WWW-Authenticate', 'transport'), ('WWW-Authenticate', 'transport'),
] ]
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
"""
status = _getStatus(httplib.FORBIDDEN)
class NotFound(ApplicationError): class NotFound(ApplicationError):
""" """
HTTP not found error HTTP not found error
...@@ -105,15 +166,95 @@ class InsufficientStorage(ApplicationError): ...@@ -105,15 +166,95 @@ class InsufficientStorage(ApplicationError):
STATUS_OK = _getStatus(httplib.OK) STATUS_OK = _getStatus(httplib.OK)
STATUS_CREATED = _getStatus(httplib.CREATED) STATUS_CREATED = _getStatus(httplib.CREATED)
STATUS_NO_CONTENT = _getStatus(httplib.NO_CONTENT) STATUS_NO_CONTENT = _getStatus(httplib.NO_CONTENT)
STATUS_FOUND = _getStatus(httplib.FOUND)
MAX_BODY_LENGTH = 10 * 1024 * 1024 # 10 MB MAX_BODY_LENGTH = 10 * 1024 * 1024 # 10 MB
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)
return jwt.encode(
payload={'p': payload},
key=key,
algorithm='HS256',
)
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
class Application(object): class Application(object):
""" """
WSGI application class WSGI application class
Thread- and process-safe (locks handled by sqlite3). Thread- and process-safe (locks handled by sqlite3).
""" """
def __init__(self, cau, cas): def __init__(
self,
cau,
cas,
http_url,
https_url,
cors_token_manager,
cors_cookie_id='cors',
cors_whitelist=(),
):
""" """
cau (caucase.ca.CertificateAuthority) cau (caucase.ca.CertificateAuthority)
CA for users. CA for users.
...@@ -122,8 +263,32 @@ class Application(object): ...@@ -122,8 +263,32 @@ class Application(object):
cas (caucase.ca.CertificateAuthority) cas (caucase.ca.CertificateAuthority)
CA for services. CA for services.
Will be hosted under /cas Will be hosted under /cas
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.
""" """
self._cau = cau self._cau = cau
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
# Routing dict structure: # Routing dict structure:
# path entry dict: # path entry dict:
...@@ -152,14 +317,30 @@ class Application(object): ...@@ -152,14 +317,30 @@ class Application(object):
# - status: HTTP status code & reason # - status: HTTP status code & reason
# - header_list: HTTP reponse header list (see wsgi specs) # - header_list: HTTP reponse header list (see wsgi specs)
# - iterator: HTTP response body generator (see wsgi specs) # - iterator: HTTP response body generator (see wsgi specs)
# "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)
# "subpath": whether a subpath is expected, forbidden, or optional # "subpath": whether a subpath is expected, forbidden, or optional
# (default: forbidden) # (default: forbidden)
# 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)
caucase_routing_dict = { caucase_routing_dict = {
'crl': { 'crl': {
'method': { 'method': {
'GET': { 'GET': {
'do': self.getCertificateRevocationList, 'do': self.getCertificateRevocationList,
'descriptor': [{
'name': 'getCertificateRevocationList',
'title': 'Retrieve latest certificate revocation list.',
}],
}, },
}, },
}, },
...@@ -168,13 +349,32 @@ class Application(object): ...@@ -168,13 +349,32 @@ class Application(object):
'GET': { 'GET': {
'do': self.getCSR, 'do': self.getCSR,
'subpath': SUBPATH_OPTIONAL, 'subpath': SUBPATH_OPTIONAL,
'descriptor': [{
'name': 'getPendingCertificateRequestList',
'title': 'List pending certificate signing requests.',
'authenticated': True
}, {
'name': 'getCertificateSigningRequest',
'title': 'Retrieve a pending certificate signing request.',
'subpath': '{+csr_id}',
}],
}, },
'PUT': { 'PUT': {
'do': self.createCertificateSigningRequest, 'do': self.createCertificateSigningRequest,
'descriptor': [{
'name': 'createCertificateSigningRequest',
'title': 'Request a new certificate signature.',
}],
}, },
'DELETE': { 'DELETE': {
'do': self.deletePendingCertificateRequest, 'do': self.deletePendingCertificateRequest,
'subpath': SUBPATH_REQUIRED, 'subpath': SUBPATH_REQUIRED,
'descriptor': [{
'name': 'deletePendingCertificateRequest',
'title': 'Reject a pending certificate signing request.',
'subpath': '{+csr_id}',
'authenticated': True,
}],
}, },
}, },
}, },
...@@ -184,6 +384,10 @@ class Application(object): ...@@ -184,6 +384,10 @@ class Application(object):
'method': { 'method': {
'GET': { 'GET': {
'do': self.getCACertificate, 'do': self.getCACertificate,
'descriptor': [{
'name': 'getCACertificate',
'title': 'Retrieve current CA certificate.',
}],
}, },
}, },
}, },
...@@ -191,6 +395,10 @@ class Application(object): ...@@ -191,6 +395,10 @@ class Application(object):
'method': { 'method': {
'GET': { 'GET': {
'do': self.getCACertificateChain, 'do': self.getCACertificateChain,
'descriptor': [{
'name': 'getCACertificateChain',
'title': 'Retrieve current CA certificate trust chain.',
}],
}, },
}, },
}, },
...@@ -198,6 +406,10 @@ class Application(object): ...@@ -198,6 +406,10 @@ class Application(object):
'method': { 'method': {
'PUT': { 'PUT': {
'do': self.revokeCertificate, 'do': self.revokeCertificate,
'descriptor': [{
'name': 'revokeCertificate',
'title': 'Revoke a certificate',
}],
}, },
}, },
}, },
...@@ -205,6 +417,10 @@ class Application(object): ...@@ -205,6 +417,10 @@ class Application(object):
'method': { 'method': {
'PUT': { 'PUT': {
'do': self.renewCertificate, 'do': self.renewCertificate,
'descriptor': [{
'name': 'renewCertificate',
'title': 'Renew a certificate',
}],
}, },
}, },
}, },
...@@ -213,21 +429,66 @@ class Application(object): ...@@ -213,21 +429,66 @@ class Application(object):
'GET': { 'GET': {
'do': self.getCertificate, 'do': self.getCertificate,
'subpath': SUBPATH_REQUIRED, 'subpath': SUBPATH_REQUIRED,
'descriptor': [{
'name': 'getCertificate',
'subpath': '{+csr_id}',
'templated': True,
'title': 'Retrieve a signed certificate.',
}],
}, },
'PUT': { 'PUT': {
'do': self.createCertificate, 'do': self.createCertificate,
'subpath': SUBPATH_REQUIRED, 'subpath': SUBPATH_REQUIRED,
'descriptor': [{
'name': 'createCertificate',
'subpath': '{+crt_id}',
'title': 'Accept pending certificate signing request',
'templated': True,
'authenticated': True,
}],
}, },
}, },
}, },
} }
getHALMethodDict = lambda name, title: {
'GET': {
'do': self.getHAL,
'context_is_routing': True,
'cors': CORS_POLICY_ALWAYS_ALLOW,
'descriptor': [{
'name': name,
'title': title,
}],
},
}
self._root_dict = { self._root_dict = {
'method': {
'GET': {
# XXX: Use full-recursion getHAL instead ?
'do': self.getTopHAL,
'context_is_routing': True,
'cors': CORS_POLICY_ALWAYS_ALLOW,
},
},
'routing': { 'routing': {
'cors': {
'method': {
'GET': {
'do': self.getCORSForm,
},
'POST': {
'do': self.postCORSForm,
'cors': CORS_POLICY_ALWAYS_DENY,
},
},
},
'cas': { 'cas': {
'method': getHALMethodDict('getCASHAL', 'cas'),
'context': cas, 'context': cas,
'routing': caucase_routing_dict, 'routing': caucase_routing_dict,
}, },
'cau': { 'cau': {
'method': getHALMethodDict('getCAUHAL', 'cau'),
'context': cau, 'context': cau,
'routing': caucase_routing_dict, 'routing': caucase_routing_dict,
}, },
...@@ -238,6 +499,7 @@ class Application(object): ...@@ -238,6 +499,7 @@ class Application(object):
""" """
WSGI entry point WSGI entry point
""" """
cors_header_list = []
try: # Convert ApplicationError subclasses into error responses try: # Convert ApplicationError subclasses into error responses
try: # Convert exceptions into ApplicationError subclass exceptions try: # Convert exceptions into ApplicationError subclass exceptions
path_item_list = [ path_item_list = [
...@@ -266,6 +528,16 @@ class Application(object): ...@@ -266,6 +528,16 @@ class Application(object):
status = STATUS_NO_CONTENT status = STATUS_NO_CONTENT
header_list = [] header_list = []
result = [] result = []
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)
else: else:
raise BadMethod raise BadMethod
else: else:
...@@ -275,6 +547,12 @@ class Application(object): ...@@ -275,6 +547,12 @@ class Application(object):
subpath is SUBPATH_REQUIRED and not path_item_list subpath is SUBPATH_REQUIRED and not path_item_list
): ):
raise NotFound raise NotFound
self._checkCORSAccess(
environ=environ,
policy=action_dict.get('cors'),
header_list=cors_header_list,
preflight=False,
)
if action_dict.get('context_is_routing'): if action_dict.get('context_is_routing'):
context = path_entry_dict.get('routing') context = path_entry_dict.get('routing')
kw = { kw = {
...@@ -304,6 +582,9 @@ class Application(object): ...@@ -304,6 +582,9 @@ class Application(object):
status = e.status status = e.status
header_list = e.response_headers header_list = e.response_headers
result = [str(x) for x in e.args] result = [str(x) for x in e.args]
# 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)
start_response(status, header_list) start_response(status, header_list)
return result return result
...@@ -379,6 +660,282 @@ class Application(object): ...@@ -379,6 +660,282 @@ class Application(object):
except ValueError: except ValueError:
raise BadRequest('Invalid json') raise BadRequest('Invalid json')
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]
cookie['path'] = environ.get('SCRIPT_NAME') or '/',
cookie['expires'] = A_YEAR_IN_SECONDS
# 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
for x, y in method_dict.iteritems()
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.
"""
my_uri = application_uri(environ)
my_origin = my_uri.split('/', 1)[0]
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.
if not my_uri.endswith('/'):
my_uri += '/'
raise OriginUnauthorized(
toHTTPS(my_uri) + 'cors?' +
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()
for component, path_entry_dict in routing_dict.iteritems():
component_path = routing_path + '/' + component
if recurse and 'routing' in path_entry_dict:
routing_dict_list.append((
component_path,
path_entry_dict['routing'],
))
for method, action_dict in path_entry_dict['method'].iteritems():
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
return self._returnFile(json.dumps(hal), 'application/hal+json')
def getCORSForm(self, context, environ): # pylint: disable=unused-argument
"""
Handle GET /cors .
"""
if environ['wsgi.url_scheme'] != 'https':
return (
STATUS_FOUND,
[
('Location', toHTTPS(request_uri(environ))),
],
[],
)
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 % {
'caucase': escape(self._http_url, quote=True),
'return_to': escape(return_to, quote=True),
'origin': escape(origin, quote=True),
},
'text/html',
[
# Anti-clickjacking headers
# Standard, apparently not widespread yet
('Content-Security-Policy', "frame-ancestors 'none'"),
# BBB
('X-Frame-Options', 'DENY'),
],
)
def postCORSForm(self, context, environ): # pylint: disable=unused-argument
"""
Handle POST /cors .
"""
if environ['wsgi.url_scheme'] != 'https':
raise NotFound
if environ.get('CONTENT_TYPE') != 'application/x-www-form-urlencoded':
raise BadRequest('Unhandled Content-Type')
try:
form_dict = parse_qs(self._read(environ), strict_parsing=True)
origin, = form_dict['origin']
return_to, = form_dict['return_to']
grant, = form_dict['grant']
grant = bool(int(grant))
except (KeyError, ValueError, TypeError):
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(),
),
],
[],
)
def getCertificateRevocationList( def getCertificateRevocationList(
self, self,
context, context,
......
...@@ -52,6 +52,7 @@ setup( ...@@ -52,6 +52,7 @@ setup(
'cryptography>=2.2.1', # everything x509 except... 'cryptography>=2.2.1', # everything x509 except...
'pyOpenSSL>=18.0.0', # ...certificate chain validation 'pyOpenSSL>=18.0.0', # ...certificate chain validation
'pem>=17.1.0', # Parse PEM files 'pem>=17.1.0', # Parse PEM files
'PyJWT', # CORS token signature
], ],
zip_safe=True, zip_safe=True,
entry_points={ entry_points={
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment