Skip to content

Commit

Permalink
Merge pull request #2649 from Nordix/sslcontext
Browse files Browse the repository at this point in the history
Update SSLContext handling
  • Loading branch information
benoitc committed May 11, 2023
2 parents c829cc8 + ac8bc3a commit f955a0c
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 26 deletions.
106 changes: 97 additions & 9 deletions docs/source/settings.rst
Expand Up @@ -320,8 +320,6 @@ The log config file written in JSON.
``logconfig_dict``
~~~~~~~~~~~~~~~~~~

**Command line:** ``--log-config-dict``

**Default:** ``{}``

The log config dictionary to use, using the standard Python
Expand All @@ -343,7 +341,7 @@ For more context you can look at the default configuration dictionary for loggin

**Command line:** ``--log-syslog-to SYSLOG_ADDR``

**Default:** ``'unix:///var/run/syslog'``
**Default:** ``'udp://localhost:514'``

Address to send syslog messages.

Expand Down Expand Up @@ -932,6 +930,29 @@ Called just before exiting Gunicorn.

The callable needs to accept a single instance variable for the Arbiter.

.. _ssl-context:

``ssl_context``
~~~~~~~~~~~~~~~

**Default:**

.. code-block:: python
def ssl_context(config, default_ssl_context_factory):
return default_ssl_context_factory()
Called when SSLContext is needed.

Allows fully customized SSL context to be used in place of the default
context.

The callable needs to accept an instance variable for the Config and
a factory function that returns default SSLContext which is initialized
with certificates, private key, cert_reqs, and ciphers according to
config and can be further customized by the callable.
The callable needs to return SSLContext object.

Server Mechanics
----------------

Expand Down Expand Up @@ -994,9 +1015,7 @@ Set the ``SO_REUSEPORT`` flag on the listening socket.

**Default:** ``'.'``

Change directory to specified directory before loading apps.

Default is the current directory.
Change directory to specified directory before loading apps.

.. _daemon:

Expand Down Expand Up @@ -1157,10 +1176,16 @@ temporary directory.
**Default:** ``{'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'}``

A dictionary containing headers and values that the front-end proxy
uses to indicate HTTPS requests. These tell Gunicorn to set
uses to indicate HTTPS requests. If the source IP is permitted by
``forwarded-allow-ips`` (below), *and* at least one request header matches
a key-value pair listed in this dictionary, then Gunicorn will set
``wsgi.url_scheme`` to ``https``, so your application can tell that the
request is secure.

If the other headers listed in this dictionary are not present in the request, they will be ignored,
but if the other headers are present and do not match the provided values, then
the request will fail to parse. See the note below for more detailed examples of this behaviour.

The dictionary should map upper-case header names to exact string
values. The value comparisons are case-sensitive, unlike the header
names, so make sure they're exactly what your front-end proxy sends
Expand Down Expand Up @@ -1188,6 +1213,68 @@ you still trust the environment).
By default, the value of the ``FORWARDED_ALLOW_IPS`` environment
variable. If it is not defined, the default is ``"127.0.0.1"``.

.. note::

The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of
``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. In each case, we
have a request from the remote address 134.213.44.18, and the default value of ``secure_scheme_headers``:

.. code::
secure_scheme_headers = {
'X-FORWARDED-PROTOCOL': 'ssl',
'X-FORWARDED-PROTO': 'https',
'X-FORWARDED-SSL': 'on'
}
.. list-table::
:header-rows: 1
:align: center
:widths: auto

* - ``forwarded-allow-ips``
- Secure Request Headers
- Result
- Explanation
* - .. code::

["127.0.0.1"]
- .. code::

X-Forwarded-Proto: https
- .. code::

wsgi.url_scheme = "http"
- IP address was not allowed
* - .. code::

"*"
- <none>
- .. code::

wsgi.url_scheme = "http"
- IP address allowed, but no secure headers provided
* - .. code::

"*"
- .. code::

X-Forwarded-Proto: https
- .. code::

wsgi.url_scheme = "https"
- IP address allowed, one request header matched
* - .. code::

["134.213.44.18"]
- .. code::

X-Forwarded-Ssl: on
X-Forwarded-Proto: http
- ``InvalidSchemeHeaders()`` raised
- IP address allowed, but the two secure headers disagreed on if HTTPS was used

.. _pythonpath:

``pythonpath``
Expand Down Expand Up @@ -1360,8 +1447,9 @@ A positive integer generally in the ``2-4 x $(NUM_CORES)`` range.
You'll want to vary this a bit to find the best for your particular
application's work load.

By default, the value of the ``WEB_CONCURRENCY`` environment variable.
If it is not defined, the default is ``1``.
By default, the value of the ``WEB_CONCURRENCY`` environment variable,
which is set by some Platform-as-a-Service providers such as Heroku. If
it is not defined, the default is ``1``.

.. _worker-class:

Expand Down
24 changes: 24 additions & 0 deletions examples/example_config.py
Expand Up @@ -214,3 +214,27 @@ def worker_int(worker):

def worker_abort(worker):
worker.log.info("worker received SIGABRT signal")

def ssl_context(conf, default_ssl_context_factory):
import ssl

# The default SSLContext returned by the factory function is initialized
# with the TLS parameters from config, including TLS certificates and other
# parameters.
context = default_ssl_context_factory()

# The SSLContext can be further customized, for example by enforcing
# minimum TLS version.
context.minimum_version = ssl.TLSVersion.TLSv1_3

# Server can also return different server certificate depending which
# hostname the client uses. Requires Python 3.7 or later.
def sni_callback(socket, server_hostname, context):
if server_hostname == "foo.127.0.0.1.nip.io":
new_context = default_ssl_context_factory()
new_context.load_cert_chain(certfile="foo.pem", keyfile="foo-key.pem")
socket.context = new_context

context.sni_callback = sni_callback

return context
24 changes: 24 additions & 0 deletions gunicorn/config.py
Expand Up @@ -1523,6 +1523,8 @@ class LogConfigDict(Setting):
Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
For more context you can look at the default configuration dictionary for logging, which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``.
.. versionadded:: 19.8
"""

Expand Down Expand Up @@ -2004,6 +2006,28 @@ def on_exit(server):
The callable needs to accept a single instance variable for the Arbiter.
"""

class NewSSLContext(Setting):
name = "ssl_context"
section = "Server Hooks"
validator = validate_callable(2)
type = callable

def ssl_context(config, default_ssl_context_factory):
return default_ssl_context_factory()

default = staticmethod(ssl_context)
desc = """\
Called when SSLContext is needed.
Allows fully customized SSL context to be used in place of the default
context.
The callable needs to accept an instance variable for the Config and
a factory function that returns default SSLContext which is initialized
with certificates, private key, cert_reqs, and ciphers according to
config and can be further customized by the callable.
The callable needs to return SSLContext object.
"""

class ProxyProtocol(Setting):
name = "proxy_protocol"
Expand Down
20 changes: 19 additions & 1 deletion gunicorn/sock.py
Expand Up @@ -6,6 +6,7 @@
import errno
import os
import socket
import ssl
import stat
import sys
import time
Expand Down Expand Up @@ -203,10 +204,27 @@ def create_sockets(conf, log, fds=None):

return listeners


def close_sockets(listeners, unlink=True):
for sock in listeners:
sock_name = sock.getsockname()
sock.close()
if unlink and _sock_type(sock_name) is UnixSocket:
os.unlink(sock_name)

def ssl_context(conf):
def default_ssl_context_factory():
context = ssl.SSLContext(conf.ssl_version)
context.load_cert_chain(certfile=conf.certfile, keyfile=conf.keyfile)
context.verify_mode = conf.cert_reqs
if conf.ciphers:
context.set_ciphers(conf.ciphers)
if conf.ca_certs:
context.load_verify_locations(cafile=conf.ca_certs)
return context

return conf.ssl_context(conf, default_ssl_context_factory)

def ssl_wrap_socket(sock, conf):
return ssl_context(conf).wrap_socket(sock, server_side=True,
suppress_ragged_eofs=conf.suppress_ragged_eofs,
do_handshake_on_connect=conf.do_handshake_on_connect)
5 changes: 2 additions & 3 deletions gunicorn/workers/geventlet.py
Expand Up @@ -21,6 +21,7 @@
import greenlet

from gunicorn.workers.base_async import AsyncWorker
from gunicorn.sock import ssl_wrap_socket

# ALREADY_HANDLED is removed in 0.30.3+ now it's `WSGI_LOCAL.already_handled: bool`
# https://github.com/eventlet/eventlet/pull/544
Expand Down Expand Up @@ -152,9 +153,7 @@ def timeout_ctx(self):

def handle(self, listener, client, addr):
if self.cfg.is_ssl:
client = eventlet.wrap_ssl(client, server_side=True,
**self.cfg.ssl_options)

client = ssl_wrap_socket(client, self.cfg)
super().handle(listener, client, addr)

def run(self):
Expand Down
3 changes: 2 additions & 1 deletion gunicorn/workers/ggevent.py
Expand Up @@ -24,6 +24,7 @@

import gunicorn
from gunicorn.http.wsgi import base_environ
from gunicorn.sock import ssl_context
from gunicorn.workers.base_async import AsyncWorker

VERSION = "gevent/%s gunicorn/%s" % (gevent.__version__, gunicorn.__version__)
Expand Down Expand Up @@ -58,7 +59,7 @@ def run(self):
ssl_args = {}

if self.cfg.is_ssl:
ssl_args = dict(server_side=True, **self.cfg.ssl_options)
ssl_args = dict(ssl_context=ssl_context(self.cfg))

for s in self.sockets:
s.setblocking(1)
Expand Down
4 changes: 2 additions & 2 deletions gunicorn/workers/gthread.py
Expand Up @@ -27,6 +27,7 @@
from . import base
from .. import http
from .. import util
from .. import sock
from ..http import wsgi


Expand All @@ -52,8 +53,7 @@ def init(self):
if self.parser is None:
# wrap the socket if needed
if self.cfg.is_ssl:
self.sock = ssl.wrap_socket(self.sock, server_side=True,
**self.cfg.ssl_options)
self.sock = sock.ssl_wrap_socket(self.sock, self.cfg)

# initialize the parser
self.parser = http.RequestParser(self.cfg, self.sock, self.client)
Expand Down
10 changes: 3 additions & 7 deletions gunicorn/workers/gtornado.py
Expand Up @@ -17,6 +17,7 @@
from tornado.wsgi import WSGIContainer
from gunicorn.workers.base import Worker
from gunicorn import __version__ as gversion
from gunicorn.sock import ssl_context


# Tornado 5.0 updated its IOLoop, and the `io_loop` arguments to many
Expand Down Expand Up @@ -140,16 +141,11 @@ def on_close(instance, server_conn):
server_class = _HTTPServer

if self.cfg.is_ssl:
_ssl_opt = copy.deepcopy(self.cfg.ssl_options)
# tornado refuses initialization if ssl_options contains following
# options
del _ssl_opt["do_handshake_on_connect"]
del _ssl_opt["suppress_ragged_eofs"]
if TORNADO5:
server = server_class(app, ssl_options=_ssl_opt)
server = server_class(app, ssl_options=ssl_context(self.cfg))
else:
server = server_class(app, io_loop=self.ioloop,
ssl_options=_ssl_opt)
ssl_options=ssl_context(self.cfg))
else:
if TORNADO5:
server = server_class(app)
Expand Down
5 changes: 2 additions & 3 deletions gunicorn/workers/sync.py
Expand Up @@ -14,6 +14,7 @@

import gunicorn.http as http
import gunicorn.http.wsgi as wsgi
import gunicorn.sock as sock
import gunicorn.util as util
import gunicorn.workers.base as base

Expand Down Expand Up @@ -128,9 +129,7 @@ def handle(self, listener, client, addr):
req = None
try:
if self.cfg.is_ssl:
client = ssl.wrap_socket(client, server_side=True,
**self.cfg.ssl_options)

client = sock.ssl_wrap_socket(client, self.cfg)
parser = http.RequestParser(self.cfg, client, addr)
req = next(parser)
self.handle_request(listener, req, client, addr)
Expand Down

0 comments on commit f955a0c

Please sign in to comment.