들어가며
v1.0.0 릴리즈와 SQL Redaction 작업 이후, 코드베이스를 다시 훑었다. 8차에서 3건, 9차에서 3건, 추가로 golangci-lint 정리 1건 – 총 7건을 수정했다.
| 라운드 | 건수 | 핵심 |
|---|---|---|
| QA 8차 | 3건 | COPY 방화벽 우회, SET TRANSACTION false positive, CALL 캐시 |
| QA 9차 | 3건 | Pool mutex 블로킹, auth timing attack, extIsWrite 미초기화 |
| lint | 1건 | unused 함수, empty branch |
QA 8차: 프로토콜 엣지케이스 3건
1. COPY FROM STDIN 방화벽 우회 (#256)
문제. COPY users FROM STDIN은 write 쿼리다. Router가 writer로 올바르게 라우팅하지만, firewall 검사를 건너뛴다. COPY는 writeKeywords에 등록되어 있어 Classify()가 QueryWrite를 반환하고, 그대로 writer 풀에서 커넥션을 얻어 백엔드로 전달된다. 하지만 firewall의 checkNode()는 DeleteStmt, UpdateStmt, DropStmt, TruncateStmt만 검사하고 CopyStmt는 처리하지 않았다.
원인. CheckFirewall이 AST 파싱 후 checkNode()에서 노드 타입별로 분기하는데, CopyStmt case가 없었다. COPY는 PostgreSQL wire protocol에서 별도의 서브프로토콜(CopyIn/CopyOut)을 사용하므로, Simple Query 경로의 firewall 검사만으로는 COPY 데이터 스트림 자체를 차단할 수 없다. 따라서 쿼리 단계에서 차단해야 한다.
수정. firewall에 COPY 관련 규칙을 추가하고, checkNode에서 CopyStmt를 처리한다. 다만 기존 firewall은 DELETE/UPDATE without WHERE, DROP, TRUNCATE처럼 “위험한 패턴"을 차단하는 구조이므로, COPY 차단이 필요한 경우 firewall 규칙으로 설정할 수 있게 했다.
2. SET TRANSACTION false positive (#257)
문제. SET TRANSACTION ISOLATION LEVEL SERIALIZABLE을 실행하면 session compatibility guard가 FeatureSessionSet으로 감지한다. 그런데 SET TRANSACTION은 트랜잭션 범위 명령이다. SET LOCAL과 동일하게 transaction pooling에 안전하다.
원인. detectSingleStmtDependency의 case 's' 분기에서 SET LOCAL과 SET CONSTRAINTS는 이미 제외하고 있었지만, SET TRANSACTION은 빠져있었다. AST 파서 쪽(detectNodeDependency)의 VariableSetStmt 처리에서도 name == "TRANSACTION"을 확인하지 않았다.
수정. string parser와 AST parser 양쪽에 SET TRANSACTION 예외를 추가했다:
// string parser (detectSingleStmtDependency)
if j+11 < n && eqFoldN(rest[j:j+11], "TRANSACTION") {
return SessionDependencyResult{} // SET TRANSACTION -- transaction-scoped
}
// AST parser (detectNodeDependency)
name := strings.ToUpper(n.VariableSetStmt.GetName())
if name == "TRANSACTION" || name == "CONSTRAINTS" {
return SessionDependencyResult{}
}
3. CALL 프로시저 캐시 (#258)
문제. CALL refresh_materialized_views()처럼 부작용이 있는 프로시저 호출이 캐시될 수 있다. CALL은 writeKeywords에 등록되어 있어 writer로 라우팅되지만, 캐시 무효화 대상 테이블을 추출하지 못하면 이전 캐시 결과가 stale하게 남는다.
원인. extractTablesFromStmt에서 CALL 문은 테이블 추출 대상이 아니었다. CALL이 writer로 가더라도, 해당 프로시저가 수정하는 테이블의 캐시를 무효화하려면 테이블 이름 추출이 필요하다. 그러나 CALL의 인자에서 테이블 이름을 정확히 추출하는 것은 불가능하므로, 문서화를 통해 CALL 사용 시 캐시 주의사항을 명시했다.
수정. CALL 문은 본질적으로 캐시 안전하지 않으므로, 캐시 대상에서 명시적으로 제외하도록 처리했다. 문서에 CALL 프로시저가 테이블을 수정하는 경우 POST /admin/cache/flush/{table}로 수동 무효화하거나 Redis Pub/Sub 무효화를 사용해야 한다고 기술했다.
QA 9차: 동시성/보안 3건
4. Pool mutex 블로킹 (#262)
문제. healthCheck()에서 min connections 보충을 위해 newConn()을 호출할 때, 네트워크 I/O 동안 mutex를 잡고 있으면 Acquire()와 Release()가 모두 블록된다.
원인. 초기 구현에서는 healthCheck() 전체가 단일 mu.Lock() 안에 있었다. expired/idle 커넥션 정리까지는 빠르지만, newConn()의 TCP 다이얼은 수 초가 걸릴 수 있다. 그 동안 모든 풀 연산이 멈춘다.
수정. newConn() 호출 전에 unlock하고, 완료 후 다시 lock하는 패턴으로 변경했다:
func (p *Pool) healthCheck() {
p.mu.Lock()
// ... expired/idle 정리 ...
// Replenish to min connections -- unlock during dial
for p.numOpen < p.cfg.MinConnections {
p.numOpen++ // reserve slot
p.mu.Unlock()
conn, err := p.newConn()
p.mu.Lock()
if err != nil {
p.numOpen--
slog.Error("healthcheck: replenish connection", "error", err)
break
}
if p.closed {
p.numOpen--
p.mu.Unlock()
conn.Close()
return
}
p.idle = append(p.idle, conn)
}
p.mu.Unlock()
}
핵심은 numOpen++로 슬롯을 예약한 뒤 unlock하는 것이다. 다이얼 실패 시 numOpen--로 되돌리고, 다이얼 중 Close()가 호출된 경우도 처리한다. 이 패턴은 Acquire()의 “can we create a new connection?” 경로에서 이미 사용하고 있었다.
5. Auth timing attack (#263)
문제. frontendAuth에서 MD5 비밀번호를 비교할 때 ==를 사용하면 첫 번째 불일치 바이트에서 즉시 반환한다. 응답 시간 차이를 측정하면 한 바이트씩 올바른 해시를 추론할 수 있다(timing attack).
원인. 원래 코드가 clientHash == expectedHash 단순 비교였다.
수정. crypto/subtle.ConstantTimeCompare로 교체했다:
if subtle.ConstantTimeCompare([]byte(clientHash), []byte(expectedHash)) != 1 {
s.sendError(clientConn, "password authentication failed for user \""+username+"\"")
return fmt.Errorf("MD5 password mismatch for user %q", username)
}
ConstantTimeCompare는 입력 길이에만 비례하는 일정한 시간이 소요되므로 타이밍 사이드채널을 차단한다.
6. extIsWrite 미초기화 (#264)
문제. Extended Query 프로토콜에서 여러 배치를 연속 처리할 때, 첫 번째 배치가 write 쿼리면 extIsWrite = true가 설정된다. Sync 처리 후 extIsWrite = false로 리셋하지만, 특정 경로에서 리셋이 누락되면 다음 read 배치가 writer로 잘못 라우팅된다.
원인. relayQueries의 Sync 처리 끝에서 배치 상태를 리셋하는 코드가 있다:
// Reset batch state
extBuf = extBuf[:0]
extRoute = router.RouteReader
extTxStart, extTxEnd = false, false
extIsWrite = false
extHasExecute = false
그러나 session compatibility block이나 read-only 모드 거부 등 early return 경로에서 extIsWrite를 리셋하지 않는 브랜치가 있었다. 이 경우 다음 배치의 extIsWrite가 이전 값을 그대로 가지고 있게 된다.
수정. 모든 early return/continue 경로에서 extIsWrite = false를 포함한 전체 배치 상태 리셋을 보장했다. session blocked 경로와 read-only reject 경로 양쪽에 누락된 리셋을 추가했다.
golangci-lint 정리 (#266)
golangci-lint run에서 발견된 경미한 이슈를 정리했다:
- unused 함수 제거: 리팩토링 과정에서 호출처가 사라진 헬퍼 함수 삭제
- empty branch 수정:
if분기의 빈 블록을 정리하거나 주석으로 의도를 명시
검증
$ go build ./... OK
$ go vet ./... OK
$ golangci-lint run OK
$ go test ./internal/...
ok internal/admin 0.013s
ok internal/audit 1.015s
ok internal/cache 0.004s
ok internal/config 7.535s
ok internal/dataapi 0.012s
ok internal/digest 0.007s
ok internal/metrics 0.003s
ok internal/mirror 0.008s
ok internal/pool 3.013s
ok internal/protocol 0.003s
ok internal/proxy 0.004s
ok internal/resilience 0.103s
ok internal/router 0.009s
ok internal/telemetry 0.003s
14/14 PASS
마무리
7건을 성격별로 정리하면:
- 프로토콜 엣지케이스 3건 (8차): COPY 방화벽 우회, SET TRANSACTION false positive, CALL 캐시. 모두 “정상적인 SELECT/INSERT/UPDATE는 잘 되지만 덜 흔한 구문에서 빈틈이 있는” 유형이다.
- 동시성/보안 3건 (9차): Pool mutex 블로킹은 고부하 환경에서만 드러나고, timing attack은 공격자가 네트워크 지연을 정밀 측정해야 하며, extIsWrite 미초기화는 특정 Extended Query 시퀀스에서만 재현된다. 모두 정상 경로에서는 문제가 없지만, 엣지 조건에서 조용히 깨지는 패턴이다.
- lint 1건: 코드 위생.
8차의 공통점은 PostgreSQL의 다양한 SQL 구문이 프록시의 분류/검사 로직에 빈틈을 만든다는 것이다. COPY, SET TRANSACTION, CALL – 모두 표준적인 구문이지만 프록시가 특별 처리해야 하는 이유가 각각 다르다. 9차는 비기능 요구사항(성능, 보안, 상태 일관성)에 집중했다. 기능은 정상 동작하지만 품질 속성에서 문제가 있는 유형이다.
💬 댓글