Files
scylladb/test/alternator/test_https.py
Nadav Har'El 7c24e09b0d test/alternator: add some Alternator-over-HTTPS tests
This patch adds a few tests for Alternator over HTTPS (encrypted HTTP,
a.k.a. TLS or SSL). The tests are skipped unless run with "--https", so
they will not be run in CI. Nevertheless, they are useful to improve
our understanding on how DynamoDB works over HTTPS and can be a basis
for adding more tests for HTTPS support. The included tests pass on both
Alternator and AWS DynamoDB.

One test checks that both TLS 1.2 and TLS 1.3 are properly supported,
and if chosen by the client, are actually honored. The same test also
checks that TLS 1.1 is not supported, and results with a proper error
if attempted. Both AWS DynamoDB and Alterator support the same protocols.

Another test verifies that HTTP (unencrypted) requests cannot be sent
over an HTTPS port. This is important for security - an installation
that chooses to allow only HTTPS wants users to only use encrypted
connections, and would not want users to continue sending unencrypted
requests to the HTTPS port.

Signed-off-by: Nadav Har'El <nyh@scylladb.com>

Closes scylladb/scylladb#23493
2025-05-12 15:38:33 +03:00

115 lines
5.8 KiB
Python

# Copyright 2025-present ScyllaDB
#
# SPDX-License-Identifier: LicenseRef-ScyllaDB-Source-Available-1.0
#############################################################################
# Tests for Alternator (DynamoDB API) requests over HTTPS (a.k.a. SSL, TLS).
# These tests are skipped when the tests are *not* running over https, so
# run the tests with the "--https" flag to enable them. The "--https" flag,
# among other things, uses an https:// endpoint.
#############################################################################
import pytest
import urllib3
import urllib.parse
import ssl
@pytest.fixture(scope="module")
def https_url(dynamodb):
url = dynamodb.meta.client._endpoint.host
if not url.startswith('https://'):
pytest.skip("HTTPS-specific tests are skipped without the '--https' option")
yield url
# Test which TLS versions are supported. We require that both TLS 1.2 and 1.3
# must be supported, and the older TLS 1.1 version may either work or be
# rejected by the server with the proper error ("no protocols available"),
# and not a broken connection as happened in issue #8827.
# To make it easier to understand what TLS setup the client-side stack is
# using, we avoid boto3 and instead create requests manually using Python's
# "urllib3" (which boto3 also uses in its implementation). Moreover, to
# avoid having to build and sign complex requests (see examples of that in
# test_manual_requests.py), we used the generic health-check request (the "/"
# request). This request does not need to be signed - but still needs to use
# TLS properly, so we can use it to test TLS.
@pytest.mark.parametrize("tls_version_and_support_required", [
('TLSv1_1', False),
('TLSv1_2', True),
('TLSv1_3', True)])
def test_tls_versions(https_url, tls_version_and_support_required):
tls_version, support_required = tls_version_and_support_required
context = ssl.create_default_context()
context.minimum_version = getattr(ssl.TLSVersion, tls_version)
context.maximum_version = context.minimum_version
# check_hostname and verify_mode is needed when we use self-signed
# certificates in tests.
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with urllib3.PoolManager(ssl_context=context, retries=0) as pool:
try:
# preload_content=False tells the library not to read the content
# and not release the connection back to the pool, so we can
# still inspect this connection and its negoatiated TLS version.
res = pool.request('GET', https_url, preload_content=False)
except urllib3.exceptions.MaxRetryError as e:
if support_required or 'NO_PROTOCOLS_AVAILABLE' not in str(e):
# An error in a TLS version we are required to support,
# or an unexpected type of error in any version - this is
# a failure.
raise
return
# Check that the negotiated TLS version, res.connection.sock.version(),
# is the one we tried to set. Note that version() returns a string
# like TLSv1.2, while the ssl.TLSVersion attribute name we used for
# tls_version uses a underscore.
assert res.connection
assert res.connection.sock.version().replace('.', '_') == tls_version
# Finally read the response
res.read()
assert res.status == 200
# Test that if we send an unencrypted (HTTP) request to an HTTPS port,
# it doesn't work.
# Although in theory it is possible to implement a server that serves both
# HTTP and HTTPS on the same port (by looking at the first byte of the
# request) - neither DynamoDB nor Scylla do this, and it's not a good idea:
# A deployment that wants to allow only HTTPS, not HTTP, probably has a
# security reason for doing this, and doesn't want us to allow an unencrypted
# HTTP request to be sent over the HTTPS port. So let's verify that an HTTP
# request on an HTTPS port indeed does not work.
def test_http_on_https(https_url):
# Take an https URL, replace the https by http. If the port is not
# explicitly specified we need to assume it is the default https port
# (443) and need to add it explicitly, because it's not the default for
# http.
p = urllib.parse.urlparse(https_url)
assert p.scheme == 'https'
p = p._replace(scheme='http')
if p.port is None:
p = p._replace(netloc=p.netloc+':443')
http_url = p.geturl()
# CERT_NONE causes the client to not verify the server certificate,
# and is needed because we use self-signed certificates in tests.
with urllib3.PoolManager(retries=0, cert_reqs=ssl.CERT_NONE) as pool:
# Sanity check: the original https:// URL works. This is useful to
# know so that if the http:// URL does *not* work - as we expect -
# we can be sure this is a real success of the test and not some
# silly error causing every request to fail and not just the http one.
res = pool.request('GET', https_url)
assert res.status == 200
# In DynamoDB, sending an unencrypted request to the SSL port results
# in nice clean HTTP 400 Bad Request error, with a body explaining
# that "The plain HTTP request was sent to HTTPS port". In Scylla,
# the situation is not as clean - it replies with an SSL error which
# confuses urllib3 because doesn't look like an HTTP reply. Let's
# accept both. The important part is that the unencrypted request is
# not accepted.
try:
res = pool.request('GET', http_url)
assert res.status == 400
except urllib3.exceptions.MaxRetryError as e:
# This happens when the SSL server returns an error in SSL
# form, which doesn't look like an HTTP status line.
assert 'BadStatusLine' in str(e)