Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions doc/admin-guide/files/records.yaml.en.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4244,8 +4244,9 @@ SSL Termination
This configuration specifies the maximum number of bytes to write
into a SSL record when replying over a SSL session. In some
circumstances this setting can improve response latency by reducing
buffering at the SSL layer. This setting can have a value between 0
and 16383 (max TLS record size).
buffering at the SSL layer. This setting accepts ``-1`` for dynamic
sizing, ``0`` for the default behavior, or a fixed maximum between
``1`` and ``16383`` bytes.

The default of ``0`` means to always write all available data into
a single SSL record.
Expand Down
2 changes: 1 addition & 1 deletion src/records/RecordsConfig.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1230,7 +1230,7 @@ static constexpr RecordElement RecordsConfig[] =
,
{RECT_CONFIG, "proxy.config.ssl.origin_session_cache.size", RECD_INT, "10240", RECU_RESTART_TS, RR_NULL, RECC_NULL, nullptr, RECA_NULL}
,
{RECT_CONFIG, "proxy.config.ssl.max_record_size", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-16383]", RECA_NULL}
{RECT_CONFIG, "proxy.config.ssl.max_record_size", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[-1-16383]", RECA_NULL}
,
{RECT_CONFIG, "proxy.config.ssl.hsts_max_age", RECD_INT, "-1", RECU_DYNAMIC, RR_NULL, RECC_STR, "^-?[0-9]+$", RECA_NULL}
,
Expand Down
72 changes: 39 additions & 33 deletions tests/gold_tests/tls/tls_record_size.test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'''
Exercise the TLS record-size clamp (proxy.config.ssl.max_record_size > 0): on a
large TLS download the body must arrive intact and every application-data record
on the wire must be clamped to the configured size.
Exercise fixed and dynamic TLS record sizing. On a large TLS download the body
must arrive intact and application-data records on the wire must follow the
configured sizing strategy.
'''
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
Expand All @@ -19,30 +19,22 @@
# See the License for the specific language governing permissions and
# limitations under the License.

# NOTE: only the positive (fixed-clamp) branch of the record-sizing logic is
# covered here. The documented dynamic mode (max_record_size == -1) cannot be
# enabled through records.yaml because the record's validity check is [0-16383],
# which rejects -1; that inconsistency is pre-existing, so the dynamic branch
# stays uncovered by design.

import os
import sys

Test.Summary = __doc__


class TestRecordSizeClamp:
'''Verify max_record_size clamps every record of a large TLS download.'''

# Comfortably larger than the clamp so many records pass through it.
_body_len: int = 1024 * 1024
_max_record: int = 4096
class TestRecordSize:
'''Verify fixed and dynamic TLS record sizing on large downloads.'''

_server_counter: int = 0
_ts_counter: int = 0

def __init__(self) -> None:
def __init__(self, max_record: int, body_len: int) -> None:
'''Declare the test Processes.'''
self._max_record = max_record
self._body_len = body_len
self._server = self._configure_server()
self._ts = self._configure_trafficserver()

Expand All @@ -51,28 +43,28 @@ def _configure_server(self) -> 'Process':

:return: The origin server Process.
'''
server = Test.MakeOriginServer(f'server-{TestRecordSizeClamp._server_counter}')
TestRecordSizeClamp._server_counter += 1
server = Test.MakeOriginServer(f'server-{TestRecordSize._server_counter}')
TestRecordSize._server_counter += 1

body = "x" * TestRecordSizeClamp._body_len
body = "x" * self._body_len
request_header = {"headers": "GET /obj HTTP/1.1\r\nHost: ex.test\r\n\r\n", "timestamp": "1469733493.993", "body": ""}
response_header = {
"headers":
"HTTP/1.1 200 OK\r\nServer: microserver\r\nConnection: close\r\n"
f"Cache-Control: max-age=3600\r\nContent-Length: {TestRecordSizeClamp._body_len}\r\n\r\n",
f"Cache-Control: max-age=3600\r\nContent-Length: {self._body_len}\r\n\r\n",
"timestamp": "1469733493.993",
"body": body
}
server.addResponse("sessionlog.json", request_header, response_header)
return server

def _configure_trafficserver(self) -> 'Process':
'''Configure Traffic Server with a positive max_record_size clamp.
'''Configure Traffic Server with the requested record-size strategy.

:return: The Traffic Server Process.
'''
ts = Test.MakeATSProcess(f'ts-{TestRecordSizeClamp._ts_counter}', enable_tls=True)
TestRecordSizeClamp._ts_counter += 1
ts = Test.MakeATSProcess(f'ts-{TestRecordSize._ts_counter}', enable_tls=True)
TestRecordSize._ts_counter += 1

ts.addDefaultSSLFiles()
ts.Disk.ssl_multicert_yaml.AddLines(
Expand All @@ -87,31 +79,45 @@ def _configure_trafficserver(self) -> 'Process':
{
'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}',
'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}',
# Positive cap -> the write path clamps each TLS record to this many bytes.
'proxy.config.ssl.max_record_size': TestRecordSizeClamp._max_record,
'proxy.config.ssl.max_record_size': self._max_record,
})
if self._max_record == -1:
ts.Disk.traffic_out.Content = Testers.ExcludesExpression(
r'proxy\.config\.ssl\.max_record_size.*Validity Check error',
'The dynamic record-size sentinel should pass records validation')
return ts

def run(self) -> None:
'''Configure and run the TestRun.

The client downloads the object and measures the TLS records on the wire,
asserting both that the body is intact and that no application-data record
exceeds the configured clamp.
The client downloads the object and measures the TLS records on the wire.
'''
tr = Test.AddTestRun("max_record_size>0 clamps records on a large TLS download")
if self._max_record == -1:
description = 'max_record_size=-1 dynamically sizes records on a large TLS download'
client_option = '--dynamic'
expected_output = 'PASS: TLS records ramp from small to large after the dynamic threshold'
else:
description = 'max_record_size>0 clamps records on a large TLS download'
client_option = f'--max-record {self._max_record}'
expected_output = 'PASS: every application-data record is within the configured clamp'

tr = Test.AddTestRun(description)
tr.Processes.Default.StartBefore(self._server)
tr.Processes.Default.StartBefore(self._ts)
tr.Processes.Default.Command = (
f'{sys.executable} {os.path.join(Test.TestDirectory, "tls_record_size_client.py")} '
f'-p {self._ts.Variables.ssl_port} --host ex.test --path /obj '
f'--max-record {TestRecordSizeClamp._max_record} --expect-bytes {TestRecordSizeClamp._body_len}')
f'{client_option} --expect-bytes {self._body_len}')
tr.Processes.Default.ReturnCode = 0
tr.Processes.Default.Streams.All += Testers.ContainsExpression(
"PASS: every application-data record is within the configured clamp",
"every TLS record must be clamped to the configured size")
expected_output, 'TLS records must follow the configured sizing strategy')
tr.StillRunningAfter = self._ts
tr.StillRunningAfter = self._server


TestRecordSizeClamp().run()
# The fixed-size test response is comfortably larger than its 4,096-byte clamp,
# ensuring that many records exercise the clamp. The dynamic-sizing test
# response must exceed its 1,000,000-byte threshold by enough data to demonstrate
# both phases.
TestRecordSize(4096, 1024 * 1024).run()
TestRecordSize(-1, 2 * 1024 * 1024).run()
50 changes: 43 additions & 7 deletions tests/gold_tests/tls/tls_record_size_client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
'''
Download an object from ATS over TLS and inspect the TLS records on the wire:
confirm the body arrives intact AND that every application-data record is no
larger than the configured proxy.config.ssl.max_record_size (plus AEAD overhead).
confirm the body arrives intact AND that application-data records follow the
configured fixed or dynamic record-size strategy.

A MemoryBIO drives the handshake so the raw ciphertext stream is visible; the
5-byte TLS record headers (type, version, length) are in cleartext, so record
Expand Down Expand Up @@ -34,12 +34,16 @@
from collections.abc import Iterator

TLS_APPLICATION_DATA = 23
TLS12_GCM_OVERHEAD = 24
# A clamped plaintext record becomes ciphertext of plaintext + AEAD overhead
# (TLS1.2 GCM: 8-byte explicit nonce + 16-byte tag = 24 bytes; the cipher is pinned
# to AEAD below). 256 is a generous ceiling over that, far below an unclamped ~16 KB
# record, so the clamp check stays decisive and cannot be tripped by the larger,
# variable expansion of a CBC suite.
RECORD_OVERHEAD = 256
DYNAMIC_SMALL_RECORD = 1300
DYNAMIC_MAX_RECORD = 16383
DYNAMIC_BYTE_THRESHOLD = 1_000_000


def iter_record_lengths(buf: bytes | bytearray) -> Iterator[tuple[int, int]]:
Expand All @@ -54,12 +58,42 @@ def iter_record_lengths(buf: bytes | bytearray) -> Iterator[tuple[int, int]]:
i += 5 + length


def verify_dynamic_records(app_lengths: list[int]) -> bool:
'''Verify records ramp from single-segment to maximum-sized records.'''
small_limit = DYNAMIC_SMALL_RECORD + TLS12_GCM_OVERHEAD
max_limit = DYNAMIC_MAX_RECORD + TLS12_GCM_OVERHEAD
first_large = next((i for i, length in enumerate(app_lengths) if length > small_limit), None)

if first_large is None:
print('FAIL: dynamic sizing never ramped up to large TLS records')
return False

plaintext_before_ramp = sum(length - TLS12_GCM_OVERHEAD for length in app_lengths[:first_large])
if plaintext_before_ramp < DYNAMIC_BYTE_THRESHOLD:
print(
f'FAIL: dynamic sizing ramped after only {plaintext_before_ramp} plaintext bytes; '
f'expected at least {DYNAMIC_BYTE_THRESHOLD}')
return False

max_record = max(app_lengths)
if max_record > max_limit:
print(f'FAIL: a dynamic application-data record ({max_record}) exceeds the maximum ({max_limit})')
return False

print(
f'PASS: TLS records ramp from small to large after the dynamic threshold '
f'(first_large={first_large}, plaintext_before_ramp={plaintext_before_ramp}, max_record_len={max_record})')
return True


def main() -> int:
parser = argparse.ArgumentParser(description='Measure ATS TLS record sizes on a download.')
parser.add_argument('-p', '--port', type=int, required=True, help='ATS TLS port')
parser.add_argument('--host', default='ex.test', help='Host header / SNI')
parser.add_argument('--path', default='/obj', help='request path')
parser.add_argument('--max-record', type=int, required=True, help='configured proxy.config.ssl.max_record_size')
sizing = parser.add_mutually_exclusive_group(required=True)
sizing.add_argument('--max-record', type=int, help='positive proxy.config.ssl.max_record_size clamp')
sizing.add_argument('--dynamic', action='store_true', help='expect dynamic TLS record sizing')
parser.add_argument('--expect-bytes', type=int, required=True, help='expected response body length')
args = parser.parse_args()

Expand Down Expand Up @@ -132,18 +166,20 @@ def feed() -> bytes:

app_lengths = [length for content_type, length in iter_record_lengths(raw) if content_type == TLS_APPLICATION_DATA]
max_record = max(app_lengths) if app_lengths else 0
limit = args.max_record + RECORD_OVERHEAD

print(
f'app_data_records={len(app_lengths)} max_record_len={max_record} '
f'limit={limit} body_len={body_len} expect={args.expect_bytes}')
print(f'app_data_records={len(app_lengths)} max_record_len={max_record} body_len={body_len} expect={args.expect_bytes}')

if body_len != args.expect_bytes:
print(f'FAIL: body length {body_len} != expected {args.expect_bytes}')
return 1
if len(app_lengths) < 2:
print('FAIL: too few application-data records to judge clamping')
return 1
if args.dynamic:
return 0 if verify_dynamic_records(app_lengths) else 1

assert args.max_record is not None
limit = args.max_record + RECORD_OVERHEAD
if max_record > limit:
print(f'FAIL: an application-data record ({max_record}) exceeds the clamp + overhead ({limit})')
return 1
Expand Down