From 3b8bef9028fed9a4b4a1b02d28e03a3affd6483f Mon Sep 17 00:00:00 2001 From: Coden Date: Tue, 20 Oct 2026 21:06:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(source):=20=EC=9B=90=EA=B2=A9=20prefer?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=EC=9D=98=20=EC=95=94=ED=98=B8=ED=99=94=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=84=9C=EB=B2=84=20=EC=9E=90?= =?UTF-8?q?=EA=B8=B0=20=EB=B3=B4=EA=B3=A0=20=EB=8C=80=EC=8B=A0=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=EC=9D=98=20TLS=20=ED=98=91?= =?UTF-8?q?=EC=83=81=20=EA=B2=B0=EA=B3=BC=EB=A1=9C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인: - 네트워크를 지나는 prefer(PG prefer, MySQL PREFERRED) 연결은 sqlx 자체 prefer로 연 뒤 after_connect에서 서버에 세션의 TLS 여부를 물었고(pg_stat_ssl, Ssl_cipher), 답이 암호화면 connection-not-encrypted limitation을 내지 않았다. - 클라이언트 쪽 TLS만 거절하고 서버와는 TLS로 붙는 중간자 뒤에서는 서버의 답이 중간자-서버 구간을 말해 "암호화됨"이 되고, 클라이언트 평문 구간의 경보가 사라졌다. 가짜 서버는 답을 그냥 위조할 수도 있다. 평문으로 위조할 수 있는 답에 기댄 fail-open이다. 수정: - 네트워크를 지나는 prefer 연결은 sslmode만 require(MySQL REQUIRED)로 바꿔 먼저 연다. 열리면 그 풀을 그대로 쓴다. 이후 연결도 sqlx가 TLS를 강제한다. 서버가 TLS를 거절했을 때만 disable(DISABLED)로 다시 열고 "the server did not accept TLS, so the catalog was read over an unencrypted connection; <해결 방향>" limitation을 남긴다. 그 밖의 오류는 기존 접속 실패(종료 2)다. - 로컬(Unix 소켓·루프백)·disable·require 이상은 그대로다. 서버 확인 질의 (observe_transport, transport_from_answer, transport_from_cipher), needs_server_check, Transport::Unconfirmed와 "could not be confirmed" 문구를 지웠다. - README 두 언어의 판정 설명과 Scripts/verify-native-tls.py의 기대값을 맞췄다. 결정: 평문 재시도는 sqlx가 서버의 TLS 거절에 내는 Error::Tls("server does not support TLS")에서만 한다 (이유: sqlx 0.8.6에서 서버가 TLS를 받은 뒤의 핸드셰이크 실패는 Error::Io로 나와 결정의 Error::Tls 기준에 들지 않는다. 같은 Error::Tls의 나머지(서버 이름으로 쓸 수 없는 호스트, 클라이언트 인증서 오류)는 서버의 거절이 아니다. 여기서 평문으로 내려가면 사용자가 준 TLS 설정을 무시하고 limitation의 원인도 틀린다. 그래서 원칙 1(fail-closed)과 원칙 2(핸드셰이크 실패는 종료 2라는 기존 계약)를 따랐다. sqlx를 올려 문구가 바뀌면 거절도 종료 2가 되는 쪽으로만 틀린다.) 결정: 시도용 연결을 PgConnection/MySqlConnection::connect_with로 따로 열지 않고 풀의 첫 접속을 시도로 쓴다 (이유: 따로 열면 sqlx 풀이 하던 접속 거절·기동 중 오류의 재시도(acquire 상한 30초)가 원격 prefer에서만 사라져 기존 동작이 바뀐다(원칙 2). 성공하면 그 풀이 곧 require 풀이라 연결도 하나 덜 연다.) 검증: - cargo test -p schemagraph-source -p schemagraph-cli 통과. 결정 함수(성공은 require 풀, 거절만 평문, 그 밖의 오류는 실패), open 흐름, 문구의 단위 테스트를 더했다. 가짜 서버 통합 테스트 3개(PG 거절 뒤 새 평문 접속, PG 핸드셰이크 실패 뒤 재접속 없음, CLIENT_SSL 없는 MySQL 서버 뒤 새 평문 접속)를 더했다. 이 가운데 PG 거절과 MySQL 사례는 수정 전 코드에서 실패한다(거절된 접속으로 평문을 이어 간다). - Scripts/verify-native-tls.py에 중간자 재현 사례(downgrade-proxy-network-host)를 더했다. 수정 전 엔진은 limitation 없이 scan에 성공해 이 사례에서 실패한다. 수정 뒤 PG16 17사례와 MySQL 8.4·MariaDB 11.4 14사례가 모두 통과한다. - 수정 전후 엔진으로 PG fixture(basic.sql)를 넣은 평문·TLS 클러스터의 12사례와 SQLite basic을 scan했다. 종료 코드는 모두 같다. graph.json은 TLS를 끈 서버의 원격 기본 prefer 하나만 다르고, 그 차이는 전송 limitation 문구뿐이다. verify-bridge-facts 결과도 같다. clippy 경고는 기준선과 같다(고유 29건). Co-Authored-By: Claude Opus 5.5 --- README.ko.md | 14 +- README.md | 16 +- Scripts/verify-native-tls.py | 239 +++++++++++++-- engine/cli/tests/database_url_errors.rs | 187 +++++++++++- engine/source/src/mysql.rs | 149 +++++----- engine/source/src/postgres.rs | 151 +++++----- engine/source/src/transport.rs | 369 +++++++++++++++++------- 7 files changed, 832 insertions(+), 293 deletions(-) diff --git a/README.ko.md b/README.ko.md index a3cc344..cca63a0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -95,11 +95,15 @@ Unix 소켓이나 루프백 주소(`127.0.0.0/8`, `::1`, `localhost`)가 아닌 암호화하지 않은 연결로 카탈로그를 읽으면, catalog document와 그래프에 `connection-not-encrypted:` limitation이 남습니다. 이런 호스트에서는 `sslmode=disable`, `sslmode=allow`, `ssl-mode=DISABLED`가 항상 이 limitation을 -남깁니다. 기본 모드에서는 reader가 세션의 TLS 사용 여부를 서버에 -묻습니다(`pg_stat_ssl`, `Ssl_cipher`). 서버가 답하지 못하면 암호화를 확인하지 -못했다고 limitation에 적습니다. 커넥션 풀러나 프록시 뒤에서는 이 답이 풀러와 -서버 사이 연결을 말하므로, 암호화가 반드시 필요하면 verify 모드를 씁니다. 이 -limitation은 `catalog_complete`를 바꾸지 않습니다. +남깁니다. 기본 모드에서는 reader가 이런 호스트에 먼저 `sslmode=require`· +`ssl-mode=REQUIRED`처럼 접속하고, 서버가 TLS를 거절했을 때만 TLS 없이 다시 +접속합니다. 이때 limitation은 서버가 TLS를 받지 않았다고 적습니다. TLS +핸드셰이크가 실패하면 그대로 종료 코드 2로 끝납니다. reader는 서버가 알려 주는 +세션 정보가 아니라 자기가 직접 한 TLS 협상 결과로 판정하므로, reader 쪽 TLS만 +거절하는 프록시가 이 limitation을 숨길 수 없습니다. 이 limitation은 reader 자신의 +연결을 말하며, 커넥션 풀러나 프록시가 DB로 잇는 연결은 다루지 않습니다. 암호화가 +반드시 필요하면 verify 모드를 씁니다. 이 limitation은 `catalog_complete`를 바꾸지 +않습니다. JDBC 기본 수집 범위는 드라이버가 제공하는 스키마·테이블·컬럼·키·인덱스·루틴 메타데이터입니다. 본문과 사용 통계의 수집 범위는 DB 방언, 권한, 사용 가능한 diff --git a/README.md b/README.md index 74a25c9..d0aa8c2 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,16 @@ When a catalog is read over an unencrypted connection to a host other than a Unix socket or a loopback address (`127.0.0.0/8`, `::1`, or `localhost`), the catalog document and graph record a `connection-not-encrypted:` limitation. For such a host, `sslmode=disable`, `sslmode=allow`, and `ssl-mode=DISABLED` -always record it. In the default mode the reader asks the server whether the -session uses TLS (`pg_stat_ssl`, `Ssl_cipher`); if the server cannot answer, -the limitation says that encryption could not be confirmed. Behind a -connection pooler or proxy, that answer describes the pooler's connection to -the server, so use a verify mode when the connection must be encrypted. The -limitation does not change `catalog_complete`. +always record it. In the default mode the reader first connects to such a +host as `sslmode=require` / `ssl-mode=REQUIRED` does, and reconnects without +TLS only when the server refuses TLS; the limitation then says that the server +did not accept TLS. A failed TLS handshake still ends the scan with exit code +2. The reader decides from its own TLS negotiation, not from the server's +report of the session, so a proxy that refuses TLS toward the reader cannot +hide the limitation. The limitation describes the reader's own connection, +not a pooler's or proxy's onward connection to the database; use a verify +mode when the connection must be encrypted. The limitation does not change +`catalog_complete`. The JDBC baseline collects schemas, tables, columns, keys, indexes, and routine metadata exposed by the driver. Body and usage collection depend on diff --git a/Scripts/verify-native-tls.py b/Scripts/verify-native-tls.py index 7426934..fedcb1d 100644 --- a/Scripts/verify-native-tls.py +++ b/Scripts/verify-native-tls.py @@ -14,13 +14,18 @@ - require와 기본 prefer는 TLS로 성공하고, disable은 hostssl 서버가 거절한다(종료 2). - TLS를 끈 서버에서 require는 종료 2이고, 기본 prefer는 평문으로 성공한다. - 루프백 평문 연결에는 `connection-not-encrypted:` limitation이 없다. -- 네트워크 호스트로 분류되는 연결은 서버가 본 전송 상태를 따른다. TLS 서버에서는 기본 - prefer가 pg_stat_ssl로 TLS를 확인해 limitation이 없고, TLS를 끈 서버에서는 평문 관측 - limitation이 남는다. 확인 질의가 실패했을 때의 문구("could not be confirmed")와 구분한다. +- 네트워크 호스트로 분류되는 연결은 reader가 직접 한 TLS 협상 결과를 따른다. 기본 prefer는 + TLS를 요구해 먼저 붙고, 서버가 TLS를 거절했을 때만 평문으로 다시 붙어 그 사실("the server + did not accept TLS")을 limitation에 남긴다. TLS 서버에서는 limitation이 없고, TLS를 끈 + 서버에서는 거절 limitation이 남는다. disable은 모드 문구("the catalog was read over an + unencrypted connection")를 남기므로 두 문구를 가른다. +- 공격 재현: 클라이언트 쪽 TLS만 거절하고 서버와는 TLS로 붙는 중간자를 TLS 서버 앞에 둔다. + 서버가 보는 세션은 TLS라 서버의 자기 보고(pg_stat_ssl)는 암호화됐다고 답하지만, reader의 + 연결은 평문이다. 기본 prefer가 거절 limitation을 남겨야 한다. 호스트는 `0.0.0.0`을 쓴다. reader는 루프백 주소가 아니라서 네트워크로 보지만, Linux·macOS 커널은 0.0.0.0으로 가는 접속을 자기 호스트로 보내 루프백 리스너에 닿는다. 그래서 클러스터를 - 외부에 열지 않고도 원격 판정(서버 확인 질의 포함)을 탄다. `127.1` 같은 축약 표기는 rustls가 - 서버 이름으로 받지 않아 TLS 서버에는 쓸 수 없다. + 외부에 열지 않고도 원격 판정(TLS를 요구한 첫 접속 포함)을 탄다. `127.1` 같은 축약 표기는 + rustls가 서버 이름으로 받지 않아 TLS 서버에는 쓸 수 없다. 사용자 PG 설정이 결과를 바꾸지 않도록 PG로 시작하는 환경 변수(PGSSLMODE 등)를 지운 환경으로 돌린다. 비밀번호는 0600 PGPASSFILE로만 넘겨 URL·argv에 싣지 않는다. @@ -28,11 +33,9 @@ `--mysql-environments`(정확도 하네스와 같은 고정 이미지 목록)를 주면 MySQL·MariaDB도 Docker로 검증한다. 엔진마다 기본 설정(자체 서명 인증서로 TLS를 켠다) 서버와 TLS를 끈 서버를 루프백 포트로 띄운다. 네트워크 호스트(`0.0.0.0`)의 기본 PREFERRED 연결은 MySQL -reader가 `SHOW SESSION STATUS LIKE 'Ssl_cipher'`로 서버에 물어 판정하는 유일한 경로다. -TLS 서버에서는 limitation이 없어야 하고, TLS를 끈 서버에서는 평문 관측 limitation이 남아야 -한다. 텍스트 프로토콜 실행이나 BINARY 열 디코드가 sqlx 갱신 등으로 어긋나면 "could not be -confirmed" 문구가 대신 붙어 실패한다. 루프백 fixture(`127.0.0.1`)만으로는 이 경로를 타지 -않는다. +reader가 REQUIRED로 먼저 붙어 보는 경로다. TLS 서버에서는 limitation이 없어야 하고, TLS를 +끈 서버에서는 거절 limitation이 남아야 한다. 루프백 fixture(`127.0.0.1`)만으로는 이 경로를 +타지 않는다. """ import argparse @@ -41,26 +44,38 @@ import os from pathlib import Path import secrets +import select import shutil import socket +import ssl import subprocess import sys import tempfile +import threading import urllib.parse ROOT = Path(__file__).resolve().parents[1] # 평문 원격 연결을 알리는 limitation 접두사다(엔진 계약). LIMITATION_PREFIX = 'connection-not-encrypted:' -# 서버가 평문이라고 답했을 때의 문구다. 확인 질의 실패("could not be confirmed")와 가른다. -OBSERVED_PLAINTEXT = 'read over an unencrypted connection' +# 모드가 TLS를 시도하지 않은 평문 연결(disable·DISABLED)의 문구다. +PLAINTEXT_BY_MODE = f'{LIMITATION_PREFIX} the catalog was read over an unencrypted connection;' +# 기본 prefer에서 서버가 TLS를 거절해 평문으로 다시 붙은 연결의 문구다. +PLAINTEXT_AFTER_REFUSAL = (f'{LIMITATION_PREFIX} the server did not accept TLS, so the catalog ' + 'was read over an unencrypted connection;') # reader가 네트워크 호스트로 분류하지만 커널이 자기 호스트로 보내는 주소다. NETWORK_HOST = '0.0.0.0' # 연결마다 확인하는 테이블이다. 그래프에 이 정점이 있으면 카탈로그를 실제로 읽은 것이다. PROBE_TABLE = 'public.tls_probe' -# 사례별 전송 limitation 기대값: 없음, 또는 서버가 답한 평문 관측 하나. +# 사례별 전송 limitation 기대값: 없음, 모드로 끈 평문 하나, 또는 서버가 거절한 평문 하나. EXPECT_NONE = 'none' EXPECT_PLAINTEXT = 'plaintext' +EXPECT_REFUSED = 'refused' +EXPECTED_LIMITATION = {EXPECT_PLAINTEXT: PLAINTEXT_BY_MODE, EXPECT_REFUSED: PLAINTEXT_AFTER_REFUSAL} +# PostgreSQL SSLRequest(길이 8, 요청 코드 80877103)다. 중간자 재현이 TLS 요청을 알아본다. +PG_SSL_REQUEST = bytes([0x00, 0x00, 0x00, 0x08, 0x04, 0xD2, 0x16, 0x2F]) +# 중간자 중계가 한쪽을 기다리는 상한(초). scan 하나보다 넉넉하다. +RELAY_IDLE_SECONDS = 60 # MySQL·MariaDB 사례가 쓸 데이터베이스다. Scripts/wait-mysql-ready.py가 이 이름으로 최종 # 서버의 TCP 준비를 확인한다(fixture 하네스와 같은 절차). @@ -231,11 +246,14 @@ def transport_limitations(graph): def check_transport(label, found, expected): - """전송 limitation이 기대와 같은지 본다. 평문 관측은 확인 실패 문구와 구분한다.""" - if expected == EXPECT_NONE and found: - raise AssertionError(f'{label}: expected no transport limitation, got {found}') - if expected == EXPECT_PLAINTEXT and (len(found) != 1 or OBSERVED_PLAINTEXT not in found[0]): - raise AssertionError(f'{label}: expected one observed-plaintext limitation, got {found}') + """전송 limitation이 기대와 같은지 본다. 모드로 끈 평문과 서버가 거절한 평문을 문구로 가른다.""" + if expected == EXPECT_NONE: + if found: + raise AssertionError(f'{label}: expected no transport limitation, got {found}') + return + prefix = EXPECTED_LIMITATION[expected] + if len(found) != 1 or not found[0].startswith(prefix): + raise AssertionError(f'{label}: expected one limitation starting with {prefix!r}, got {found}') def check_success(label, graph, expected, hidden, probe=PROBE_TABLE): @@ -292,27 +310,195 @@ def tls_cluster_cases(port, certificates): def plain_cluster_cases(port): - """TLS를 끈 서버의 사례. 루프백은 limitation이 없고, 네트워크 호스트는 평문 관측이 남는다.""" + """TLS를 끈 서버의 사례. 루프백은 limitation이 없다. 네트워크 호스트는 기본 prefer면 거절 + limitation이, disable이면 모드 limitation이 남는다.""" return [ ('require-without-server-tls', scan_url('127.0.0.1', port, {'sslmode': 'require'}), 2, 'server does not support TLS', None), ('default-prefer-loopback', scan_url('127.0.0.1', port, {}), 0, None, EXPECT_NONE), ('default-prefer-localhost', scan_url('localhost', port, {}), 0, None, EXPECT_NONE), ('disable-loopback', scan_url('127.0.0.1', port, {'sslmode': 'disable'}), 0, None, EXPECT_NONE), - ('default-prefer-network-host', scan_url(NETWORK_HOST, port, {}), 0, None, EXPECT_PLAINTEXT), + ('default-prefer-network-host', scan_url(NETWORK_HOST, port, {}), 0, None, EXPECT_REFUSED), ('disable-network-host', scan_url(NETWORK_HOST, port, {'sslmode': 'disable'}), 0, None, EXPECT_PLAINTEXT), ] +def recv_exact(sock, size): + """`size`바이트를 받는다. 상대가 먼저 닫으면 그때까지 받은 바이트를 돌려준다.""" + data = b'' + while len(data) < size: + chunk = sock.recv(size - len(data)) + if not chunk: + break + data += chunk + return data + + +class UpstreamTls: + """중간자와 서버 사이의 TLS 구간. 서버 인증서는 시험 CA와 인증서 이름(localhost)으로 검증한다. + + MemoryBIO로 한 스레드가 읽기와 쓰기를 번갈아 하므로 SSL 객체를 두 스레드가 함께 쓰지 않는다. + """ + + def __init__(self, sock, cafile): + context = ssl.create_default_context(cafile=str(cafile)) + self.sock = sock + self.incoming, self.outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + self.tls = context.wrap_bio(self.incoming, self.outgoing, server_hostname='localhost') + self.handshake() + + def flush(self): + """TLS가 보낼 바이트를 서버로 보낸다.""" + data = self.outgoing.read() + if data: + self.sock.sendall(data) + + def handshake(self): + """서버와 TLS 핸드셰이크를 끝낸다.""" + while True: + try: + self.tls.do_handshake() + break + except ssl.SSLWantReadError: + self.flush() + data = self.sock.recv(65536) + if not data: + raise RuntimeError('the server closed the connection during the TLS handshake') + self.incoming.write(data) + self.flush() + + def send(self, data): + """평문을 암호화해 서버로 보낸다.""" + self.tls.write(data) + self.flush() + + def receive(self, data): + """서버가 보낸 TLS 바이트를 넣고 풀린 평문을 돌려준다. 레코드가 덜 왔으면 빈 바이트다.""" + self.incoming.write(data) + plain = [] + while True: + try: + chunk = self.tls.read(65536) + except ssl.SSLWantReadError: + break + if not chunk: + break # 서버가 TLS를 닫았다(close_notify). 이어지는 소켓 EOF가 중계를 끝낸다. + plain.append(chunk) + self.flush() + return b''.join(plain) + + +def relay(client, upstream): + """클라이언트 평문과 서버 TLS 사이에서 바이트를 옮긴다. 어느 쪽이든 닫으면 끝낸다.""" + while True: + readable, _, _ = select.select([client, upstream.sock], [], [], RELAY_IDLE_SECONDS) + if not readable: + raise RuntimeError(f'the relay was idle for {RELAY_IDLE_SECONDS} seconds') + for source in readable: + data = source.recv(65536) + if not data: + return + if source is client: + upstream.send(data) + else: + client.sendall(upstream.receive(data)) + + +class DowngradeProxy: + """클라이언트 쪽 TLS만 거절하고 서버와는 TLS로 붙는 중간자(공격 재현). + + 클라이언트의 SSLRequest에 `N`으로 답해 클라이언트 구간을 평문으로 만들고, 서버에는 자기가 + SSLRequest를 보내 TLS로 붙어 바이트를 옮긴다. 서버가 보는 세션은 TLS라 pg_stat_ssl은 + ssl = true를 답한다. 그 답으로 판정하는 reader는 클라이언트 평문 구간의 limitation을 잃는다. + """ + + def __init__(self, upstream_port, cafile): + self.upstream_port = upstream_port + self.cafile = cafile + self.listener = socket.create_server(('127.0.0.1', 0)) + self.port = self.listener.getsockname()[1] + self.errors = [] + self.thread = threading.Thread(target=self.serve, daemon=True) + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, *exc_info): + self.listener.close() + self.thread.join(timeout=10) + + def serve(self): + """접속마다 중계 스레드를 띄운다. __exit__가 리스너를 닫으면 accept가 OSError로 끝난다.""" + while True: + try: + client, _ = self.listener.accept() + except OSError: + return + threading.Thread(target=self.handle, args=(client,), daemon=True).start() + + def handle(self, client): + """중계 하나를 돌린다. 실패 원인은 모아 두었다가 사례 판정에서 드러낸다.""" + try: + with client: + client.settimeout(RELAY_IDLE_SECONDS) + self.downgrade(client) + except Exception as error: # 스레드 밖으로 원인을 옮긴다(사례 판정이 실패로 낸다). + self.errors.append(f'{type(error).__name__}: {error}') + + def downgrade(self, client): + """클라이언트의 TLS 요청을 거절하고, 평문으로 이어지는 접속만 서버의 TLS 세션에 잇는다.""" + first = recv_exact(client, len(PG_SSL_REQUEST)) + if first == PG_SSL_REQUEST: + client.sendall(b'N') + # TLS를 요구한 접속은 여기서 닫히고, 서버 자기 보고에 기대는 prefer는 같은 접속으로 + # 평문 StartupMessage를 잇는다. + first = recv_exact(client, len(PG_SSL_REQUEST)) + if len(first) < len(PG_SSL_REQUEST): + return + with socket.create_connection(('127.0.0.1', self.upstream_port), timeout=RELAY_IDLE_SECONDS) as sock: + sock.sendall(PG_SSL_REQUEST) + if recv_exact(sock, 1) != b'S': + raise RuntimeError('the server refused TLS to the proxy') + upstream = UpstreamTls(sock, self.cafile) + upstream.send(first) + try: + relay(client, upstream) + except (ConnectionResetError, BrokenPipeError): + return # scan이 끝나며 접속을 끊었다. 정상 종료와 같다. + + +def check_downgrade_proxy(args, work, env, port, cafile, hidden): + """TLS 서버 앞에 클라이언트 쪽 TLS만 거절하는 중간자를 두고 기본 prefer로 scan한다. + + 서버는 TLS 세션을 보지만 reader의 연결은 평문이다. reader가 직접 한 TLS 협상으로 판정하면 + 거절 limitation이 남고, 서버의 자기 보고(pg_stat_ssl)로 판정하면 limitation이 사라져 실패한다. + """ + with DowngradeProxy(port, cafile) as proxy: + case = ('downgrade-proxy-network-host', scan_url(NETWORK_HOST, proxy.port, {}), 0, None, + EXPECT_REFUSED) + concealed = {**hidden, 'limitation': [*hidden['limitation'], f':{proxy.port}']} + try: + result = check_case(args.engine, work, env, case, concealed) + except AssertionError as error: + raise AssertionError(f'{error}; relay errors: {proxy.errors}') from error + if proxy.errors: + raise AssertionError(f'downgrade-proxy-network-host: relay failed: {proxy.errors}') + return result + + def run_cluster(args, work, name, tls, password, cases_for): - """클러스터 하나를 띄워 사례를 모두 돌리고 멈춘다.""" + """클러스터 하나를 띄워 사례를 모두 돌리고 멈춘다. TLS 클러스터에서는 중간자 사례도 돌린다.""" env = args.environment data, port = start_cluster(args.postgres_bin, work, name, tls, password, env) try: create_probe_table(args.postgres_bin, port, env) # 비밀번호는 오류에도 limitation에도, 호스트·포트는 limitation에 실리면 안 된다. hidden = {'stderr': [password], 'limitation': [password, NETWORK_HOST, f':{port}']} - return [check_case(args.engine, work, env, case, hidden) for case in cases_for(port)] + results = [check_case(args.engine, work, env, case, hidden) for case in cases_for(port)] + if tls: + results.append(check_downgrade_proxy(args, work, env, port, tls['ca'], hidden)) + return results finally: stop_cluster(args.postgres_bin, data, env) @@ -384,8 +570,8 @@ def mysql_url(host, port, parameters): def mysql_tls_cases(port): - """TLS를 켠 서버의 사례. 네트워크 호스트의 기본 PREFERRED는 Ssl_cipher로 TLS를 확인해 - limitation이 없다. DISABLED는 모드로 평문을 알아 서버에 묻지 않고 남긴다.""" + """TLS를 켠 서버의 사례. 네트워크 호스트의 기본 PREFERRED는 REQUIRED로 먼저 붙어 TLS로 + 읽으므로 limitation이 없다. DISABLED는 모드 limitation을 남긴다.""" return [ ('preferred-network-host', mysql_url(NETWORK_HOST, port, {}), 0, None, EXPECT_NONE), ('required-network-host', mysql_url(NETWORK_HOST, port, {'ssl-mode': 'REQUIRED'}), 0, None, EXPECT_NONE), @@ -396,9 +582,10 @@ def mysql_tls_cases(port): def mysql_plain_cases(port): - """TLS를 끈 서버의 사례. 네트워크 호스트의 기본 PREFERRED는 빈 Ssl_cipher를 평문으로 읽는다.""" + """TLS를 끈 서버의 사례. 네트워크 호스트의 기본 PREFERRED는 서버가 TLS를 거절해 평문으로 + 다시 붙으므로 거절 limitation을 남긴다.""" return [ - ('preferred-network-host', mysql_url(NETWORK_HOST, port, {}), 0, None, EXPECT_PLAINTEXT), + ('preferred-network-host', mysql_url(NETWORK_HOST, port, {}), 0, None, EXPECT_REFUSED), ('required-without-server-tls', mysql_url('127.0.0.1', port, {'ssl-mode': 'REQUIRED'}), 2, 'server does not support TLS', None), ('preferred-loopback', mysql_url('127.0.0.1', port, {}), 0, None, EXPECT_NONE), diff --git a/engine/cli/tests/database_url_errors.rs b/engine/cli/tests/database_url_errors.rs index 9404e05..862ee85 100644 --- a/engine/cli/tests/database_url_errors.rs +++ b/engine/cli/tests/database_url_errors.rs @@ -5,13 +5,17 @@ //! //! PostgreSQL·MySQL URL은 가짜 서버로 접속의 첫 메시지를 본다. 기본 접속이 TLS를 먼저 //! 청하는지, `sslmode=disable`·`ssl-mode=DISABLED`가 그 요청을 끄는지, TLS를 요구한 접속이 -//! 서버가 거절하면 종료 2로 끝나는지를 서버 없이 고정한다. +//! 서버가 거절하면 종료 2로 끝나는지를 서버 없이 고정한다. 네트워크 호스트의 기본 접속은 +//! 접속 순서까지 본다. TLS를 요구해 먼저 붙고, 서버가 TLS를 거절했을 때만 새 평문 접속으로 +//! 다시 붙으며, 핸드셰이크가 실패하면 다시 붙지 않는다. use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::Path; use std::process::{Command, Output}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; @@ -148,6 +152,13 @@ const MYSQL_SSL_REQUEST_LEN: usize = 32; /// 가짜 서버가 접속과 첫 메시지를 기다리는 상한. 클라이언트가 오지 않아도 테스트가 멈추지 않는다. const FAKE_SERVER_TIMEOUT: Duration = Duration::from_secs(30); +/// reader가 네트워크를 지나는 접속으로 분류하는 주소. 루프백 주소가 아니지만 Linux·macOS +/// 커널은 0.0.0.0으로 가는 접속을 자기 호스트로 보내므로, 루프백 가짜 서버로 원격 경로를 탄다. +const NETWORK_HOST: &str = "0.0.0.0"; + +/// 가짜 서버가 접속 하나와 나누는 교환. 클라이언트가 보낸 바이트를 돌려준다. +type Exchange = Box Vec + Send>; + /// libpq 환경 변수(`PGSSLMODE` 등)를 지우고 scan한다. /// /// sqlx는 이 변수로 기본 sslmode·호스트를 바꾼다. 개발자 환경과 무관하게 URL만으로 @@ -183,6 +194,68 @@ where (port, server) } +/// 루프백에 가짜 서버를 띄워 들어오는 접속마다 `exchanges`를 차례로 돌린다. 교환이 바닥난 +/// 뒤의 접속은 첫 8바이트만 받는다. scan이 끝났다고 `finished`가 켜진 뒤 대기열이 비면 멈추고, +/// 접속마다 받은 바이트를 순서대로 돌려준다. 접속 수로 reader가 다시 붙었는지를 판정한다. +fn fake_server_sessions( + exchanges: Vec, + finished: Arc, +) -> (u16, JoinHandle>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let server = thread::spawn(move || { + let mut exchanges = exchanges.into_iter(); + let mut sessions = Vec::new(); + let deadline = Instant::now() + FAKE_SERVER_TIMEOUT; + while let Some(mut stream) = next_session(&listener, &finished, deadline) { + let exchange = exchanges.next().unwrap_or_else(|| Box::new(first_message)); + sessions.push(exchange(&mut stream)); + } + sessions + }); + (port, server) +} + +/// 다음 접속을 받는다. `finished`를 accept보다 먼저 읽으므로, scan이 끝난 뒤에 본 빈 +/// 대기열이면 더 올 접속이 없다(None). 상한이 지나도 None이다. +fn next_session( + listener: &TcpListener, + finished: &AtomicBool, + deadline: Instant, +) -> Option { + loop { + let done = finished.load(Ordering::SeqCst) || Instant::now() >= deadline; + match listener.accept() { + Ok((stream, _)) => return Some(blocking_with_timeout(stream)), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if done { + return None; + } + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("fake server could not accept a connection: {error}"), + } + } +} + +/// 예상하지 않은 접속의 교환. 무엇을 보냈는지 실패 메시지에 보이도록 첫 8바이트를 받는다. +fn first_message(stream: &mut TcpStream) -> Vec { + read_up_to(stream, PG_SSL_REQUEST.len()) +} + +/// 가짜 서버 앞에서 scan을 돌리고, scan이 끝난 뒤 서버가 접속마다 받은 바이트를 모은다. +fn scan_against( + exchanges: Vec, + scan: impl FnOnce(u16) -> Output, +) -> (Output, Vec>) { + let finished = Arc::new(AtomicBool::new(false)); + let (port, server) = fake_server_sessions(exchanges, Arc::clone(&finished)); + let output = scan(port); + finished.store(true, Ordering::SeqCst); + (output, server.join().unwrap()) +} + /// 상한 안에 들어온 접속 하나를 받는다. 클라이언트가 오지 않으면 None이다. fn accept_within(listener: &TcpListener, timeout: Duration) -> Option { listener.set_nonblocking(true).unwrap(); @@ -239,17 +312,43 @@ fn postgres_exchange(reply: Option) -> impl FnOnce(&mut TcpStream) -> Vec Vec { - stream - .write_all(&mysql_packet(0, MYSQL_HANDSHAKE_V10)) - .expect("send HandshakeV10"); - let mut packet = read_up_to(stream, 4); - if let [low, middle, high, _] = packet[..] { - let length = u32::from_le_bytes([low, middle, high, 0]) as usize; - packet.extend(read_up_to(stream, length.min(4096))); + mysql_exchange_with(MYSQL_HANDSHAKE_V10.to_vec())(stream) +} + +/// HandshakeV10 `handshake`를 보내고 클라이언트의 첫 패킷(4바이트 머리와 페이로드)을 읽는다. +/// 클라이언트가 답하지 않고 접속을 닫으면 빈 바이트다. +fn mysql_exchange_with(handshake: Vec) -> impl FnOnce(&mut TcpStream) -> Vec + Send { + move |stream| { + stream + .write_all(&mysql_packet(0, &handshake)) + .expect("send HandshakeV10"); + let mut packet = read_up_to(stream, 4); + if let [low, middle, high, _] = packet[..] { + let length = u32::from_le_bytes([low, middle, high, 0]) as usize; + packet.extend(read_up_to(stream, length.min(4096))); + } + packet } - packet +} + +/// CLIENT_SSL을 광고하지 않는 HandshakeV10. TLS를 끈 서버나, 그 비트를 지운 중간자가 보내는 +/// 모양이다. 프로토콜 버전 1바이트, NUL로 끝나는 서버 버전, 접속 id 4바이트, 인증 자료 8바이트, +/// 채움 1바이트 뒤에 능력 비트 하위 2바이트(리틀 엔디언)가 온다. +fn mysql_handshake_without_tls() -> Vec { + let mut handshake = MYSQL_HANDSHAKE_V10.to_vec(); + let version_end = 1 + handshake[1..].iter().position(|&byte| byte == 0).unwrap(); + let at = version_end + 1 + 4 + 8 + 1; + let lower = u16::from_le_bytes([handshake[at], handshake[at + 1]]); + assert_ne!( + u32::from(lower) & MYSQL_CLIENT_SSL, + 0, + "the sample must offer TLS" + ); + let without_tls = lower & !(MYSQL_CLIENT_SSL as u16); + handshake[at..at + 2].copy_from_slice(&without_tls.to_le_bytes()); + handshake } /// MySQL 패킷 머리(3바이트 길이와 1바이트 순번)를 페이로드 앞에 붙인다. @@ -334,3 +433,71 @@ fn mysql_ssl_mode_disabled_answers_without_tls() { assert!(after_fixed_fields.starts_with(b"reader\0"), "{packet:?}"); assert_redacted_error(&output, "database scan failed", &["FAKEPW8"]); } + +/// 네트워크 호스트의 기본 PostgreSQL URL은 TLS를 요구해 먼저 붙는다. 서버가 거절하면(`N`) 그 +/// 접속에 아무것도 더 보내지 않고 닫은 뒤, TLS를 청하지 않는 새 접속(StartupMessage)으로 다시 +/// 붙는다. 거절한 접속에서 평문으로 이어 가지 않으므로, 이후 연결이 평문이라는 사실을 서버에 +/// 묻지 않고 reader가 안다(서버의 답은 평문 구간에서 위조될 수 있다). +#[test] +fn remote_default_postgres_url_reconnects_without_tls_after_the_server_refuses_it() { + let dir = tempfile::tempdir().unwrap(); + let refuse: Exchange = Box::new(|stream| { + let mut received = postgres_exchange(Some(b'N'))(stream); + // 거절 뒤 같은 접속으로 보낸 바이트가 있으면 함께 남긴다 — 있으면 안 된다. + received.extend(read_up_to(stream, PG_SSL_REQUEST.len())); + received + }); + let exchanges = vec![refuse, Box::new(postgres_exchange(None)) as Exchange]; + let (output, sessions) = scan_against(exchanges, |port| { + let url = format!("postgres://reader:FAKEPW7@{NETWORK_HOST}:{port}/app"); + scan_without_libpq_env(dir.path(), &url) + }); + assert_eq!(sessions.len(), 2, "{sessions:?}"); + assert_eq!(sessions[0], PG_SSL_REQUEST, "{sessions:?}"); + assert_eq!(sessions[1].len(), 8, "{sessions:?}"); + assert_eq!(sessions[1][4..], PG_PROTOCOL_3_0, "{sessions:?}"); + assert_redacted_error(&output, "database scan failed", &["FAKEPW7"]); +} + +/// 서버가 TLS를 받겠다고 한 뒤(`S`) 핸드셰이크가 실패하면 평문으로 다시 붙지 않고 종료 2로 +/// 끝난다. 평문 재접속은 서버가 TLS를 거절했을 때뿐이다(sqlx 자체 prefer와 같다). +#[test] +fn remote_default_postgres_url_does_not_retry_without_tls_after_a_failed_handshake() { + let dir = tempfile::tempdir().unwrap(); + let exchanges = vec![Box::new(postgres_exchange(Some(b'S'))) as Exchange]; + let (output, sessions) = scan_against(exchanges, |port| { + let url = format!("postgres://reader:FAKEPW7@{NETWORK_HOST}:{port}/app"); + scan_without_libpq_env(dir.path(), &url) + }); + assert_eq!(sessions, [PG_SSL_REQUEST.to_vec()]); + assert_redacted_error(&output, "database scan failed", &["FAKEPW7"]); +} + +/// 네트워크 호스트의 기본 MySQL URL도 TLS를 요구해 먼저 붙는다. 서버가 CLIENT_SSL을 광고하지 +/// 않으면 인증 응답 없이 그 접속을 닫고, CLIENT_SSL을 끈 새 접속으로 다시 붙어 평문으로 인증한다. +#[test] +fn remote_default_mysql_url_reconnects_without_tls_when_the_server_lacks_it() { + let dir = tempfile::tempdir().unwrap(); + let handshake = mysql_handshake_without_tls(); + let exchanges: Vec = vec![ + Box::new(mysql_exchange_with(handshake.clone())), + Box::new(mysql_exchange_with(handshake)), + ]; + let (output, sessions) = scan_against(exchanges, |port| { + scan( + dir.path(), + &format!("mysql://reader:FAKEPW8@{NETWORK_HOST}:{port}/app"), + ) + }); + assert_eq!(sessions.len(), 2, "{sessions:?}"); + assert!( + sessions[0].is_empty(), + "answered without TLS: {:?}", + sessions[0] + ); + let packet = &sessions[1]; + assert_eq!(mysql_client_capabilities(packet) & MYSQL_CLIENT_SSL, 0); + let after_fixed_fields = &packet[4 + MYSQL_SSL_REQUEST_LEN..]; + assert!(after_fixed_fields.starts_with(b"reader\0"), "{packet:?}"); + assert_redacted_error(&output, "database scan failed", &["FAKEPW8"]); +} diff --git a/engine/source/src/mysql.rs b/engine/source/src/mysql.rs index 0d1c11b..47d548c 100644 --- a/engine/source/src/mysql.rs +++ b/engine/source/src/mysql.rs @@ -15,7 +15,7 @@ use std::str::FromStr; use std::sync::{Arc, OnceLock}; use crate::document::*; -use crate::transport::{is_local_endpoint, TlsPolicy, Transport, TransportCheck}; +use crate::transport::{is_local_endpoint, ConnectMode, TlsPolicy, Transport, TransportCheck}; use crate::SourceError; /// 평문 원격 연결 limitation의 해결 방향. 공개 CA가 아닌 관리형 DB는 CA 파일이 필요하다. @@ -41,31 +41,11 @@ pub async fn read(url: &str) -> Result { // URL에 데이터베이스가 있으면 그것만 — 없으면 사용자 스키마 전부. let scope_db = options.get_database().map(|d| d.to_owned()); - let transport = transport_check(&options); - let connection_transport = transport.clone(); // after_connect는 연결마다 돈다 — 어느 연결이든 고정에 실패하면 문서 전체의 한계다. // 원인은 처음 실패한 연결의 것을 남긴다. let read_only_cause = Arc::new(OnceLock::::new()); - let first_cause = Arc::clone(&read_only_cause); - let pool = MySqlPoolOptions::new() - .after_connect(move |conn, _| { - let first_cause = Arc::clone(&first_cause); - let transport = connection_transport.clone(); - Box::pin(async move { - // 둘 다 실패해도 스캔은 계속하되(reader는 카탈로그 SELECT만 보낸다) - // 세션이 쓰기 가능이라는 사실과 그 원인은 limitation으로 남긴다. - if let Some(cause) = pin_read_only_session(conn).await { - first_cause.get_or_init(|| cause); - } - // 전송 확인도 같은 이유로 오류를 돌려주지 않고 limitation으로 남긴다. - if transport.needs_server_check() { - transport.record(observe_transport(conn).await); - } - Ok(()) - }) - }) - .connect_with(options) + let (pool, transport) = connect(&options, &read_only_cause) .await .map_err(|e| SourceError::Connect(format!("mysql 접속 실패: {e}")))?; @@ -116,28 +96,48 @@ fn transport_check(options: &MySqlConnectOptions) -> TransportCheck { ) } -/// 서버가 본 이 세션의 TLS 암호 모음을 묻는다. 빈 값이면 평문이다. `SHOW STATUS`는 -/// prepared statement로 허용되는 문장(MySQL 문서의 목록) 밖일 수 있어 텍스트 -/// 프로토콜(`&str` 실행)로 보낸다. 커넥션 풀러나 프록시 뒤라면 이 답은 풀러와 서버 사이 -/// 연결을 말한다. -async fn observe_transport(conn: &mut MySqlConnection) -> Transport { - let row = - sqlx::Executor::fetch_optional(&mut *conn, "SHOW SESSION STATUS LIKE 'Ssl_cipher'").await; - // 둘째 열(Value)에 BINARY 플래그가 붙으면 sqlx가 String 디코드를 거절하므로 바이트로 읽는다. - let cipher = row.and_then(|row| row.map(|row| row.try_get::, _>(1)).transpose()); - transport_from_cipher(cipher) +/// 풀을 열고 카탈로그를 읽을 연결의 전송을 함께 돌려준다. 네트워크를 지나는 PREFERRED +/// 연결은 REQUIRED로 먼저 열고, 서버가 TLS를 거절했을 때만 DISABLED로 다시 연다 +/// (TransportCheck::open). 거절된 시도는 after_connect에 닿기 전에 끝나므로, 읽기 전용 고정 +/// 실패 원인은 실제로 쓰는 풀의 연결에서만 남는다. +async fn connect( + options: &MySqlConnectOptions, + read_only_cause: &Arc>, +) -> Result<(MySqlPool, Transport), sqlx::Error> { + transport_check(options) + .open(|mode| { + pool_options(Arc::clone(read_only_cause)).connect_with(options_for(options, mode)) + }) + .await } -/// Ssl_cipher 답을 전송 상태로 바꾼다. 행이 없거나 질의가 실패하면 암호화를 주장하지 않는다. -fn transport_from_cipher(answer: Result>, sqlx::Error>) -> Transport { - match answer { - Ok(Some(cipher)) if !cipher.is_empty() => Transport::Encrypted, - Ok(Some(_)) => Transport::Plaintext, - Ok(None) => Transport::Unconfirmed("Ssl_cipher status is unavailable".into()), - Err(error) => Transport::Unconfirmed(format!("Ssl_cipher status query failed: {error}")), +/// 여는 방식을 ssl-mode로 옮긴다. REQUIRED는 PREFERRED처럼 인증서를 검증하지 않으므로, +/// 서버가 TLS를 받는 한 sqlx 자체 PREFERRED와 같은 연결이다. ssl-mode 말고는 URL 그대로다. +fn options_for(options: &MySqlConnectOptions, mode: ConnectMode) -> MySqlConnectOptions { + let options = options.clone(); + match mode { + ConnectMode::AsGiven => options, + ConnectMode::RequireTls => options.ssl_mode(MySqlSslMode::Required), + ConnectMode::Plaintext => options.ssl_mode(MySqlSslMode::Disabled), } } +/// 연결마다 세션을 읽기 전용으로 고정하는 풀 설정. 고정 실패 원인은 처음 실패한 연결의 것을 +/// `first_cause`에 남긴다. +fn pool_options(first_cause: Arc>) -> MySqlPoolOptions { + MySqlPoolOptions::new().after_connect(move |conn, _| { + let first_cause = Arc::clone(&first_cause); + Box::pin(async move { + // 둘 다 실패해도 스캔은 계속하되(reader는 카탈로그 SELECT만 보낸다) + // 세션이 쓰기 가능이라는 사실과 그 원인은 limitation으로 남긴다. + if let Some(cause) = pin_read_only_session(conn).await { + first_cause.get_or_init(|| cause); + } + Ok(()) + }) + }) +} + /// 읽기 전용 세션을 고정한다. 처음 성공한 문장에서 멈추고, 모두 거절되면 원인을 돌려준다. async fn pin_read_only_session(conn: &mut MySqlConnection) -> Option { let mut attempts = Vec::new(); @@ -777,10 +777,11 @@ mod tests { } } - /// 서버 확인은 네트워크를 지나는 PREFERRED(기본) 연결에서만 한다. socket 인자는 Unix 소켓이다. + /// TLS를 요구해 먼저 여는 것은 네트워크를 지나는 PREFERRED(기본) 연결뿐이다. socket 인자는 + /// Unix 소켓이다. #[test] - fn only_remote_preferred_connections_ask_the_server() { - for (url, asks_server) in [ + fn only_remote_preferred_connections_negotiate_tls() { + for (url, negotiates) in [ ("mysql://u@127.0.0.1/app", false), ("mysql://u@localhost/app", false), ("mysql://u@[::1]/app", false), @@ -791,50 +792,56 @@ mod tests { ] { let options = MySqlConnectOptions::from_str(url).unwrap(); assert_eq!( - transport_check(&options).needs_server_check(), - asks_server, + transport_check(&options).negotiates_tls(), + negotiates, "{url}" ); } } - /// Ssl_cipher 답을 연결의 전송 상태로 바꾼다. 빈 값은 평문이고, 행이 없거나 질의가 실패하면 - /// 암호화를 주장하지 않고 원인을 남긴다. + /// 여는 방식은 ssl-mode만 바꾸고 접속 대상과 다른 인자는 URL 그대로 둔다. MySqlSslMode에는 + /// 비교 연산이 없어 Debug 표기로 본다. #[test] - fn ssl_cipher_answers_become_transport_observations() { - let cipher = b"TLS_AES_256_GCM_SHA384".to_vec(); - assert_eq!( - transport_from_cipher(Ok(Some(cipher))), - Transport::Encrypted - ); - assert_eq!( - transport_from_cipher(Ok(Some(Vec::new()))), - Transport::Plaintext - ); - assert!(matches!( - transport_from_cipher(Ok(None)), - Transport::Unconfirmed(cause) if cause.contains("Ssl_cipher") - )); - assert!(matches!( - transport_from_cipher(Err(sqlx::Error::PoolTimedOut)), - Transport::Unconfirmed(cause) if cause.contains("Ssl_cipher") - )); + fn connect_modes_change_only_the_ssl_mode() { + let options = + MySqlConnectOptions::from_str("mysql://u@db.example:3307/app?ssl-ca=/etc/ca.pem") + .unwrap(); + for (mode, ssl_mode) in [ + (ConnectMode::AsGiven, "Preferred"), + (ConnectMode::RequireTls, "Required"), + (ConnectMode::Plaintext, "Disabled"), + ] { + let opened = options_for(&options, mode); + assert_eq!(format!("{:?}", opened.get_ssl_mode()), ssl_mode, "{mode:?}"); + assert_eq!(opened.get_host(), "db.example"); + assert_eq!(opened.get_port(), 3307); + assert_eq!(opened.get_database(), Some("app")); + } } /// 원격 평문 연결의 limitation은 MySQL URL 인자로 해결 방향을 말하고, 읽기 전용 고정 실패보다 - /// 앞에 선다(접속 단계 순서). + /// 앞에 선다(접속 단계 순서). 모드로 끈 평문과 서버가 TLS를 거절한 평문은 원인 문구로 갈린다. #[test] fn remote_plaintext_limitation_precedes_the_read_only_failure() { - let mut doc = collected_document(true); - record_read_only_failure(&mut doc, Some("denied")); - TransportCheck::new(false, TlsPolicy::Never).record_limitation(&mut doc, TLS_REMEDY); + let first_limitation = |transport: Transport| { + let mut doc = collected_document(true); + record_read_only_failure(&mut doc, Some("denied")); + transport.record_limitation(&mut doc, TLS_REMEDY); + assert!(doc.limitations[1].starts_with(READ_ONLY_UNAVAILABLE)); + assert_eq!(doc.context, collected_document(true).context); + doc.limitations[0].clone() + }; assert_eq!( - doc.limitations[0], + first_limitation(Transport::Plaintext), "connection-not-encrypted: the catalog was read over an unencrypted connection; \ use ssl-mode=VERIFY_IDENTITY (with ssl-ca= for a private CA) to require \ a verified TLS connection" ); - assert!(doc.limitations[1].starts_with(READ_ONLY_UNAVAILABLE)); - assert_eq!(doc.context, collected_document(true).context); + assert_eq!( + first_limitation(Transport::TlsRefused), + "connection-not-encrypted: the server did not accept TLS, so the catalog was read \ + over an unencrypted connection; use ssl-mode=VERIFY_IDENTITY (with ssl-ca= for a private CA) to require a verified TLS connection" + ); } } diff --git a/engine/source/src/postgres.rs b/engine/source/src/postgres.rs index bb5a296..605850d 100644 --- a/engine/source/src/postgres.rs +++ b/engine/source/src/postgres.rs @@ -4,12 +4,12 @@ //! 몸체·routine 언어가 빠진다) pg_catalog를 주로 쓴다. 세션은 읽기 전용으로 //! 고정한다 — 스캐너가 DB를 바꾸는 일은 없어야 한다(sqlite의 mode=ro와 같은 의도). -use sqlx::postgres::{PgConnectOptions, PgConnection, PgPool, PgPoolOptions, PgSslMode}; +use sqlx::postgres::{PgConnectOptions, PgPool, PgPoolOptions, PgSslMode}; use sqlx::Row; use std::str::FromStr; use crate::document::*; -use crate::transport::{is_local_endpoint, TlsPolicy, Transport, TransportCheck}; +use crate::transport::{is_local_endpoint, ConnectMode, TlsPolicy, Transport, TransportCheck}; use crate::SourceError; /// 평문 원격 연결 limitation의 해결 방향. 공개 CA가 아닌 관리형 DB는 CA 파일이 필요하다. @@ -28,29 +28,7 @@ pub async fn read_with_dependencies( ) -> Result { let options = PgConnectOptions::from_str(url) .map_err(|e| SourceError::Connect(format!("postgres URL 해석 실패: {e}")))?; - let transport = transport_check(&options); - let connection_transport = transport.clone(); - let pool = PgPoolOptions::new() - .after_connect(move |conn, _| { - let transport = connection_transport.clone(); - Box::pin(async move { - // 읽기 전용 세션 고정 — sqlite reader의 read_only(true)와 같은 의도. - sqlx::Executor::execute( - &mut *conn, - "SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY", - ) - .await?; - // 서버가 렌더링하는 뷰 이름을 한정해 수집 세션의 search_path 영향을 없앤다. - sqlx::Executor::execute(&mut *conn, "SET search_path = pg_catalog").await?; - // 전송 확인은 실패해도 오류로 돌려주지 않는다. after_connect가 오류를 내면 풀이 - // 시간 상한까지 접속을 되풀이하므로, 확인하지 못한 사실은 limitation으로 남긴다. - if transport.needs_server_check() { - transport.record(observe_transport(conn).await); - } - Ok(()) - }) - }) - .connect_with(options) + let (pool, transport) = connect(&options) .await .map_err(|e| SourceError::Connect(format!("postgres 접속 실패: {e}")))?; @@ -123,27 +101,42 @@ fn transport_check(options: &PgConnectOptions) -> TransportCheck { ) } -/// 서버가 본 이 세션의 TLS 여부를 묻는다(pg_stat_ssl, PostgreSQL 9.5+). 커넥션 풀러나 -/// 프록시 뒤라면 서버가 본 상대는 풀러라, 이 답은 풀러와 서버 사이 연결을 말한다. -async fn observe_transport(conn: &mut PgConnection) -> Transport { - let answer = sqlx::query_scalar::<_, bool>( - "SELECT ssl FROM pg_catalog.pg_stat_ssl WHERE pid = pg_catalog.pg_backend_pid()", - ) - .fetch_optional(conn) - .await; - transport_from_answer(answer) +/// 풀을 열고 카탈로그를 읽을 연결의 전송을 함께 돌려준다. 네트워크를 지나는 prefer 연결은 +/// require로 먼저 열고, 서버가 TLS를 거절했을 때만 disable로 다시 연다(TransportCheck::open). +async fn connect(options: &PgConnectOptions) -> Result<(PgPool, Transport), sqlx::Error> { + transport_check(options) + .open(|mode| pool_options().connect_with(options_for(options, mode))) + .await } -/// pg_stat_ssl 답을 전송 상태로 바꾼다. 행이 없거나 질의가 실패하면 암호화를 주장하지 않는다. -fn transport_from_answer(answer: Result, sqlx::Error>) -> Transport { - match answer { - Ok(Some(true)) => Transport::Encrypted, - Ok(Some(false)) => Transport::Plaintext, - Ok(None) => Transport::Unconfirmed("pg_stat_ssl has no row for this session".into()), - Err(error) => Transport::Unconfirmed(format!("pg_stat_ssl query failed: {error}")), +/// 여는 방식을 sslmode로 옮긴다. require는 prefer처럼 인증서를 검증하지 않으므로, 서버가 +/// TLS를 받는 한 sqlx 자체 prefer와 같은 연결이다. sslmode 말고는 URL 그대로다. +fn options_for(options: &PgConnectOptions, mode: ConnectMode) -> PgConnectOptions { + let options = options.clone(); + match mode { + ConnectMode::AsGiven => options, + ConnectMode::RequireTls => options.ssl_mode(PgSslMode::Require), + ConnectMode::Plaintext => options.ssl_mode(PgSslMode::Disable), } } +/// 연결마다 수집 세션을 고정하는 풀 설정. +fn pool_options() -> PgPoolOptions { + PgPoolOptions::new().after_connect(|conn, _| { + Box::pin(async move { + // 읽기 전용 세션 고정 — sqlite reader의 read_only(true)와 같은 의도. + sqlx::Executor::execute( + &mut *conn, + "SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY", + ) + .await?; + // 서버가 렌더링하는 뷰 이름을 한정해 수집 세션의 search_path 영향을 없앤다. + sqlx::Executor::execute(&mut *conn, "SET search_path = pg_catalog").await?; + Ok(()) + }) + }) +} + async fn read_dependencies( pool: &PgPool, schema: &str, @@ -836,12 +829,12 @@ mod tests { } } - /// 서버 확인은 네트워크를 지나는 prefer 연결에서만 한다. Unix 소켓은 `/`로 시작하는 - /// 호스트(퍼센트 인코딩한 URL 호스트나 host 인자)로 고르는 sqlx 규칙을 따르고, + /// TLS를 요구해 먼저 여는 것은 네트워크를 지나는 prefer 연결뿐이다. Unix 소켓은 `/`로 + /// 시작하는 호스트(퍼센트 인코딩한 URL 호스트나 host 인자)로 고르는 sqlx 규칙을 따르고, /// hostaddr는 실제 접속 주소라 호스트 이름보다 앞선다. #[test] - fn only_remote_prefer_connections_ask_the_server() { - for (url, asks_server) in [ + fn only_remote_prefer_connections_negotiate_tls() { + for (url, negotiates) in [ ("postgres://u@127.0.0.1/app?sslmode=prefer", false), ("postgres://u@[::1]/app?sslmode=prefer", false), ("postgres://u@localhost/app?sslmode=prefer", false), @@ -863,50 +856,64 @@ mod tests { ] { let options = PgConnectOptions::from_str(url).unwrap(); assert_eq!( - transport_check(&options).needs_server_check(), - asks_server, + transport_check(&options).negotiates_tls(), + negotiates, "{url}" ); } } - /// pg_stat_ssl 답을 연결의 전송 상태로 바꾼다. 행이 없거나 질의가 실패하면 암호화를 - /// 주장하지 않고 원인을 남긴다. + /// 여는 방식은 sslmode만 바꾸고 접속 대상과 다른 인자는 URL 그대로 둔다. PgSslMode에는 + /// 비교 연산이 없어 Debug 표기로 본다. #[test] - fn pg_stat_ssl_answers_become_transport_observations() { - assert_eq!(transport_from_answer(Ok(Some(true))), Transport::Encrypted); - assert_eq!(transport_from_answer(Ok(Some(false))), Transport::Plaintext); - assert!(matches!( - transport_from_answer(Ok(None)), - Transport::Unconfirmed(cause) if cause.contains("pg_stat_ssl") - )); - assert!(matches!( - transport_from_answer(Err(sqlx::Error::PoolTimedOut)), - Transport::Unconfirmed(cause) if cause.contains("pg_stat_ssl") - )); + fn connect_modes_change_only_the_ssl_mode() { + let url = "postgres://u@db.example:6543/app?sslmode=prefer&sslrootcert=/etc/ca.pem"; + let options = PgConnectOptions::from_str(url).unwrap(); + for (mode, ssl_mode) in [ + (ConnectMode::AsGiven, "Prefer"), + (ConnectMode::RequireTls, "Require"), + (ConnectMode::Plaintext, "Disable"), + ] { + let opened = options_for(&options, mode); + assert_eq!(format!("{:?}", opened.get_ssl_mode()), ssl_mode, "{mode:?}"); + assert_eq!(opened.get_host(), "db.example"); + assert_eq!(opened.get_port(), 6543); + assert_eq!(opened.get_database(), Some("app")); + } } - /// 원격 평문 연결의 limitation은 PostgreSQL URL 인자로 해결 방향을 말한다. + /// 원격 평문 연결의 limitation은 PostgreSQL URL 인자로 해결 방향을 말한다. 모드로 끈 + /// 평문과 서버가 TLS를 거절한 평문은 원인 문구로 갈린다. #[test] fn remote_plaintext_limitation_names_the_postgres_remedy() { - let check = TransportCheck::new(false, TlsPolicy::Never); - let mut doc = CatalogDocument { - context: None, - dependencies: vec![], - version: DOCUMENT_VERSION, - dialect: "postgres".into(), - reader: "native-sqlx".into(), - schemas: vec![], - limitations: vec![], + let limitation = |transport: Transport| { + let mut doc = CatalogDocument { + context: None, + dependencies: vec![], + version: DOCUMENT_VERSION, + dialect: "postgres".into(), + reader: "native-sqlx".into(), + schemas: vec![], + limitations: vec![], + }; + transport.record_limitation(&mut doc, TLS_REMEDY); + doc.limitations }; - check.record_limitation(&mut doc, TLS_REMEDY); assert_eq!( - doc.limitations, + limitation(Transport::Plaintext), [ "connection-not-encrypted: the catalog was read over an unencrypted connection; \ use sslmode=verify-full (with sslrootcert= for a private CA) to require \ a verified TLS connection" ] ); + assert_eq!( + limitation(Transport::TlsRefused), + [ + "connection-not-encrypted: the server did not accept TLS, so the catalog was read \ + over an unencrypted connection; use sslmode=verify-full (with sslrootcert= for a private CA) to require a verified TLS connection" + ] + ); } } diff --git a/engine/source/src/transport.rs b/engine/source/src/transport.rs index 7c49067..ee9cb30 100644 --- a/engine/source/src/transport.rs +++ b/engine/source/src/transport.rs @@ -4,33 +4,63 @@ //! 뜻이다. 그 사실을 문서에 남겨 소비자가 결과를 얼마나 믿을지 스스로 정하게 한다. Unix //! 소켓과 루프백 연결은 네트워크를 지나지 않으므로 남기지 않는다. 로컬 fixture마다 붙는 //! 상투적 경보는 읽히지 않기 때문이다(AGENTS.md "limitations를 장식으로 쓰지 마세요"). +//! +//! 판정은 클라이언트가 직접 아는 사실로만 한다. 서버에 세션의 TLS 여부를 묻는 답(pg_stat_ssl, +//! Ssl_cipher)은 평문 연결에서 위조될 수 있고, 클라이언트 쪽 TLS만 거절한 중간자 뒤에서는 +//! 중간자와 서버 사이 구간을 말해 경보를 지운다. 그래서 네트워크를 지나는 prefer 연결은 sqlx +//! 자체 prefer에 맡기지 않고 TLS를 요구해 먼저 열며, 서버가 거절했을 때만 평문으로 다시 연다. +use std::future::Future; use std::net::IpAddr; -use std::sync::{Arc, OnceLock}; use crate::document::CatalogDocument; /// 평문 원격 연결을 알리는 limitation의 접두사. 소비자가 문구 대신 이 접두사로 찾는다. pub(crate) const NOT_ENCRYPTED: &str = "connection-not-encrypted:"; +/// sqlx 0.8.6이 TLS를 요구한 접속에서 서버가 TLS를 거절했을 때 내는 `Error::Tls` 문구. +/// PostgreSQL은 SSLRequest에 `N`을 받았을 때, MySQL은 서버 핸드셰이크에 CLIENT_SSL이 없을 +/// 때다(sqlx-postgres·sqlx-mysql `connection/tls.rs`). +/// +/// 같은 변형의 다른 오류(서버 이름으로 쓸 수 없는 호스트, 클라이언트 인증서 오류)는 서버의 +/// 거절이 아니므로 문구로 가른다. sqlx를 올려 문구가 바뀌면 거절도 접속 실패(종료 2)가 된다. +/// 평문으로 내려가는 쪽으로는 틀리지 않으며, 가짜 서버 테스트가 그 변화를 잡는다. +const SERVER_REFUSED_TLS: &str = "server does not support TLS"; + /// 접속 모드가 TLS를 다루는 방식. sqlx 0.8.6의 PostgreSQL·MySQL 모드를 세 갈래로 접는다. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TlsPolicy { /// TLS를 시도하지 않는다(PG disable·allow, MySQL DISABLED). 연결은 늘 평문이다. Never, - /// 서버가 받으면 TLS, 아니면 평문이다(PG prefer, MySQL PREFERRED). 결과는 서버에 물어야 안다. + /// 서버가 받으면 TLS, 아니면 평문이다(PG prefer, MySQL PREFERRED). 네트워크를 지나는 + /// 연결은 TLS를 요구해 열어 봐야 어느 쪽인지 안다. Prefer, /// TLS가 아니면 sqlx가 접속을 거절한다(require·verify-ca·verify-full과 MySQL 대응 모드). Required, } -/// 연결 하나에서 서버에 물어 확인한 전송 상태. -#[derive(Debug, Clone, PartialEq, Eq)] +/// reader가 풀을 여는 방식. reader마다 자기 sslmode로 옮긴다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConnectMode { + /// URL(과 libpq 환경 변수)이 정한 모드 그대로 연다. + AsGiven, + /// TLS를 요구한다(PG require, MySQL REQUIRED). prefer처럼 인증서는 검증하지 않는다. + RequireTls, + /// TLS를 시도하지 않는다(PG disable, MySQL DISABLED). + Plaintext, +} + +/// 카탈로그를 읽은 연결의 전송. 풀의 연결은 모두 같은 모드로 열리므로 첫 접속에서 정해진다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Transport { + /// Unix 소켓이나 루프백이라 네트워크를 지나지 않는다. + Local, + /// 모든 연결이 TLS다. 모드가 TLS를 요구하므로 sqlx가 연결마다 강제한다. Encrypted, + /// 모드가 TLS를 시도하지 않아 평문으로 읽었다. Plaintext, - /// 확인 질의가 실패했거나 답이 없었다. 암호화됐다고 주장하지 않고 원인을 싣는다. - Unconfirmed(String), + /// prefer에서 서버가 TLS를 거절해 평문으로 다시 열어 읽었다. + TlsRefused, } /// 접속 대상이 네트워크를 지나지 않는지 본다. Unix 소켓과 루프백 주소(127.0.0.0/8, ::1)와 @@ -59,82 +89,124 @@ fn is_loopback_address(address: IpAddr) -> bool { } } -/// 연결마다 서버에 물은 전송 상태를 문서 전체의 사실로 모은다. -/// -/// 풀은 연결을 여럿 열 수 있고 after_connect는 연결마다 돈다. 어느 연결이든 평문이거나 -/// 확인하지 못했으면 문서 전체의 한계이므로, 처음 나온 비암호화 관측 하나만 남긴다. -/// 복제본은 같은 기록을 공유한다(after_connect 클로저가 연결마다 복제해 쓴다). -#[derive(Debug, Clone)] +/// 접속 전에 정해지는 판정 재료: 대상이 네트워크를 지나는지와 URL의 TLS 모드. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct TransportCheck { local: bool, policy: TlsPolicy, - first_unencrypted: Arc>, } impl TransportCheck { /// 접속 대상 종류와 모드로 판정을 준비한다. pub(crate) fn new(local: bool, policy: TlsPolicy) -> Self { - Self { - local, - policy, - first_unencrypted: Arc::new(OnceLock::new()), + Self { local, policy } + } + + /// 접속 전에 전송을 아는지 본다. 네트워크를 지나는 prefer만 모른다 — 서버가 TLS를 + /// 받을지는 시도해 봐야 안다. 다른 모드는 TLS를 시도하지 않거나, TLS가 아니면 접속하지 않는다. + fn known(self) -> Option { + match (self.local, self.policy) { + (true, _) => Some(Transport::Local), + (false, TlsPolicy::Never) => Some(Transport::Plaintext), + (false, TlsPolicy::Required) => Some(Transport::Encrypted), + (false, TlsPolicy::Prefer) => None, } } - /// 서버에 물어야 판정할 수 있는지 본다. 네트워크를 지나는 prefer 연결만 그렇다. - /// 다른 모드는 클라이언트가 결과를 이미 안다 — TLS를 시도하지 않거나, TLS가 아니면 - /// 접속하지 않는다. 클라이언트가 아는 사실은 평문 연결에서 위조될 수 있는 서버 답보다 강하다. - pub(crate) fn needs_server_check(&self) -> bool { - !self.local && self.policy == TlsPolicy::Prefer + /// TLS를 요구해 먼저 열어 봐야 하는지 본다(reader의 URL 분류 테스트용). + #[cfg(test)] + pub(crate) fn negotiates_tls(self) -> bool { + self.known().is_none() } - /// 연결 하나의 관측을 기록한다. 암호화된 연결은 남길 것이 없다. - pub(crate) fn record(&self, observed: Transport) { - if observed != Transport::Encrypted { - self.first_unencrypted.get_or_init(|| observed); + /// `connect`로 풀을 열고 카탈로그를 읽을 연결의 전송을 함께 돌려준다. 전송을 아는 모드는 + /// URL대로 한 번 연다. 네트워크를 지나는 prefer는 `negotiate`가 정한다. + /// + /// `connect`는 받은 방식으로 풀을 연다. 풀의 첫 접속이 곧 시도라서 sqlx 풀의 접속 재시도 + /// (서버 기동 중의 접속 거절 등)가 그대로 살고, 시도용 연결을 따로 열지 않는다. + pub(crate) async fn open(self, connect: F) -> Result<(P, Transport), sqlx::Error> + where + F: Fn(ConnectMode) -> Fut, + Fut: Future>, + { + match self.known() { + Some(transport) => Ok((connect(ConnectMode::AsGiven).await?, transport)), + None => negotiate(connect).await, } } +} + +/// 네트워크를 지나는 prefer 연결을 연다. TLS를 요구해 먼저 열고, 서버가 TLS를 거절했을 때만 +/// 평문으로 다시 연다. 어느 쪽이든 풀의 모든 연결이 같은 모드라서, 이후 연결도 클라이언트가 +/// 아는 대로다 — TLS 풀은 sqlx가 연결마다 TLS를 강제하고, 평문 풀은 TLS를 시도하지 않는다. +async fn negotiate(connect: F) -> Result<(P, Transport), sqlx::Error> +where + F: Fn(ConnectMode) -> Fut, + Fut: Future>, +{ + match after_tls_attempt(connect(ConnectMode::RequireTls).await)? { + TlsAttempt::Accepted(pool) => Ok((pool, Transport::Encrypted)), + TlsAttempt::Refused => Ok(( + connect(ConnectMode::Plaintext).await?, + Transport::TlsRefused, + )), + } +} + +/// TLS를 요구한 첫 시도에서 고른 다음 걸음. +#[derive(Debug, PartialEq, Eq)] +enum TlsAttempt

{ + /// 서버가 TLS를 받았다. 연 풀을 그대로 쓴다. + Accepted(P), + /// 서버(또는 경로상의 누군가)가 TLS를 거절했다. 평문으로 다시 연다. + Refused, +} + +/// TLS를 요구한 시도의 결과를 다음 걸음으로 바꾼다. 서버의 TLS 거절만 평문 재시도로 이어진다. +/// +/// 인증·네트워크 오류, 서버가 TLS를 받은 뒤의 핸드셰이크 실패(sqlx에서는 `Error::Io`), +/// 클라이언트 TLS 설정 오류는 그대로 접속 실패다. sqlx 자체 prefer도 이때 평문으로 다시 +/// 붙지 않았고, 평문으로 내려가면 사용자가 준 TLS 설정을 무시하게 된다. +fn after_tls_attempt

(attempt: Result) -> Result, sqlx::Error> { + match attempt { + Ok(pool) => Ok(TlsAttempt::Accepted(pool)), + Err(error) if refuses_tls(&error) => Ok(TlsAttempt::Refused), + Err(error) => Err(error), + } +} +/// 서버가 TLS를 거절해 난 오류인지 본다(`SERVER_REFUSED_TLS`). +fn refuses_tls(error: &sqlx::Error) -> bool { + matches!(error, sqlx::Error::Tls(cause) if cause.to_string() == SERVER_REFUSED_TLS) +} + +impl Transport { /// 문서 limitations 맨 앞에 전송 limitation을 둔다. 접속 단계의 사실이라 수집 단계의 /// 한계보다 앞선다. 수집 범위가 아니라 전송 사실이므로 catalog_complete는 건드리지 않는다. - pub(crate) fn record_limitation(&self, doc: &mut CatalogDocument, remedy: &str) { - let observed = self.first_unencrypted.get(); - if let Some(limitation) = connection_limitation(self.local, self.policy, observed, remedy) { + pub(crate) fn record_limitation(self, doc: &mut CatalogDocument, remedy: &str) { + if let Some(limitation) = connection_limitation(self, remedy) { doc.limitations.insert(0, limitation); } } } -/// 연결 종류·모드·서버 관측으로 전송 limitation을 정한다. 문구에는 호스트·사용자·비밀번호를 -/// 싣지 않고, 원인과 해결 방향(`remedy`)을 함께 싣는다. -pub(crate) fn connection_limitation( - local: bool, - policy: TlsPolicy, - observed: Option<&Transport>, - remedy: &str, -) -> Option { - if local { - return None; - } - let transport = match policy { - TlsPolicy::Never => &Transport::Plaintext, - TlsPolicy::Required => return None, - TlsPolicy::Prefer => observed?, +/// 전송으로 limitation을 정한다. 문구에는 호스트·사용자·비밀번호를 싣지 않고, 원인과 해결 +/// 방향(`remedy`)을 함께 싣는다. +fn connection_limitation(transport: Transport, remedy: &str) -> Option { + let cause = match transport { + Transport::Local | Transport::Encrypted => return None, + Transport::Plaintext => "the catalog was read over an unencrypted connection", + Transport::TlsRefused => { + "the server did not accept TLS, so the catalog was read over an unencrypted connection" + } }; - match transport { - Transport::Encrypted => None, - Transport::Plaintext => Some(format!( - "{NOT_ENCRYPTED} the catalog was read over an unencrypted connection; {remedy}" - )), - Transport::Unconfirmed(cause) => Some(format!( - "{NOT_ENCRYPTED} the catalog connection could not be confirmed as encrypted ({cause}); {remedy}" - )), - } + Some(format!("{NOT_ENCRYPTED} {cause}; {remedy}")) } #[cfg(test)] mod tests { use super::*; + use std::cell::RefCell; const REMEDY: &str = "add sslmode=verify-full to the URL"; @@ -174,54 +246,158 @@ mod tests { } } + /// 로컬 연결은 모드와 무관하게 limitation이 없고, 원격 연결은 prefer만 TLS 협상으로 정한다. #[test] - fn local_connections_never_carry_the_limitation() { + fn the_mode_decides_every_connection_except_remote_prefer() { for policy in [TlsPolicy::Never, TlsPolicy::Prefer, TlsPolicy::Required] { assert_eq!( - connection_limitation(true, policy, Some(&Transport::Plaintext), REMEDY), - None + TransportCheck::new(true, policy).known(), + Some(Transport::Local) ); } + let remote = |policy| TransportCheck::new(false, policy).known(); + assert_eq!(remote(TlsPolicy::Never), Some(Transport::Plaintext)); + assert_eq!(remote(TlsPolicy::Required), Some(Transport::Encrypted)); + assert_eq!(remote(TlsPolicy::Prefer), None); + assert!(TransportCheck::new(false, TlsPolicy::Prefer).negotiates_tls()); + assert!(!TransportCheck::new(true, TlsPolicy::Prefer).negotiates_tls()); } - /// 모드가 결과를 정하는 원격 연결은 서버 관측 없이 판정한다. + /// 평문 전송만 limitation을 남긴다. 모드로 끈 평문은 기존 문구 그대로이고, 서버가 거절한 + /// 평문은 그 원인을 앞에 싣는다. #[test] - fn the_mode_decides_remote_connections_without_tls_negotiation() { - let disabled = connection_limitation(false, TlsPolicy::Never, None, REMEDY).unwrap(); + fn only_plaintext_transports_carry_the_limitation() { + assert_eq!(connection_limitation(Transport::Local, REMEDY), None); + assert_eq!(connection_limitation(Transport::Encrypted, REMEDY), None); assert_eq!( - disabled, + connection_limitation(Transport::Plaintext, REMEDY).unwrap(), "connection-not-encrypted: the catalog was read over an unencrypted connection; add sslmode=verify-full to the URL" ); assert_eq!( - connection_limitation(false, TlsPolicy::Required, None, REMEDY), - None + connection_limitation(Transport::TlsRefused, REMEDY).unwrap(), + "connection-not-encrypted: the server did not accept TLS, so the catalog was read over an unencrypted connection; add sslmode=verify-full to the URL" ); } - /// prefer 원격 연결은 서버 관측을 따르고, 확인하지 못했으면 암호화를 주장하지 않는다. + /// sqlx가 서버의 TLS 거절에 내는 오류. + fn refusal() -> sqlx::Error { + sqlx::Error::Tls(SERVER_REFUSED_TLS.into()) + } + + /// 결정 함수: 성공은 TLS 풀을 그대로 쓰고, 서버의 TLS 거절만 평문 재시도이며, 나머지 + /// 오류(같은 `Error::Tls`의 다른 원인, 핸드셰이크 I/O 실패, 시간 초과)는 접속 실패다. #[test] - fn prefer_follows_the_server_observation() { - let limitation = - |observed| connection_limitation(false, TlsPolicy::Prefer, observed, REMEDY); - assert_eq!(limitation(None), None); - assert_eq!(limitation(Some(&Transport::Encrypted)), None); - assert!(limitation(Some(&Transport::Plaintext)) - .unwrap() - .starts_with("connection-not-encrypted: the catalog was read over an unencrypted")); - let unconfirmed = limitation(Some(&Transport::Unconfirmed("no row".into()))).unwrap(); + fn only_a_tls_refusal_leads_to_a_plaintext_retry() { + assert_eq!(after_tls_attempt(Ok(7)).unwrap(), TlsAttempt::Accepted(7)); assert_eq!( - unconfirmed, - "connection-not-encrypted: the catalog connection could not be confirmed as encrypted (no row); add sslmode=verify-full to the URL" + after_tls_attempt::<()>(Err(refusal())).unwrap(), + TlsAttempt::Refused ); + let failures = [ + sqlx::Error::Tls("invalid dns name".into()), + sqlx::Error::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "handshake", + )), + sqlx::Error::PoolTimedOut, + ]; + for failure in failures { + let shown = failure.to_string(); + let error = after_tls_attempt::<()>(Err(failure)).unwrap_err(); + assert_eq!(error.to_string(), shown); + } } - /// 서버 확인은 네트워크를 지나는 prefer 연결에서만 한다. - #[test] - fn only_remote_prefer_connections_ask_the_server() { - assert!(TransportCheck::new(false, TlsPolicy::Prefer).needs_server_check()); - assert!(!TransportCheck::new(true, TlsPolicy::Prefer).needs_server_check()); - assert!(!TransportCheck::new(false, TlsPolicy::Never).needs_server_check()); - assert!(!TransportCheck::new(false, TlsPolicy::Required).needs_server_check()); + /// `open` 흉내: 받은 방식을 기록하고, 방식마다 준비한 결과로 답한다. + struct Recorder { + modes: RefCell>, + answer: fn(ConnectMode) -> Result, + } + + impl Recorder { + fn new(answer: fn(ConnectMode) -> Result) -> Self { + Self { + modes: RefCell::new(Vec::new()), + answer, + } + } + + /// 풀 대신 연 방식을 돌려준다. 어느 방식의 풀이 남았는지 결과로 보인다. + fn open(&self, mode: ConnectMode) -> std::future::Ready> { + self.modes.borrow_mut().push(mode); + std::future::ready((self.answer)(mode)) + } + + fn modes(&self) -> Vec { + self.modes.borrow().clone() + } + } + + /// 모드가 전송을 정하는 연결은 URL대로 한 번만 연다. + #[tokio::test] + async fn known_transports_open_once_as_given() { + let cases = [ + (true, TlsPolicy::Prefer, Transport::Local), + (false, TlsPolicy::Never, Transport::Plaintext), + (false, TlsPolicy::Required, Transport::Encrypted), + ]; + for (local, policy, transport) in cases { + let recorder = Recorder::new(Ok); + let opened = TransportCheck::new(local, policy) + .open(|mode| recorder.open(mode)) + .await + .unwrap(); + assert_eq!(opened, (ConnectMode::AsGiven, transport)); + assert_eq!(recorder.modes(), [ConnectMode::AsGiven]); + } + } + + /// 원격 prefer는 TLS를 요구해 먼저 열고, 받아 주면 그 풀을 쓴다. + #[tokio::test] + async fn remote_prefer_keeps_the_tls_pool_when_the_server_accepts() { + let recorder = Recorder::new(Ok); + let opened = TransportCheck::new(false, TlsPolicy::Prefer) + .open(|mode| recorder.open(mode)) + .await + .unwrap(); + assert_eq!(opened, (ConnectMode::RequireTls, Transport::Encrypted)); + assert_eq!(recorder.modes(), [ConnectMode::RequireTls]); + } + + /// 서버가 TLS를 거절하면 평문으로 다시 열고 그 사실을 전송으로 남긴다. + #[tokio::test] + async fn remote_prefer_reopens_without_tls_only_after_a_refusal() { + let recorder = Recorder::new(|mode| match mode { + ConnectMode::RequireTls => Err(refusal()), + other => Ok(other), + }); + let opened = TransportCheck::new(false, TlsPolicy::Prefer) + .open(|mode| recorder.open(mode)) + .await + .unwrap(); + assert_eq!(opened, (ConnectMode::Plaintext, Transport::TlsRefused)); + assert_eq!( + recorder.modes(), + [ConnectMode::RequireTls, ConnectMode::Plaintext] + ); + } + + /// 거절이 아닌 실패는 평문으로 다시 열지 않고 그대로 돌려준다. + #[tokio::test] + async fn remote_prefer_fails_without_a_plaintext_retry_on_other_errors() { + let recorder = Recorder::new(|mode| match mode { + ConnectMode::RequireTls => Err(sqlx::Error::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "handshake", + ))), + other => Ok(other), + }); + let error = TransportCheck::new(false, TlsPolicy::Prefer) + .open(|mode| recorder.open(mode)) + .await + .unwrap_err(); + assert!(matches!(error, sqlx::Error::Io(_)), "{error}"); + assert_eq!(recorder.modes(), [ConnectMode::RequireTls]); } fn empty_document() -> CatalogDocument { @@ -236,31 +412,18 @@ mod tests { } } - /// 연결 여러 개 중 하나라도 평문이면 문서의 한계다. 처음 관측한 것만 맨 앞에 남는다. + /// 전송 limitation은 다른 한계보다 앞에 서고, limitation이 없는 전송은 문서를 바꾸지 않는다. #[test] - fn the_first_unencrypted_connection_becomes_the_first_limitation() { - let check = TransportCheck::new(false, TlsPolicy::Prefer); - let shared = check.clone(); - shared.record(Transport::Encrypted); - shared.record(Transport::Unconfirmed("query failed".into())); - check.record(Transport::Plaintext); + fn the_transport_limitation_comes_first() { let mut doc = empty_document(); - check.record_limitation(&mut doc, REMEDY); + Transport::TlsRefused.record_limitation(&mut doc, REMEDY); assert_eq!(doc.limitations.len(), 2); - assert!( - doc.limitations[0].contains("(query failed)"), - "{:?}", - doc.limitations - ); + assert!(doc.limitations[0].starts_with(NOT_ENCRYPTED), "{doc:?}"); assert_eq!(doc.limitations[1], "track_functions=none"); - } - - #[test] - fn encrypted_connections_leave_the_document_unchanged() { - let check = TransportCheck::new(false, TlsPolicy::Prefer); - check.record(Transport::Encrypted); - let mut doc = empty_document(); - check.record_limitation(&mut doc, REMEDY); - assert_eq!(doc.limitations, vec!["track_functions=none".to_owned()]); + for transport in [Transport::Local, Transport::Encrypted] { + let mut unchanged = empty_document(); + transport.record_limitation(&mut unchanged, REMEDY); + assert_eq!(unchanged.limitations, ["track_functions=none"]); + } } } From 9e9453b8a35ffb8c505a65eb30d079b4a40634ce Mon Sep 17 00:00:00 2001 From: Coden Date: Tue, 20 Oct 2026 21:24:05 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=8A=A4=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=EC=9D=98=20=EC=9B=90=EA=B2=A9=20prefer=20?= =?UTF-8?q?=EC=95=94=ED=98=B8=ED=99=94=20=ED=8C=90=EC=A0=95=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8=20TLS=20=ED=98=91=EC=83=81=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인: - 3b8bef9가 네트워크를 지나는 prefer(MySQL PREFERRED) 연결의 판정을 서버의 자기 보고(pg_stat_ssl, Ssl_cipher) 대신 reader 자신의 TLS 협상 결과로 바꿨다. 그런데 Unreleased의 Security·Changed 문단은 서버에 묻는 방식과 "encryption could not be confirmed" limitation을 그대로 설명하고 있었다. 수정: - Security 다섯째 불릿: 기본 모드에서 원격 호스트는 sslmode=require(ssl-mode=REQUIRED) 처럼 먼저 붙고 서버가 TLS를 거절했을 때만 TLS 없이 다시 붙으며, 그때 limitation은 서버가 TLS를 받지 않았다고 말한다고 적었다. 판정이 서버 보고가 아니라 reader의 협상이라 reader 쪽 TLS를 거절하는 프록시가 limitation을 숨기지 못한다는 것과, limitation이 풀러·프록시의 다음 구간이 아니라 reader 자신의 연결을 말한다는 것도 README 두 언어와 같게 적었다. - Changed 첫 불릿에서 "or encryption could not be confirmed"를 지웠다. - 서버 확인 방식은 #47 뒤로 태그가 없어(원격 최신 태그 v0.7.1, git tag --contains 27f665e 비어 있음) 릴리스된 적이 없다. 그래서 새 불릿을 더하지 않고 기존 두 문단만 최종 동작으로 고쳤다. 버전 번호는 그대로다. 검증: - transport.rs의 limitation 문구, README.md·README.ko.md의 같은 문단, Scripts/verify-native-tls.py 사례(downgrade-proxy-network-host 등)와 대조했다. - 바뀐 줄은 모두 80자 이내이고 git diff --check가 깨끗하다. Co-Authored-By: Claude Opus 5.5 --- RELEASE-NOTES.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3c41252..2ebc471 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -49,11 +49,15 @@ These changes are not yet in a published release. socket or a loopback address (`127.0.0.0/8`, `::1`, `localhost`) records a `connection-not-encrypted:` limitation in the catalog document and the graph. `sslmode=disable`, `sslmode=allow`, and `ssl-mode=DISABLED` always record it - for such a host. In the default mode the reader asks the server whether the - session uses TLS (`pg_stat_ssl`, `Ssl_cipher`); if the server cannot answer, - the limitation says that encryption could not be confirmed. Loopback and - Unix-socket scans, including the fixtures, produce the same output as before, - and `catalog_complete` is unchanged. + for such a host. In the default mode the reader first connects to such a + host as `sslmode=require` / `ssl-mode=REQUIRED` does and reconnects without + TLS only when the server refuses TLS; the limitation then says that the + server did not accept TLS. The reader decides from its own TLS negotiation, + not from the server's report of the session, so a proxy that refuses TLS + toward the reader cannot hide the limitation. The limitation describes the + reader's own connection, not the onward connection from a pooler or proxy + to the database. Loopback and Unix-socket scans, including the fixtures, + produce the same output as before, and `catalog_complete` is unchanged. - New dependencies, pinned by `engine/Cargo.lock`: rustls 0.23.43, rustls-webpki 0.103.14, rustls-pki-types 1.15.1, ring 0.17.14, untrusted 0.9.0, and webpki-roots 0.26.11 and 1.0.10. The root certificate data in @@ -86,9 +90,8 @@ These changes are not yet in a published release. - A native PostgreSQL or MySQL/MariaDB scan of a host that is not a Unix socket or a loopback address now adds a `connection-not-encrypted:` limitation to the catalog document and the graph when the catalog was read - over an unencrypted connection or encryption could not be confirmed (details - under Security). Loopback and Unix-socket scans, including the fixtures, keep - their output. + over an unencrypted connection (details under Security). Loopback and + Unix-socket scans, including the fixtures, keep their output. - Cache format version 4 makes existing `--cache-dir` entries miss once; the next scan rewrites them. Caches restored from another machine or CI run are reused only when they were written with the same `SCHEMAGRAPH_CACHE_KEY`. From f21b194e84e55a4eeb71ef53bf7b0932b4a86a4a Mon Sep 17 00:00:00 2001 From: Coden Date: Tue, 20 Oct 2026 22:03:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(source):=20TLS=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=EC=9E=90=EA=B0=80=20Linux=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A2=85=EB=A3=8C=ED=95=A0=20=EB=95=8C=2010=EC=B4=88?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EA=B3=A0=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B6=99=EC=9E=A1=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인: - Scripts/verify-native-tls.py의 DowngradeProxy.__exit__는 리스너를 닫아 serve 스레드의 accept를 끝내려 했다. Linux는 다른 스레드가 리스닝 소켓을 닫아도 막힌 accept를 깨우지 않는다(macOS는 ECONNABORTED로 깨운다). 그래서 CI verify job(ubuntu-24.04)에서는 join(timeout=10)이 상한을 다 기다려 TLS 검증 단계 실행마다 10초가 늘었다. 그 accept가 소켓을 붙잡아 프로세스가 끝날 때까지 프록시 포트도 LISTEN으로 남았다. - serve 문서 주석("리스너를 닫으면 accept가 OSError로 끝난다")은 Linux에서 틀렸다. 사례 판정 결과는 바뀌지 않았다. 스레드가 daemon이고 판정이 종료 전에 끝나기 때문이다. 수정: - 리스너에 ACCEPT_POLL_SECONDS(0.2초) timeout을 건다. __exit__는 threading.Event로 멈춤을 요청하고, serve가 accept에서 나와 끝나기를 기다린 뒤 리스너를 닫는다. 그래서 포트도 바로 놓인다. - timeout은 socket.timeout으로 받는다. Python 3.9(macOS 기본 python3)에서는 이 예외가 TimeoutError의 별칭이 아니다. TimeoutError로 받으면 timeout이 일반 OSError로 떨어진다. - 리스너는 serve가 끝난 뒤에 닫으므로, accept가 내는 다른 OSError는 실제 실패다. 예전처럼 조용히 끝내지 않고 errors에 남겨 사례 판정이 실패로 낸다. PROXY_STOP_SECONDS(10초) 안에 스레드가 멈추지 않을 때도 같은 방식으로 실패를 낸다. 이번 결함처럼 join 상한이 조용히 지나가지 않게 하기 위해서다. - serve·__exit__ 문서 주석을 실제 동작에 맞췄다. 결정: 리스너를 닫기 전에 shutdown(SHUT_RDWR)을 부르는 대안은 쓰지 않았다 (이유: 실측해 보니 Linux는 shutdown 뒤 accept가 EINVAL로 깼다. 하지만 macOS는 리스닝 소켓의 shutdown이 ENOTCONN을 내고 accept를 깨우지 못했다. 플랫폼마다 다른 errno를 가려 받아야 한다. timeout과 Event는 두 플랫폼에서 같은 경로로 끝나고, 비용은 종료마다 최대 0.2초다.) 검증: - 실제 DowngradeProxy를 열고 닫는 측정을 python:3.12-slim 컨테이너(Linux 6.8)에서 했다. 수정 전에는 __exit__가 10.01초 걸렸고, serve 스레드가 살아 있었고, 종료 뒤에도 포트가 LISTEN(/proc/net/tcp 0A)이라 새 접속이 받아졌다. 수정 후에는 0.12초에 스레드가 끝나고 접속이 거절된다. macOS(Python 3.14.7·3.9.6)는 수정 전 0.0초, 수정 후 0.09초 안팎이다. - 가짜 TLS upstream으로 중계 경로(SSLRequest 거절 → 평문 StartupMessage → 서버와 TLS 중계)를 Linux·macOS에서 끝까지 확인했다. 수정 후 Linux 종료는 0.17초이고, 중계 결과는 수정 전과 같다. - 변이 확인: 닫은 뒤 join하는 예전 순서는 Linux에서 "accept failed: OSError: [Errno 9] Bad file descriptor"로 드러난다. 멈춤 요청을 보지 않는 serve는 두 플랫폼에서 "did not stop within 10 seconds"로 드러난다. - PostgreSQL 16으로 python3 Scripts/verify-native-tls.py --engine --mysql-environments Fixtures/accuracy/mysql-environments.json 실행: rc 0, PG 17·MySQL 7·MariaDB 7사례, 21초. PG만 돌린 출력 JSON은 수정 전과 바이트 단위로 같다. - pyflakes 3.2.0 지적 0건(수정 전후 같음). Python 3.14.7·3.9.6에서 -W error로 import가 통과한다. Co-Authored-By: Claude Opus 5.5 --- Scripts/verify-native-tls.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/Scripts/verify-native-tls.py b/Scripts/verify-native-tls.py index fedcb1d..ef561ea 100644 --- a/Scripts/verify-native-tls.py +++ b/Scripts/verify-native-tls.py @@ -76,6 +76,12 @@ PG_SSL_REQUEST = bytes([0x00, 0x00, 0x00, 0x08, 0x04, 0xD2, 0x16, 0x2F]) # 중간자 중계가 한쪽을 기다리는 상한(초). scan 하나보다 넉넉하다. RELAY_IDLE_SECONDS = 60 +# 중간자 리스너가 멈춤 요청을 확인하는 accept 대기 간격(초). Linux는 다른 스레드가 리스닝 +# 소켓을 닫아도 막힌 accept를 깨우지 않으므로(macOS는 ECONNABORTED로 깨운다), 닫아서 끝내지 +# 않고 이 간격마다 요청을 본다. +ACCEPT_POLL_SECONDS = 0.2 +# 멈춤 요청 뒤 리스너 스레드를 기다리는 상한(초). accept 대기 간격보다 넉넉하다. 넘기면 실패로 낸다. +PROXY_STOP_SECONDS = 10 # MySQL·MariaDB 사례가 쓸 데이터베이스다. Scripts/wait-mysql-ready.py가 이 이름으로 최종 # 서버의 TCP 준비를 확인한다(fixture 하네스와 같은 절차). @@ -416,8 +422,11 @@ def __init__(self, upstream_port, cafile): self.upstream_port = upstream_port self.cafile = cafile self.listener = socket.create_server(('127.0.0.1', 0)) + # accept가 이 간격마다 돌아와 serve가 멈춤 요청을 본다. 받은 접속은 blocking으로 온다. + self.listener.settimeout(ACCEPT_POLL_SECONDS) self.port = self.listener.getsockname()[1] self.errors = [] + self.stopping = threading.Event() self.thread = threading.Thread(target=self.serve, daemon=True) def __enter__(self): @@ -425,15 +434,26 @@ def __enter__(self): return self def __exit__(self, *exc_info): + """멈춤을 요청하고, serve가 accept에서 나와 끝난 뒤에 리스너를 닫아 포트를 바로 놓는다. + + 리스너를 먼저 닫아 accept를 끝내지 않는다. Linux에서는 막힌 accept가 깨지 않아 join이 상한을 + 다 기다리고, 그 accept가 소켓을 붙잡아 프로세스가 끝날 때까지 포트가 LISTEN으로 남는다. + """ + self.stopping.set() + self.thread.join(timeout=PROXY_STOP_SECONDS) + if self.thread.is_alive(): + self.errors.append(f'the proxy listener did not stop within {PROXY_STOP_SECONDS} seconds') self.listener.close() - self.thread.join(timeout=10) def serve(self): - """접속마다 중계 스레드를 띄운다. __exit__가 리스너를 닫으면 accept가 OSError로 끝난다.""" - while True: + """접속마다 중계 스레드를 띄운다. __exit__가 멈춤을 요청하면 다음 accept 대기 뒤에 끝난다.""" + while not self.stopping.is_set(): try: client, _ = self.listener.accept() - except OSError: + except socket.timeout: # Python 3.9에서는 TimeoutError의 별칭이 아니라 이 이름으로 받는다. + continue + except OSError as error: # 리스너는 serve가 끝난 뒤에 닫히므로 실제 실패다(사례 판정이 낸다). + self.errors.append(f'accept failed: {type(error).__name__}: {error}') return threading.Thread(target=self.handle, args=(client,), daemon=True).start()