scp81: UI framing options, permissive TLS, drop the Apache-header mimicry (v2.2.14)

Listener Options block (applied at Start, persisted in localStorage, Reset to
defaults): HTTP framing - chunked body, chunk size (0 = one TLS record),
keep-alive, Connection header, compact headers, Next-URI (unchecked = omit);
script framing - indefinite/definite Command Scripting template, CR tag,
targeted app; link events (now parsed on the common start path, so every mode
honors them). scp81OptionsFromForm() is unit-tested.

TLS is automatic: 'auto' (min 1.0, max 1.2 + :@SECLEVEL=0) is the new
default, all six PSK suites are offered and OpenSSL negotiates the highest;
the negotiated version/cipher is logged (tls-handshake) and reported as
version_seen/cipher_seen in /api/scp81/status, and a handshake failing for a
TLS/cipher reason logs tls-handshake-failed (post-handshake record errors
stay tls-error). tls_version/cipher/keylog/answer_delay stay as API-only pins.

Dropped the Apache-style header mimicry completely: no Date/Server/
X-Powered-By, no Content-Length-before-Content-Type ordering, no Content-Type
on 204 - the minimal response set is X-Admin-Protocol (+ X-Admin-Next-URI /
Targeted-Application), Content-Type on 200s, and Transfer-Encoding or
Content-Length per the chunked flag. Docs, help (EN/RU), READMEs and the
AGENTS notes updated; SW cache otaman-v180.
This commit is contained in:
2026-09-17 08:50:40 +03:00
parent 404fdf5a1c
commit f4462c14e7
13 changed files with 517 additions and 80 deletions
+118 -17
View File
@@ -99,6 +99,26 @@ class PskTlsServerTest(unittest.TestCase):
sock.close()
raise
def test_tls_version_auto_is_permissive(self):
# TLS is automatic by default: accept TLS 1.0-1.2, OpenSSL picks the
# highest the card offers.
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK})
try:
self.assertEqual(srv.tls_version, 'auto')
self.assertEqual(srv.ctx.minimum_version, ssl.TLSVersion.TLSv1)
self.assertEqual(srv.ctx.maximum_version, ssl.TLSVersion.TLSv1_2)
finally:
srv.stop()
def test_tls_version_pin_remains_available(self):
srv = scp81.PskTlsServer('127.0.0.1', 0, psk_map={IDENT: PSK},
tls_version='1.0')
try:
self.assertEqual(srv.ctx.minimum_version, ssl.TLSVersion.TLSv1)
self.assertEqual(srv.ctx.maximum_version, ssl.TLSVersion.TLSv1)
finally:
srv.stop()
def test_handshake_and_204_session(self):
logs = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, identity=IDENT,
@@ -630,6 +650,77 @@ class BipControlTest(unittest.TestCase):
# the key never leaves the server
self.assertNotIn('psk_hex', listener)
def test_tls_defaults_and_framing_options(self):
try:
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'psk_identity': 'id-1',
'chunked': False, 'chunk_size': 100, 'keep_alive': False,
'compact_headers': True, 'conn_header': 'close', 'next_uri': '',
'script_template': 'definite', 'cr_tag': True,
'targeted_app': '//aid/A000000151000000', 'link_events': False,
})
self.assertTrue(resp['ok'], resp)
listener = resp['listener']
# TLS is automatic (no version/cipher setting in the PWA)
self.assertEqual(listener['tls_version'], 'auto')
self.assertIn('version_seen', listener)
self.assertIn('cipher_seen', listener)
self.assertEqual((listener['chunked'], listener['chunk_size'],
listener['keep_alive'], listener['compact_headers']),
(False, 100, False, True))
self.assertEqual(server._SCP81_SCRIPT_TEMPLATE, 'definite')
self.assertTrue(server._SCP81_SCRIPT_CR_TAG)
self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000')
self.assertEqual(server._SCP81_NEXT_URI, '')
self.assertFalse(server._SCP81_LINK_EVENTS)
self.assertEqual(resp['script_template'], 'definite')
self.assertTrue(resp['cr_tag'])
self.assertFalse(resp['link_events'])
self.assertNotIn('apache_headers', resp)
finally:
server._SCP81_SCRIPT_TEMPLATE = 'indefinite'
server._SCP81_SCRIPT_CR_TAG = False
server._SCP81_TARGETED_APP = None
server._SCP81_NEXT_URI = None
server._SCP81_LINK_EVENTS = True
def test_link_events_apply_to_every_mode(self):
# TS 102 223 7.5.11 events are a BIP-layer feature, not a TLS option.
resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect',
'host': '10.11.12.13', 'port': 10174,
'link_events': False})
self.assertTrue(resp['ok'], resp)
self.assertFalse(server._SCP81_LINK_EVENTS)
resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru',
'link_events': True})
self.assertTrue(resp['ok'], resp)
self.assertTrue(server._SCP81_LINK_EVENTS)
def test_tls_handshake_failure_is_logged(self):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls',
'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'psk_identity': 'id-1'})
self.assertTrue(resp['ok'], resp)
port = resp['listener']['port']
sock = socket.create_connection(('127.0.0.1', port), timeout=2)
try:
sock.sendall(b'this is not a tls hello')
sock.settimeout(2)
try:
sock.recv(64)
except OSError:
pass
finally:
sock.close()
kinds = []
for _ in range(40):
kinds = [e['kind'] for e in server._BIP.entries_after(0)]
if 'tls-handshake-failed' in kinds:
break
time.sleep(0.05)
self.assertIn('tls-handshake-failed', kinds)
def test_psk_map_start_and_update(self):
server._SCP81_PSKS = {}
server._SCP81_PSK_LEGACY = None
@@ -944,46 +1035,56 @@ class TargetedAppTest(unittest.TestCase):
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_apache_headers(self):
def test_response_headers_are_minimal(self):
# No Date/Server/X-Powered-By mimicry (dropped 2.2.14 - the reference
# server's extra headers earned nothing); only the dialog headers.
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = False
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(list(headers)[:4],
['Date', 'Server', 'X-Powered-By', 'X-Admin-Protocol'])
self.assertEqual(headers['Content-Length'], str(len(body)))
self.assertEqual(list(headers),
['X-Admin-Protocol', 'X-Admin-Next-URI', 'Content-Type'])
self.assertTrue(headers['X-Admin-Next-URI'].startswith('/api/scp81?req='))
out = scp81.build_http_response(status, 'OK', headers, body)
self.assertLess(out.index(b'Content-Length'),
out.index(b'Content-Type'))
self.assertIn(b'Content-Length', out)
for gone in (b'Date:', b'Server:', b'X-Powered-By'):
self.assertNotIn(gone, out)
finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_chunked_apache_has_no_content_length(self):
# The reference (RAM/HTTPOTA_test5.pcap, decryptable) sends chunked
# without Content-Length, Transfer-Encoding before Content-Type.
def test_session_end_204_has_only_the_admin_header(self):
reset_script([])
server._SCP81_SCRIPT_NEXT = 0
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertEqual(status, 204)
self.assertEqual(list(headers), ['X-Admin-Protocol'])
self.assertEqual(body, b'')
finally:
reset_script()
server._SCP81_SCRIPT_NEXT = 0
def test_chunked_response_has_no_content_length(self):
# A chunked response must not carry Content-Length (invalid HTTP - and
# the card rejects it); the Transfer-Encoding header is emitted by the
# HTTP builder, exactly once.
reset_script(['80CAFF2100'])
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_APACHE_HEADERS = True
server._SCP81_CHUNKED = True
try:
status, headers, body = server._scp81_script_responder(
'POST', '/api/scp81', {}, b'')
self.assertNotIn('Content-Length', headers)
self.assertEqual(headers['Transfer-Encoding'], 'chunked')
self.assertLess(list(headers).index('Transfer-Encoding'),
list(headers).index('Content-Type'))
out = scp81.build_http_response(status, 'OK', headers, body,
chunked=True, connection=None)
self.assertNotIn(b'Content-Length', out)
self.assertEqual(out.count(b'Transfer-Encoding'), 1)
self.assertEqual(out.count(b'Transfer-Encoding: chunked'), 1)
finally:
server._SCP81_APACHE_HEADERS = False
server._SCP81_CHUNKED = False
reset_script()
server._SCP81_SCRIPT_NEXT = 0