From d815ef6d07e1e0600d5ac385c198e621dbb340c5 Mon Sep 17 00:00:00 2001 From: Tony Locke Date: Fri, 29 Mar 2024 12:57:25 +0000 Subject: [PATCH] Better defaults for SSL Now the ssl_context connection parameter can have one of four values: None - The default, meaning it'll try and connect over SSL but fall back to a plain socket if not. True - Will try and connect over SSL and fail if not. False - It'll not try to connect over SSL. SSLContext object - It'll use this object to connect over SSL. --- .github/workflows/test.yml | 12 ++-- README.md | 73 +++++++++++----------- pg8000/core.py | 48 ++++++++------ test/legacy/auth/test_scram-sha-256_ssl.py | 27 ++++---- test/native/auth/test_scram-sha-256.py | 43 +++++++++++-- test/native/auth/test_scram-sha-256_ssl.py | 50 ++++++++++++--- 6 files changed, 164 insertions(+), 89 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2aca4f..cfc434f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -80,7 +80,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -127,7 +127,7 @@ jobs: psql -c "SELECT pg_reload_conf()" - name: Check out repository code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: @@ -161,13 +161,17 @@ jobs: steps: - name: Check out repository code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.11" - name: Install dependencies run: | + psql postgresql://postgres:cpsnow@localhost -c "ALTER SYSTEM SET ssl = on;" + psql postgresql://postgres:cpsnow@localhost -c "ALTER SYSTEM SET ssl_cert_file = '/etc/ssl/certs/ssl-cert-snakeoil.pem'" + psql postgresql://postgres:cpsnow@localhost -c "ALTER SYSTEM SET ssl_key_file = '/etc/ssl/private/ssl-cert-snakeoil.key'" + psql postgresql://postgres:cpsnow@localhost localhost -c "SELECT pg_reload_conf()" python -m pip install --upgrade pip pip install black build flake8 pytest flake8-alphabetize Flake8-pyproject \ twine . diff --git a/README.md b/README.md index 10bfbed..18d304e 100644 --- a/README.md +++ b/README.md @@ -824,46 +824,43 @@ So we've taken the approach of only being able to set connection parameters usin ### Connect To PostgreSQL Over SSL -To connect to the server using SSL defaults do:: +By default the `ssl_context` connection parameter has the value `None` which means pg8000 will +attempt to connect to the server using SSL, and then fall back to a plain socket if the server +refuses SSL. If you want to *require* SSL (ie. to fail if it's not achieved) then you can set +`ssl_context=True`: ```python -import pg8000.native -connection = pg8000.native.Connection('postgres', password="cpsnow", ssl_context=True) -connection.run("SELECT 'The game is afoot!'") +>>> import pg8000.native +>>> +>>> con = pg8000.native.Connection('postgres', password="cpsnow", ssl_context=True) +>>> con.run("SELECT 'The game is afoot!'") +[['The game is afoot!']] +>>> con.close() ``` -To connect over SSL with custom settings, set the `ssl_context` parameter to an -`ssl.SSLContext` object: +If on the other hand you want to connect over SSL with custom settings, set the `ssl_context` +parameter to an [`ssl.SSLContext`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext) object: ```python -import pg8000.native -import ssl - - -ssl_context = ssl.create_default_context() -ssl_context.verify_mode = ssl.CERT_REQUIRED -ssl_context.load_verify_locations('root.pem') -connection = pg8000.native.Connection( - 'postgres', password="cpsnow", ssl_context=ssl_context) +>>> import pg8000.native +>>> import ssl +>>> +>>> ssl_context = ssl.create_default_context() +>>> ssl_context.check_hostname = False +>>> ssl_context.verify_mode = ssl.CERT_NONE +>>> con = pg8000.native.Connection( +... 'postgres', password="cpsnow", ssl_context=ssl_context) +>>> con.run("SELECT 'Work is the curse of the drinking classes.'") +[['Work is the curse of the drinking classes.']] +>>> con.close() ``` It may be that your PostgreSQL server is behind an SSL proxy server in which case you -can set a pg8000-specific attribute `ssl.SSLContext.request_ssl = False` which tells -pg8000 to connect using an SSL socket, but not to request SSL from the PostgreSQL -server: - -```python -import pg8000.native -import ssl - -ssl_context = ssl.create_default_context() -ssl_context.request_ssl = False -connection = pg8000.native.Connection( - 'postgres', password="cpsnow", ssl_context=ssl_context) - -``` +can give pg8000 the SSL socket with the `sock` parameter, and then set +`ssl_context=False` which means that no attempt will be made to create an SSL connection +to the server. ### Server-Side Cursors @@ -1433,10 +1430,11 @@ Creates a connection to a PostgreSQL database. - *password* - The user password to connect to the server with. This parameter is optional; if omitted and the database server requests password-based authentication, the connection will fail to open. If this parameter is provided but not requested by the server, no error will occur. If your server character encoding is not `ascii` or `utf8`, then you need to provide `password` as bytes, eg. `'my_password'.encode('EUC-JP')`. - *source_address* - The source IP address which initiates the connection to the PostgreSQL server. The default is `None` which means that the operating system will choose the source address. - *unix_sock* - The path to the UNIX socket to access the database through, for example, `'/tmp/.s.PGSQL.5432'`. One of either `host` or `unix_sock` must be provided. -- *ssl_context* - This governs SSL encryption for TCP/IP sockets. It can have three values: - - `None`, meaning no SSL (the default) - - `True`, means use SSL with an `ssl.SSLContext` created using `ssl.create_default_context()` - - An instance of `ssl.SSLContext` which will be used to create the SSL connection. If your PostgreSQL server is behind an SSL proxy, you can set the pg8000-specific attribute `ssl.SSLContext.request_ssl = False`, which tells pg8000 to use an SSL socket, but not to request SSL from the PostgreSQL server. Note that this means you can't use SCRAM authentication with channel binding. +- *ssl_context* - This governs SSL encryption for TCP/IP sockets. It can have four values: + - `None`, the default, meaning that an attempt will be made to connect over SSL, but if this is rejected by the server then pg8000 will fall back to using a plain socket. + - `True`, means use SSL with an `ssl.SSLContext` with the minimum of checks. + - `False`, means to not attempt to create an SSL socket. + - An instance of `ssl.SSLContext` which will be used to create the SSL connection. - *timeout* - This is the time in seconds before the connection to the server will time out. The default is `None` which means no timeout. - *tcp_keepalive* - If `True` then use [TCP keepalive](https://en.wikipedia.org/wiki/Keepalive#TCP_keepalive). The default is `True`. - *application_name* - Sets the [application\_name](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-APPLICATION-NAME). If your server character encoding is not `ascii` or `utf8`, then you need to provide values as bytes, eg. `'my_application_name'.encode('EUC-JP')`. The default is `None` which means that the server will set the application name. @@ -1636,10 +1634,11 @@ Creates a connection to a PostgreSQL database. - *password* - The user password to connect to the server with. This parameter is optional; if omitted and the database server requests password-based authentication, the connection will fail to open. If this parameter is provided but not requested by the server, no error will occur. If your server character encoding is not `ascii` or `utf8`, then you need to provide `password` as bytes, eg. `'my_password'.encode('EUC-JP')`. - *source_address* - The source IP address which initiates the connection to the PostgreSQL server. The default is `None` which means that the operating system will choose the source address. - *unix_sock* - The path to the UNIX socket to access the database through, for example, `'/tmp/.s.PGSQL.5432'`. One of either `host` or `unix_sock` must be provided. -- *ssl\_context* - This governs SSL encryption for TCP/IP sockets. It can have three values: - - `None`, meaning no SSL (the default) - - `True`, means use SSL with an `ssl.SSLContext` created using `ssl.create_default_context()`. - - An instance of `ssl.SSLContext` which will be used to create the SSL connection. If your PostgreSQL server is behind an SSL proxy, you can set the pg8000-specific attribute `ssl.SSLContext.request_ssl = False`, which tells pg8000 to use an SSL socket, but not to request SSL from the PostgreSQL server. Note that this means you can't use SCRAM authentication with channel binding. +- *ssl_context* - This governs SSL encryption for TCP/IP sockets. It can have four values: + - `None`, the default, meaning that an attempt will be made to connect over SSL, but if this is rejected by the server then pg8000 will fall back to using a plain socket. + - `True`, means use SSL with an `ssl.SSLContext` with the minimum of checks. + - `False`, means to not attempt to create an SSL socket. + - An instance of `ssl.SSLContext` which will be used to create the SSL connection. - *timeout* - This is the time in seconds before the connection to the server will time out. The default is `None` which means no timeout. - *tcp_keepalive* - If `True` then use [TCP keepalive](https://en.wikipedia.org/wiki/Keepalive#TCP_keepalive). The default is `True`. - *application_name* - Sets the [application\_name](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-APPLICATION-NAME). If your server character encoding is not `ascii` or `utf8`, then you need to provide values as bytes, eg. `'my_application_name'.encode('EUC-JP')`. The default is `None` which means that the server will set the application name. diff --git a/pg8000/core.py b/pg8000/core.py index 8375b95..ce187ba 100644 --- a/pg8000/core.py +++ b/pg8000/core.py @@ -170,10 +170,17 @@ def _write(sock, d): def _make_socket( - unix_sock, sock, host, port, timeout, source_address, tcp_keepalive, ssl_context + unix_sock, + orig_sock, + host, + port, + timeout, + source_address, + tcp_keepalive, + orig_ssl_context, ): if unix_sock is not None: - if sock is not None: + if orig_sock is not None: raise InterfaceError("If unix_sock is provided, sock must be None") try: @@ -191,8 +198,8 @@ def _make_socket( sock.close() raise InterfaceError("communication error") from e - elif sock is not None: - pass + elif orig_sock is not None: + sock = orig_sock elif host is not None: try: @@ -210,29 +217,30 @@ def _make_socket( raise InterfaceError("one of host, sock or unix_sock must be provided") channel_binding = None - if ssl_context is not None: + if orig_ssl_context is not False: try: import ssl - if ssl_context is True: + if orig_ssl_context is True or orig_ssl_context is None: ssl_context = ssl.create_default_context() - - request_ssl = getattr(ssl_context, "request_ssl", True) - - if request_ssl: - # Int32(8) - Message length, including self. - # Int32(80877103) - The SSL request code. - sock.sendall(ii_pack(8, 80877103)) - resp = sock.recv(1) - if resp != b"S": - raise InterfaceError("Server refuses SSL") - - sock = ssl_context.wrap_socket(sock, server_hostname=host) - - if request_ssl: + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + else: + ssl_context = orig_ssl_context + + # Int32(8) - Message length, including self. + # Int32(80877103) - The SSL request code. + sock.sendall(ii_pack(8, 80877103)) + resp = sock.recv(1).decode("ascii") + if resp == "S": + sock = ssl_context.wrap_socket(sock, server_hostname=host) channel_binding = scramp.make_channel_binding( "tls-server-end-point", sock ) + elif orig_ssl_context is not None: + if sock is not None: + sock.close() + raise InterfaceError("Server refuses SSL") except ImportError: raise InterfaceError( diff --git a/test/legacy/auth/test_scram-sha-256_ssl.py b/test/legacy/auth/test_scram-sha-256_ssl.py index dd35b42..49519ac 100644 --- a/test/legacy/auth/test_scram-sha-256_ssl.py +++ b/test/legacy/auth/test_scram-sha-256_ssl.py @@ -1,22 +1,23 @@ -import ssl - import pytest from pg8000 import DatabaseError, connect # This requires a line in pg_hba.conf that requires scram-sha-256 for the -# database scram-sha-256 +# database pg8000_scram-sha-256 + +DB = "pg8000_scram_sha_256" + +@pytest.fixture +def setup(con): + try: + con.run(f"CREATE DATABASE {DB}") + except DatabaseError: + pass -def test_scram_sha_256_plus(db_kwargs): - context = ssl.create_default_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - db_kwargs["ssl_context"] = context - db_kwargs["database"] = "pg8000_scram_sha_256" +def test_scram_sha_256_plus(setup, db_kwargs): + db_kwargs["database"] = DB - # Should only raise an exception saying db doesn't exist - with pytest.raises(DatabaseError, match="3D000"): - with connect(**db_kwargs) as con: - con.close() + with connect(**db_kwargs): + pass diff --git a/test/native/auth/test_scram-sha-256.py b/test/native/auth/test_scram-sha-256.py index a60e486..8d06a0a 100644 --- a/test/native/auth/test_scram-sha-256.py +++ b/test/native/auth/test_scram-sha-256.py @@ -1,14 +1,45 @@ import pytest -from pg8000.native import Connection, DatabaseError +from pg8000.native import Connection, DatabaseError, InterfaceError # This requires a line in pg_hba.conf that requires scram-sha-256 for the # database scram_sha_256 +DB = "pg8000_scram_sha_256" -def test_scram_sha_256(db_kwargs): - db_kwargs["database"] = "pg8000_scram_sha_256" - # Should only raise an exception saying db doesn't exist - with pytest.raises(DatabaseError, match="3D000"): - Connection(**db_kwargs) +@pytest.fixture +def setup(con): + try: + con.run(f"CREATE DATABASE {DB}") + except DatabaseError: + pass + con.run("ALTER SYSTEM SET ssl = off") + con.run("SELECT pg_reload_conf()") + yield + con.run("ALTER SYSTEM SET ssl = on") + con.run("SELECT pg_reload_conf()") + + +def test_scram_sha_256(setup, db_kwargs): + db_kwargs["database"] = DB + + with Connection(**db_kwargs): + pass + + +def test_scram_sha_256_ssl_False(setup, db_kwargs): + db_kwargs["database"] = DB + db_kwargs["ssl_context"] = False + + with Connection(**db_kwargs): + pass + + +def test_scram_sha_256_ssl_True(setup, db_kwargs): + db_kwargs["database"] = DB + db_kwargs["ssl_context"] = True + + with pytest.raises(InterfaceError, match="Server refuses SSL"): + with Connection(**db_kwargs): + pass diff --git a/test/native/auth/test_scram-sha-256_ssl.py b/test/native/auth/test_scram-sha-256_ssl.py index bec8aea..7976e65 100644 --- a/test/native/auth/test_scram-sha-256_ssl.py +++ b/test/native/auth/test_scram-sha-256_ssl.py @@ -1,21 +1,53 @@ -import ssl +from ssl import CERT_NONE, SSLSocket, create_default_context import pytest from pg8000.native import Connection, DatabaseError # This requires a line in pg_hba.conf that requires scram-sha-256 for the -# database scram_sha_256 +# database pg8000_scram_sha_256 +DB = "pg8000_scram_sha_256" -def test_scram_sha_256_plus(db_kwargs): - context = ssl.create_default_context() + +@pytest.fixture +def setup(con): + try: + con.run(f"CREATE DATABASE {DB}") + except DatabaseError: + pass + + +def test_scram_sha_256_plus(setup, db_kwargs): + db_kwargs["database"] = DB + + with Connection(**db_kwargs) as con: + assert isinstance(con._usock, SSLSocket) + + +def test_scram_sha_256_plus_ssl_True(setup, db_kwargs): + db_kwargs["ssl_context"] = True + db_kwargs["database"] = DB + + with Connection(**db_kwargs) as con: + assert isinstance(con._usock, SSLSocket) + + +def test_scram_sha_256_plus_ssl_custom(setup, db_kwargs): + context = create_default_context() context.check_hostname = False - context.verify_mode = ssl.CERT_NONE + context.verify_mode = CERT_NONE db_kwargs["ssl_context"] = context - db_kwargs["database"] = "pg8000_scram_sha_256" + db_kwargs["database"] = DB + + with Connection(**db_kwargs) as con: + assert isinstance(con._usock, SSLSocket) + + +def test_scram_sha_256_plus_ssl_False(setup, db_kwargs): + db_kwargs["ssl_context"] = False + db_kwargs["database"] = DB - # Should only raise an exception saying db doesn't exist - with pytest.raises(DatabaseError, match="3D000"): - Connection(**db_kwargs) + with Connection(**db_kwargs) as con: + assert not isinstance(con._usock, SSLSocket)