Skip to content

Commit

Permalink
Better defaults for SSL
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tlocke committed Mar 31, 2024
1 parent fdfc80a commit d815ef6
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 89 deletions.
12 changes: 8 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 .
Expand Down
73 changes: 36 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
48 changes: 28 additions & 20 deletions pg8000/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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(
Expand Down
27 changes: 14 additions & 13 deletions test/legacy/auth/test_scram-sha-256_ssl.py
Original file line number Diff line number Diff line change
@@ -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
43 changes: 37 additions & 6 deletions test/native/auth/test_scram-sha-256.py
Original file line number Diff line number Diff line change
@@ -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
50 changes: 41 additions & 9 deletions test/native/auth/test_scram-sha-256_ssl.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit d815ef6

Please sign in to comment.