인증서 오류는 검증을 끄지 않고, 빠진 CA와 틀린 이름을 고쳐서 푼다

기업망에 설치한 OwlAI가 클라우드 LLM이나 고객 DB에 TLS로 붙을 때, 인증서 오류가 나는 경우는 크게 둘입니다. 하나는 망 출구의 SSL 가시화 장비(HTTPS를 풀어 검사한 뒤 사내 루트 CA로 다시 서명하는 장비)가 끼어 서버 인증서가 사내 CA의 서명으로 바뀌는 경우입니다. 다른 하나는 드라이버가 SSH 터널의 로컬 끝인 127.0.0.1로 붙는데 DB 인증서는 db.corp.example 같은 이름으로 발급된 경우입니다. 증상은 둘 다 CERTIFICATE_VERIFY_FAILED이고, 가장 빠른 우회도 같습니다. 인증서 검증을 끄는 것입니다. 그러면 통신은 여전히 암호화되지만, 중간에서 자기 인증서를 끼워 넣는 상대를 막지 못합니다.
두 오류는 검증에 필요한 값 하나가 틀려서 납니다. 첫째는 믿을 CA가 빠졌고, 둘째는 인증서와 비교할 이름이 틀렸습니다. 그래서 OwlAI는 검증을 끄는 대신 그 값을 바로잡습니다. 믿을 CA는 한 곳에서 정해 모든 외부 호출에 같은 TLS 검증 설정으로 넘기고, 인증서에서 확인할 이름은 드라이버가 붙는 주소와 따로 넘깁니다. 사내 CA 번들을 넣은 설치에서는 파이썬 3.13이 켜는 형식 검사 하나만 끕니다.
검증을 끄면 암호화만 남는다
TLS 검증은 인증서가 믿는 CA까지 이어지는지(체인 검증)와 인증서의 이름이 접속하려는 호스트와 같은지(호스트 이름 검증)를 봅니다. verify=False는 둘을 함께 끄므로, 공격자가 자기 CA로 서명한 인증서를 내밀어도 연결이 맺어지고 DB 계정과 조회 결과가 그 상대에게 넘어갑니다.
DB 드라이버는 기본값부터 이 상태입니다. 연결에서 TLS를 켜지 않으면 PostgreSQL의 libpq(sslmode=prefer)는 서버가 TLS를 줄 때 쓰되 인증서를 확인하지 않고, MariaDB·MySQL의 pymysql(PREFERRED)은 확인 없는 TLS를 시도하다 서버가 TLS를 주지 않으면 평문으로 내려갑니다. Oracle(python-oracledb thin)과 Tibero(JDBC)는 평문으로 붙습니다.
그래서 OwlAI의 DB 연결 등록 창에서 TLS 사용을 켜면 체인과 호스트 이름을 모두 확인하고, 확인하지 못하면 붙지 않습니다. 자체 서명 인증서나 사설 CA를 쓰는 DB는 연결에 CA 인증서(PEM)를 붙여 넣어 풉니다. 새 연결은 TLS가 켜진 채로 시작하고, TLS를 끄고 SSH 터널도 쓰지 않는 연결에는 암호화되지 않은 연결 표시가 붙습니다.
믿을 CA는 한 곳에서 정해 모든 외부 호출에 넘긴다
SSL 가시화 장비 뒤에서는 사내 루트 CA를 믿게 하면 됩니다. 어려운 것은 모든 클라이언트가 빠짐없이 믿게 하는 일입니다. 백엔드와 워커는 OpenAI·Anthropic SDK, 커넥터, 웹 도구, 임베딩·리랭커·음성인식 호출, 웹 푸시로 밖에 나갑니다. httpx나 urllib3에 verify=True로 맡기면 라이브러리마다 기본 TLS 컨텍스트를 따로 만들고, 어떤 SDK는 운영체제 신뢰 저장소를 읽는 패키지를 따로 씁니다. 그러면 설정 하나를 바꿔도 일부 경로만 고쳐지고, 빠진 경로 하나가 그대로 장애가 됩니다.
그래서 신뢰 규칙을 하나로 정했습니다. SSL_CERT_FILE이 있으면 그 파일, 없고 SSL_CERT_DIR이 있으면 그 디렉터리, 둘 다 없으면 기본 번들(certifi)입니다. 네트워크 정책 모듈이 이 규칙으로 TLS 검증 설정을 만들고, 외부로 나가는 모든 클라이언트가 그 설정을 받습니다. SDK에는 HTTP 클라이언트를 만들어 넣고, 컨텍스트를 인자로 받지 않는 웹 푸시 라이브러리(requests)에는 연결 풀에 직접 넣습니다. 단위 시험은 제품 코드에서 HTTP 클라이언트를 만드는 자리를 모두 훑어, 이 설정을 받지 않는 곳이 있으면 실패합니다.
프록시 설정도 같은 모듈이 정합니다. 문서 렌더·스크립트 실행 사이드카를 부르는 내부 호출은 사내 프록시를 타면 안 되므로 환경 프록시를 끄는데(httpx trust_env=False), 이 옵션은 SSL_CERT_FILE까지 무시하게 만듭니다. 둘을 따로 정하면 이 자리에서 사내 CA가 빠지므로, 내부 호출에도 TLS 검증 설정을 명시해 넘깁니다. 사내 프록시를 거치는 외부 호출은 CONNECT 터널 안에서 서버와 TLS를 맺고, 역시 같은 설정으로 검증합니다.
운영자가 조심할 것은 하나입니다. SSL_CERT_FILE은 기본 번들에 보태지지 않고 기본 번들을 대체합니다. 사내 CA 한 장만 넣으면 가시화 장비가 검사에서 빼 둔 도메인의 인증서가 거꾸로 실패합니다. 그래서 사내 CA가 이미 든 호스트 OS 번들(update-ca-certificates·update-ca-trust가 만든 파일)을 설치 디렉터리로 복사해 백엔드·워커에 읽기 전용으로 마운트하고, .env에 SSL_CERT_FILE=<컨테이너 안 경로> 한 줄을 넣습니다. 적용 여부는 기동 로그 net.egress.policy의 trust_source(file)와 trust_location으로 보고, 경로를 읽지 못하면 net.tls.ca_bundle_unreadable이 ERROR로 남습니다.
사내 CA 번들을 넣은 설치만 형식 엄격 검사를 끈다
사내 CA를 제대로 넣은 뒤에도 이런 오류가 나는 망이 있습니다.
CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier
원인은 파이썬 3.13부터 기본 TLS 컨텍스트가 켜는 VERIFY_X509_STRICT입니다. 인증서가 RFC 5280의 형식 요건까지 지키는지 보는 플래그입니다. 공개 CA의 인증서는 이 요건을 지키지만, 가시화 장비가 접속마다 즉석에서 만드는 인증서에는 Authority Key Identifier(AKI) 확장이 빠진 경우가 있습니다. VM에서 오픈소스 가로채기 프록시(Ubuntu 24.04의 mitmproxy 8.1.1) 뒤에 두면 사내 CA를 넣은 상태에서도 모든 외부 호출이 이 오류로 실패했고, 이 플래그 하나를 끄자 요청이 서버까지 닿았습니다.
OwlAI는 SSL_CERT_FILE로 번들을 넣은 설치에서만 이 플래그를 끕니다. 번들을 넣었다는 사실이 곧 이 망이 사내 CA로 다시 서명한다는 뜻이기 때문입니다. 끄는 것은 형식 검사 하나이고, 체인 검증과 호스트 이름 검증은 그대로 남습니다. 번들에 없는 CA가 서명했거나 이름이 다르면 여전히 거절합니다. 번들을 넣지 않은 설치(OwlAI SaaS 포함)와 SSL_CERT_DIR을 쓰는 설치는 엄격 검사를 켠 채 두고, 파이썬 판과 상관없이 같은 답이 나오도록 3.12 이하에서도 이 플래그를 명시적으로 켭니다.
DB 연결에 CA를 붙여 넣은 경우도 같습니다. 그 연결은 붙여 넣은 CA만 믿고 형식 엄격 검사는 끕니다. 고객 DB의 사설 CA·자체 서명 인증서 가운데에는 형식 요건을 다 지키지 않는 것이 있기 때문입니다. 설치에서의 적용 여부는 기동 로그의 x509_strict로 봅니다. 번들을 넣었는데 true라면 SSL_CERT_FILE이 백엔드·워커에 들어가지 않은 것이고, trust_source도 certifi로 찍힙니다.
붙는 주소와 확인할 이름은 따로 넘긴다
DB 연결에서는 드라이버가 실제로 붙는 주소와 인증서의 이름이 자주 어긋납니다. 배스천 호스트를 거치는 SSH 터널이면 드라이버는 127.0.0.1의 임시 포트로 붙습니다. 터널이 없어도 OwlAI는 이름을 풀어 접속을 허용할 주소인지 판정한 뒤, 드라이버를 그 판정한 IP로 붙입니다. 드라이버가 이름을 다시 풀면 판정 뒤에 바뀐 주소로 붙을 수 있기 때문입니다(DNS 리바인딩). 어느 쪽이든 드라이버가 붙은 주소로 이름을 확인하면 이름으로 발급된 인증서는 거절됩니다.
그래서 드라이버에 넘기는 접속 정보에는 칸이 둘입니다. 다이얼할 주소(터널의 로컬 끝이나 판정한 IP)와 인증서에서 확인할 이름(연결에 적은 DB 호스트)입니다. TLS는 터널 위에서 DB까지 맺고, 확인 이름은 경로와 상관없이 연결의 호스트입니다.
어려운 것은 드라이버마다 이름을 따로 받는 방법이 다르거나 아예 없다는 점입니다.
| 엔진 | 다이얼 주소 | 확인 이름을 넘기는 방법 |
|---|---|---|
| PostgreSQL | hostaddr |
host에 원래 이름, sslmode=verify-full + sslrootcert |
| MariaDB·MySQL | 소켓을 여는 host |
확인 이름을 실은 SSL 컨텍스트가 소켓을 감쌀 때 그 이름을 server_hostname으로 씀 |
| Oracle | host |
protocol=tcps, 드라이버의 이름 확인(ssl_server_dn_match)은 끄고 같은 컨텍스트가 원래 이름으로 확인 |
| Tibero | 사이드카에 넘기는 IP나 터널 소켓 | 사이드카가 JSSE로 원래 이름과 체인을 확인 |
PostgreSQL은 libpq가 host와 hostaddr를 따로 받으므로 가장 단순합니다. libpq는 CA를 파일로만 받아서, 연결에 붙여 넣은 CA는 내용 해시를 이름으로 한 파일에 한 번 씁니다.
pymysql은 소켓을 연 host를 그대로 server_hostname으로 넘기고 이름을 따로 받는 인자가 없어서, 확인 이름을 SSL 컨텍스트에 실어 소켓을 감쌀 때 그 이름으로 SNI와 인증서 확인을 하게 했습니다. 컨텍스트를 주지 않으면 확인 없는 PREFERRED로 떨어지므로, TLS를 켠 연결에는 언제나 컨텍스트를 줍니다. python-oracledb thin은 받은 컨텍스트의 check_hostname을 끄고 다이얼한 host(터널이면 127.0.0.1)와 직접 비교합니다. 그래서 드라이버의 이름 확인은 끄고, 컨텍스트가 소켓을 감싸는 순간 확인을 다시 켭니다. 드라이버가 설정을 어떻게 바꿔 놓았든 체인과 이름 확인은 이 컨텍스트가 맡습니다.
Tibero는 드라이버가 사이드카에서 돌아 TLS도 사이드카가 맺습니다. 사이드카에는 설치의 신뢰 저장소가 없으므로, 백엔드가 연결의 CA나 설치 신뢰 저장소의 내용을 요청에 PEM으로 실어 보냅니다. 네 엔진 모두 믿을 CA는 연결에 붙여 넣은 CA, 없으면 앞 절의 신뢰 규칙에서 옵니다. 터널로 Tibero의 TLS 포트를 여는 방법은 「배스천에는 계정 대신 DB 포워딩만 여는 키 한 줄을 넣는다」에서 다룹니다.
설계에서 고른 것
DB 연결의 TLS는 「체인과 이름까지 확인한다」 하나로 두었습니다. libpq의 sslmode=require처럼 「암호화는 하되 확인은 하지 않는」 단계를 두면 자체 서명 인증서를 쓰는 DB가 CA 없이 붙어 등록이 쉬워집니다. 그러나 관리자는 「암호화」를 안전하다는 뜻으로 읽기 쉽고, 그 연결은 중간자 공격에 열려 있습니다. 그래서 그 편의 대신, TLS 사용이 켜진 연결은 언제나 중간자 공격을 막는다는 보증을 골랐습니다. 대가는 관리자가 사설 CA 인증서를 찾아 붙여 넣는 일입니다.
신뢰 저장소는 운영체제 저장소를 읽는 패키지(truststore) 대신 표준 변수 SSL_CERT_FILE 하나로 모았습니다. 컨테이너 안의 운영체제 저장소는 이미지에 든 배포판 번들이지 고객 호스트의 저장소가 아니라서, 사내 CA를 넣으려면 어차피 파일을 마운트해야 합니다. 대가는 새 외부 클라이언트를 더할 때마다 이 설정을 넘겨야 한다는 점이고, 그 누락은 단위 시험이 잡습니다.
형식 엄격 검사를 끄는 조건은 설정 스위치 대신 번들을 넣었다는 사실에 묶었습니다. 스위치를 두면 운영자가 끄는 시점을 명시적으로 정할 수 있지만, 운영자는 오류를 겪은 뒤에야 그 스위치를 찾습니다. 대가는 번들을 넣으면서 엄격 검사를 켜 둘 방법이 없다는 것이어서, 끄는 범위를 형식 검사 하나로 좁혔습니다. 이 동작은 AKI 없는 인증서를 내는 로컬 TLS 서버와 CONNECT 터널로 단위 시험이 지킵니다.
다이얼 주소와 확인 이름은 모든 엔진에서 따로 넘깁니다. 방법이 엔진마다 넷으로 갈렸고, 드라이버 판을 올리면 이름 확인 동작을 다시 확인해야 합니다. 그만큼 엔진별 코드가 늘지만, 터널이든 판정한 IP든 인증서 확인을 끌 일이 없습니다.
사설 CA를 쓰는 DB가 배스천 뒤에 있다면, CA 인증서를 연결에 붙여 넣고 TLS를 켠 채로 터널을 쓰면 됩니다. 인증서는 연결에 적은 호스트 이름으로 확인되므로, 인증서가 이름으로 발급됐다면 연결도 IP가 아니라 그 이름으로 등록합니다.
어떤 문제를 해결하나요?
DB 연결과 OwlDB의 다른 글

엔지니어링 노트 · DB 연결과 OwlDB
배스천에는 계정 대신 DB 포워딩만 여는 키 한 줄을 넣는다
운영 DB가 사설망 안에 있고 그 앞에 배스천 호스트(사설망으로 들어가는 SSH 서버)가 있으면, OwlAI는 배스천을 거치는 SSH 터널로 DB에 붙습니다.

엔지니어링 노트 · DB 연결과 OwlDB
RDS 목록은 액세스 키 대신 60분 뒤 만료되는 역할로 읽는다
OwlAI SaaS에서 고객의 Amazon RDS·Aurora를 연결하려면 엔드포인트·포트·엔진과 함께 DB가 공개 서브넷에 있는지, 어느 VPC·보안 그룹에 속하는지 같은 망 정보가 필요합니다.

엔지니어링 노트 · DB 연결과 OwlDB
Supabase 연결 주소는 옮겨 적는 대신 OAuth 승인으로 채운다
Supabase 대시보드에는 같은 DB로 가는 주소가 셋 있습니다.

