WIP
This commit is contained in:
parent
a937977ca2
commit
6a65222f1e
313
sanic/http/tls.py
Normal file
313
sanic/http/tls.py
Normal file
@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
|
||||
from contextlib import suppress
|
||||
from inspect import currentframe, getframeinfo
|
||||
from pathlib import Path
|
||||
from ssl import SSLContext
|
||||
from tempfile import mkdtemp
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Union
|
||||
|
||||
from sanic.application.state import Mode
|
||||
from sanic.constants import DEFAULT_LOCAL_TLS_CERT, DEFAULT_LOCAL_TLS_KEY
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.helpers import Default, _default
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
# Only allow secure ciphers, notably leaving out AES-CBC mode
|
||||
# OpenSSL chooses ECDSA or RSA depending on the cert in use
|
||||
CIPHERS_TLS12 = [
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
"ECDHE-RSA-CHACHA20-POLY1305",
|
||||
"ECDHE-RSA-AES256-GCM-SHA384",
|
||||
"ECDHE-RSA-AES128-GCM-SHA256",
|
||||
]
|
||||
|
||||
|
||||
def create_context(
|
||||
certfile: Optional[str] = None,
|
||||
keyfile: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
) -> ssl.SSLContext:
|
||||
"""Create a context with secure crypto and HTTP/1.1 in protocols."""
|
||||
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.set_ciphers(":".join(CIPHERS_TLS12))
|
||||
context.set_alpn_protocols(["http/1.1"])
|
||||
context.sni_callback = server_name_callback
|
||||
if certfile and keyfile:
|
||||
context.load_cert_chain(certfile, keyfile, password)
|
||||
return context
|
||||
|
||||
|
||||
def shorthand_to_ctx(
|
||||
ctxdef: Union[None, ssl.SSLContext, dict, str]
|
||||
) -> Optional[ssl.SSLContext]:
|
||||
"""Convert an ssl argument shorthand to an SSLContext object."""
|
||||
if ctxdef is None or isinstance(ctxdef, ssl.SSLContext):
|
||||
return ctxdef
|
||||
if isinstance(ctxdef, str):
|
||||
return load_cert_dir(ctxdef)
|
||||
if isinstance(ctxdef, dict):
|
||||
return CertSimple(**ctxdef)
|
||||
raise ValueError(
|
||||
f"Invalid ssl argument {type(ctxdef)}."
|
||||
" Expecting a list of certdirs, a dict or an SSLContext."
|
||||
)
|
||||
|
||||
|
||||
def process_to_context(
|
||||
ssldef: Union[None, ssl.SSLContext, dict, str, list, tuple]
|
||||
) -> Optional[ssl.SSLContext]:
|
||||
"""Process app.run ssl argument from easy formats to full SSLContext."""
|
||||
return (
|
||||
CertSelector(map(shorthand_to_ctx, ssldef))
|
||||
if isinstance(ssldef, (list, tuple))
|
||||
else shorthand_to_ctx(ssldef)
|
||||
)
|
||||
|
||||
|
||||
def load_cert_dir(p: str) -> ssl.SSLContext:
|
||||
if os.path.isfile(p):
|
||||
raise ValueError(f"Certificate folder expected but {p} is a file.")
|
||||
keyfile = os.path.join(p, "privkey.pem")
|
||||
certfile = os.path.join(p, "fullchain.pem")
|
||||
if not os.access(keyfile, os.R_OK):
|
||||
raise ValueError(
|
||||
f"Certificate not found or permission denied {keyfile}"
|
||||
)
|
||||
if not os.access(certfile, os.R_OK):
|
||||
raise ValueError(
|
||||
f"Certificate not found or permission denied {certfile}"
|
||||
)
|
||||
return CertSimple(certfile, keyfile)
|
||||
|
||||
|
||||
class CertSimple(ssl.SSLContext):
|
||||
"""A wrapper for creating SSLContext with a sanic attribute."""
|
||||
|
||||
sanic: Dict[str, Any]
|
||||
|
||||
def __new__(cls, cert, key, **kw):
|
||||
# try common aliases, rename to cert/key
|
||||
certfile = kw["cert"] = kw.pop("certificate", None) or cert
|
||||
keyfile = kw["key"] = kw.pop("keyfile", None) or key
|
||||
password = kw.pop("password", None)
|
||||
if not certfile or not keyfile:
|
||||
raise ValueError("SSL dict needs filenames for cert and key.")
|
||||
subject = {}
|
||||
if "names" not in kw:
|
||||
cert = ssl._ssl._test_decode_cert(certfile) # type: ignore
|
||||
kw["names"] = [
|
||||
name
|
||||
for t, name in cert["subjectAltName"]
|
||||
if t in ["DNS", "IP Address"]
|
||||
]
|
||||
subject = {k: v for item in cert["subject"] for k, v in item}
|
||||
self = create_context(certfile, keyfile, password)
|
||||
self.__class__ = cls
|
||||
self.sanic = {**subject, **kw}
|
||||
return self
|
||||
|
||||
def __init__(self, cert, key, **kw):
|
||||
pass # Do not call super().__init__ because it is already initialized
|
||||
|
||||
|
||||
class CertSelector(ssl.SSLContext):
|
||||
"""Automatically select SSL certificate based on the hostname that the
|
||||
client is trying to access, via SSL SNI. Paths to certificate folders
|
||||
with privkey.pem and fullchain.pem in them should be provided, and
|
||||
will be matched in the order given whenever there is a new connection.
|
||||
"""
|
||||
|
||||
def __new__(cls, ctxs):
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, ctxs: Iterable[Optional[ssl.SSLContext]]):
|
||||
super().__init__()
|
||||
self.sni_callback = selector_sni_callback # type: ignore
|
||||
self.sanic_select = []
|
||||
self.sanic_fallback = None
|
||||
all_names = []
|
||||
for i, ctx in enumerate(ctxs):
|
||||
if not ctx:
|
||||
continue
|
||||
names = dict(getattr(ctx, "sanic", {})).get("names", [])
|
||||
all_names += names
|
||||
self.sanic_select.append(ctx)
|
||||
if i == 0:
|
||||
self.sanic_fallback = ctx
|
||||
if not all_names:
|
||||
raise ValueError(
|
||||
"No certificates with SubjectAlternativeNames found."
|
||||
)
|
||||
logger.info(f"Certificate vhosts: {', '.join(all_names)}")
|
||||
|
||||
|
||||
def find_cert(self: CertSelector, server_name: str):
|
||||
"""Find the first certificate that matches the given SNI.
|
||||
|
||||
:raises ssl.CertificateError: No matching certificate found.
|
||||
:return: A matching ssl.SSLContext object if found."""
|
||||
if not server_name:
|
||||
if self.sanic_fallback:
|
||||
return self.sanic_fallback
|
||||
raise ValueError(
|
||||
"The client provided no SNI to match for certificate."
|
||||
)
|
||||
for ctx in self.sanic_select:
|
||||
if match_hostname(ctx, server_name):
|
||||
return ctx
|
||||
if self.sanic_fallback:
|
||||
return self.sanic_fallback
|
||||
raise ValueError(f"No certificate found matching hostname {server_name!r}")
|
||||
|
||||
|
||||
def match_hostname(
|
||||
ctx: Union[ssl.SSLContext, CertSelector], hostname: str
|
||||
) -> bool:
|
||||
"""Match names from CertSelector against a received hostname."""
|
||||
# Local certs are considered trusted, so this can be less pedantic
|
||||
# and thus faster than the deprecated ssl.match_hostname function is.
|
||||
names = dict(getattr(ctx, "sanic", {})).get("names", [])
|
||||
hostname = hostname.lower()
|
||||
for name in names:
|
||||
if name.startswith("*."):
|
||||
if hostname.split(".", 1)[-1] == name[2:]:
|
||||
return True
|
||||
elif name == hostname:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def selector_sni_callback(
|
||||
sslobj: ssl.SSLObject, server_name: str, ctx: CertSelector
|
||||
) -> Optional[int]:
|
||||
"""Select a certificate matching the SNI."""
|
||||
# Call server_name_callback to store the SNI on sslobj
|
||||
server_name_callback(sslobj, server_name, ctx)
|
||||
# Find a new context matching the hostname
|
||||
try:
|
||||
sslobj.context = find_cert(ctx, server_name)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Rejecting TLS connection: {e}")
|
||||
# This would show ERR_SSL_UNRECOGNIZED_NAME_ALERT on client side if
|
||||
# asyncio/uvloop did proper SSL shutdown. They don't.
|
||||
return ssl.ALERT_DESCRIPTION_UNRECOGNIZED_NAME
|
||||
return None # mypy complains without explicit return
|
||||
|
||||
|
||||
def server_name_callback(
|
||||
sslobj: ssl.SSLObject, server_name: str, ctx: ssl.SSLContext
|
||||
) -> None:
|
||||
"""Store the received SNI as sslobj.sanic_server_name."""
|
||||
sslobj.sanic_server_name = server_name # type: ignore
|
||||
|
||||
|
||||
def _make_path(maybe_path: Union[Path, str], tmpdir: Optional[Path]) -> Path:
|
||||
if isinstance(maybe_path, Path):
|
||||
return maybe_path
|
||||
else:
|
||||
path = Path(maybe_path)
|
||||
if not path.exists():
|
||||
if not tmpdir:
|
||||
raise RuntimeError("Reached an unknown state. No tmpdir.")
|
||||
return tmpdir / maybe_path
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def get_ssl_context(app: Sanic, ssl: Optional[SSLContext]) -> SSLContext:
|
||||
if ssl:
|
||||
return ssl
|
||||
|
||||
if app.state.mode is Mode.PRODUCTION:
|
||||
raise SanicException(
|
||||
"Cannot run Sanic as an HTTP/3 server in PRODUCTION mode "
|
||||
"without passing a TLS certificate. If you are developing "
|
||||
"locally, please enable DEVELOPMENT mode and Sanic will "
|
||||
"generate a localhost TLS certificate. For more information "
|
||||
"please see: ___."
|
||||
)
|
||||
|
||||
try:
|
||||
tmpdir = None
|
||||
if isinstance(app.config.LOCAL_TLS_KEY, Default) or isinstance(
|
||||
app.config.LOCAL_TLS_CERT, Default
|
||||
):
|
||||
tmpdir = Path(mkdtemp())
|
||||
|
||||
key = (
|
||||
DEFAULT_LOCAL_TLS_KEY
|
||||
if isinstance(app.config.LOCAL_TLS_KEY, Default)
|
||||
else app.config.LOCAL_TLS_KEY
|
||||
)
|
||||
cert = (
|
||||
DEFAULT_LOCAL_TLS_CERT
|
||||
if isinstance(app.config.LOCAL_TLS_CERT, Default)
|
||||
else app.config.LOCAL_TLS_CERT
|
||||
)
|
||||
|
||||
key_path = _make_path(key, tmpdir)
|
||||
cert_path = _make_path(cert, tmpdir)
|
||||
|
||||
if not cert_path.exists():
|
||||
generate_local_certificate(
|
||||
key_path, cert_path, app.config.LOCALHOST
|
||||
)
|
||||
finally:
|
||||
|
||||
@app.main_process_stop
|
||||
async def cleanup(*_):
|
||||
if tmpdir:
|
||||
with suppress(FileNotFoundError):
|
||||
key_path.unlink()
|
||||
cert_path.unlink()
|
||||
tmpdir.rmdir()
|
||||
|
||||
return CertSimple(cert_path, key_path)
|
||||
|
||||
|
||||
def generate_local_certificate(
|
||||
key_path: Path, cert_path: Path, localhost: str
|
||||
):
|
||||
check_mkcert()
|
||||
|
||||
cmd = [
|
||||
"mkcert",
|
||||
"-key-file",
|
||||
str(key_path),
|
||||
"-cert-file",
|
||||
str(cert_path),
|
||||
localhost,
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def check_mkcert():
|
||||
try:
|
||||
subprocess.run(
|
||||
["mkcert", "-help"],
|
||||
check=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
raise SanicException(
|
||||
"Sanic uses mkcert to generate local TLS certificates. Since you "
|
||||
"did not supply a certificate, Sanic is attempting to generate "
|
||||
"one for you, but cannot proceed since mkcert does not appear to "
|
||||
"be installed. Please install mkcert or supply TLS certificates "
|
||||
"to proceed. Installation instructions can be found here: "
|
||||
"https://github.com/FiloSottile/mkcert"
|
||||
) from e
|
Loading…
x
Reference in New Issue
Block a user