덤프를 문자열로 들고 있었다: 256MB 상한과 %2F 우회를 닫은 기록
목차
0. b-studio가 무엇인가
사내 도구를 만드는 AI 앱 빌더입니다. 요청을 말하면 에이전트가 코드를 쓰고, 그 코드는 격리된 샌드박스에서 실제로 돌아갑니다. Next.js든 Spring Boot든 FastAPI든 상관없고, 화면 없는 백엔드 프로젝트도 다룹니다.
이 글에서 중요한 건 두 가지 구조입니다.
- 체크포인트: 요청이 검증 게이트를 통과하면 커밋 하나로 남습니다. 파일만 되돌리면 “DB에는 적용됐지만 코드에는 없는 마이그레이션”이 남아 서비스가 뜨지 못하므로, 체크포인트마다
pg_dump로 DB 상태도 같이 남깁니다. - edge 컨테이너: 샌드박스의 유일한 출입구입니다. 밖으로 나가는 HTTP는 여기의 프록시만 통과할 수 있고, 등록한 사내 API도 여기를 거쳐야 부를 수 있습니다. 허용 목록과 감사 기록이 전부 여기 있습니다.
이번에 고친 두 문제는 각각 이 두 구조에 있었습니다.
1. 덤프를 문자열로 들고 있었다
증상
DB 덤프를 이렇게 받고 있었습니다.
const result = await sandbox.exec(service, ['pg_dump', ...], { raw: true });return { sql: result.stdout };exec는 내부적으로 execFile이고, 거기에 maxBuffer: 256 * 1024 * 1024가 걸려 있었습니다. 개발용 DB니까 넉넉하다고 판단한 값이었습니다.
문제는 “개발용”의 크기가 생각보다 빨리 자란다는 것입니다. 덤프가 256MB를 넘으면 pg_dump는 정상인데 버퍼가 터져 체크포인트 저장이 실패합니다. 되돌릴 지점이 안 남는다는 뜻이라, 조용히 넘길 문제가 아니었습니다.
고친 방향
덤프 내용을 메모리에 올리지 않습니다. 샌드박스에 두 가지를 더했습니다.
execToFile(service, command, outputFile, options) // 명령의 stdout을 파일로 직접 흘린다execFromFile(service, command, inputFile, options) // 파일을 명령의 stdin으로 직접 흘린다구현은 spawn + stream.pipeline입니다. stderr만 따로 모아 두고(그마저 1MB에서 자릅니다), stdout은 파일 스트림에 그대로 연결합니다. Docker와 Kubernetes 제공자가 같은 방식을 씁니다.
그리고 세 가지를 함께 넣었습니다.
① 임시 파일에 쓴 뒤 이름 바꾸기. 덤프 도중 실패하거나 프로세스가 죽으면 반쪽 파일이 남습니다. 그게 <체크포인트>.sql이라는 이름을 갖고 있으면, 나중에 “저장된 상태”로 취급되어 깨진 덤프를 복원하게 됩니다. 그래서 <체크포인트>.save.<pid>.<시각>.tmp에 쓰고 다 받은 뒤에 rename합니다.
② 고아 임시 파일 청소. ①을 넣으면 새 문제가 생깁니다. 프로세스가 죽으면 288MB짜리 임시 파일이 아무도 지우지 않는 채 남습니다. 여기서 쓸 만했던 건 임시 파일 이름에 pid가 이미 들어 있다는 점이었습니다.
function isRunning(pid) { if (pid === process.pid) return true; try { process.kill(pid, 0); return true; } catch (error) { // EPERM은 남의 프로세스여서 신호를 못 보낸 것이므로 살아 있다고 본다 return error.code === 'EPERM'; }}저장할 때마다 덤프 폴더를 훑어 살아 있지 않은 pid의 .tmp만 지웁니다. “한 시간보다 오래된 파일” 같은 어림짐작이 필요 없고, 지금 돌고 있는 덤프를 실수로 지울 일도 없습니다.
③ 비교도 스트리밍으로. “지금 DB가 체크포인트와 같은가”를 판단할 때도 덤프를 문자열로 읽고 있었습니다. 줄 단위로 읽어 SHA-256만 견주도록 바꿨습니다. 최신 pg_dump는 덤프마다 무작위 키로 \restrict 줄을 넣기 때문에 그 줄은 빼야 하는데, 이 규칙이 두 곳에 복제되어 있으면 한쪽만 고쳤을 때 변경을 놓칩니다. 상수 하나로 모으고, 더 이상 쓰이지 않는 쪽은 지웠습니다.
실제로 쟀다
예제 프로젝트(Next.js web + Spring Boot api + Postgres)를 실제 Docker에 띄우고, db에 1KB 행 30만 개를 넣었습니다.
| 확인 항목 | 결과 |
|---|---|
| 큰 데이터 넣기 | 300,000행 3.0초, 테이블 341MB |
| 체크포인트 덤프 | 302,292,246바이트(288.3MiB). 예전 경로가 실패하던 크기 |
| 288MiB 덤프로 되돌리기 | 절반(150,000행)을 지운 뒤 복원해 300,000행 회복, DB 행 복원까지 6.8초 |
| 임시 파일 | 끝난 뒤 .tmp 0개 |
| 세션 준비 | 28.9초 |
되돌리기 6.8초는 DB 행이 돌아온 시점까지입니다. 서비스 재시작 완료까지는 재지 않았습니다.
측정 중에 로그에 이런 줄이 찍혀 잠깐 긴장했는데, 제품 오류가 아니라 DROP DATABASE ... WITH (FORCE)가 제 폴링용 연결을 끊은 것이었습니다.
FATAL: terminating connection due to administrator command상한을 없앤 대가
크기 제한은 없어졌지만 공짜는 아닙니다. 체크포인트마다 덤프 하나가 디스크에 쌓이고, 저장·비교·복원마다 그 크기를 읽고 씁니다. 아주 큰 데이터에는 여전히 템플릿 DB나 볼륨 스냅샷이 유리합니다.
2. paths를 좁혀도 %2F로 넘어갔다
증상
edge의 허용 규칙은 호스트뿐 아니라 메서드·경로까지 좁힐 수 있습니다.
policy: allow: - { callers: [web], methods: [GET], paths: ['/api/users/*'] }*는 한 경로 구간입니다. 그래서 /api/users/7은 통과하고 /api/admin/7은 막힙니다. 그런데 직접 재 보니 이랬습니다.
new URL("http://h/v1/..%2Fadmin").pathname // "/v1/..%2Fadmin"matchPath("/v1/*", "/v1/..%2Fadmin") // trueURL은 리터럴 ..와 %2e%2e는 정규화해서 /admin으로 만들어 줍니다(그래서 그 둘은 규칙에 걸려 막힙니다). 하지만 %2F는 남깁니다. 남은 채로는 한 구간이라 /v1/*에 맞고, 프록시는 경로를 원문 그대로 상위에 보냅니다.
path: `${url.pathname}${url.search}`즉 %2F를 풀어 라우팅하는 서버라면, 경로를 좁혀 놓아도 /admin에 닿습니다.
왜 디코딩해서 비교하지 않았나
가장 먼저 떠오르는 수정은 “풀어서 비교하기”입니다. 그런데 그러면 edge가 검사한 경로와 상위 서버가 실제로 해석할 경로가 여전히 다를 수 있습니다. 상위가 인코딩을 몇 번 푸는지 edge는 알 수 없습니다. %252F는 한 번 풀면 %2F, 두 번 풀면 /입니다. 한 번만 풀고 비교하면 두 번 푸는 서버 앞에서 또 뚫립니다.
그래서 검사할 수 없으면 막는 쪽을 골랐습니다. 이 판단에는 선례가 있었습니다. HTTPS CONNECT 터널은 암호화돼 경로를 볼 수 없으므로, 경로 규칙만으로는 아예 열지 않기로 이미 정해 두었습니다. 인코딩된 구분자도 같은 범주입니다 — 검사가 성립하지 않는 입력입니다.
const ENCODED_SEPARATOR = /%(?:2f|5c|25)/i;%2f(슬래시), %5c(역슬래시), 그리고 이중 인코딩의 시작인 %25까지 봅니다.
검사 지점이 세 곳이었다
이게 이번 작업에서 가장 조심한 부분입니다. 같은 정책을 보는 입구가 셋이었습니다.
- 평문 HTTP egress 객체 규칙 (샌드박스 → 외부)
- 사내 API 프록시 (샌드박스 서비스 → edge → 사내 API)
- 스튜디오 호출자 — 에이전트 도구와 API 탐색기는 edge에 포트를 열지 않고,
docker compose exec로 edge 컨테이너 안에서 같은 정책 코드를 실행합니다
방어를 정책 판단 함수 안에 넣어 2와 3을 한 번에 덮고, 입구마다 감사 기록에 이유를 따로 남겼습니다. 호스트 전체를 연 규칙은 경로를 보지 않으므로 영향이 없습니다.
규칙을 적지 않은 정책(기본 GET·HEAD)에서도 막기로 했습니다. 그 경우 경로를 보지 않으니 “우회할 규칙”이 없지만, 감사 기록에는 /api/users/..%2Fadmin이 남고 실제로는 /admin이 불릴 수 있습니다. 기록을 믿을 수 없게 만드는 것만으로도 막을 이유가 됩니다.
실제로 쟀다
가짜 사내 API를 띄우고 받은 경로를 전부 기록하게 했습니다. 막힌 요청이 정말 상위로 가지 않았는지가 핵심이었습니다.
| 확인 항목 | 결과 |
|---|---|
| 규칙에 맞는 경로 | GET /api/users/1 → 200 |
| 인코딩된 구분자 | GET /api/users/..%2Fadmin → 403 |
| 이중 인코딩 | GET /api/users/..%252Fadmin → 403 |
| 인코딩된 역슬래시 | GET /api/users/7%5Cadmin → 403 |
| 상위 유출 | 가짜 API가 받은 경로는 /api/users/1 하나뿐 |
거부 이유는 감사 기록과 응답에 이렇게 남습니다.
경로에 인코딩된 구분자가 있어 규칙을 검사할 수 없습니다이 실검증에는 덤이 하나 있었습니다. 스튜디오 호출자 경로는 edge 스크립트 뒤에 이어 붙이는 문자열 안의 코드라 타입 검사를 받지 않습니다. 새 함수가 그 스코프에서 실제로 보이는지는 돌려 보지 않으면 알 수 없었는데, 이 경로가 바로 그 코드라 함께 확인됐습니다.
남은 한계
경로에 인코딩된 /가 정말 필요한 API는 이제 경로 규칙으로 다룰 수 없습니다. 호스트 전체를 열어야 합니다. 그리고 실제 Docker로 잰 것은 사내 API 프록시 경로이고, egress 쪽 거부는 단위 테스트로만 덮었습니다.
3. 이번에 남은 교훈
검증 하네스가 오래된 빌드를 보고 있었다. 처음 실검증이 “기능이 동작하지 않는다”는 결과를 냈는데, 원인은 코드가 아니라 next start가 이미 빌드된 산출물을 서비스한다는 점이었습니다. 소스만 고치고 검증하면 변경 이전 코드를 재게 됩니다. 사전 점검에 포트·메모리·컨테이너는 있었지만 검증 대상 코드의 신선도는 없었습니다. 빌드를 하네스 안으로 옮겨 구조적으로 막았습니다.
“최신 체크포인트로 되돌리기”는 409다. 되돌리기를 검증하려고 방금 만든 체크포인트로 복원을 걸었더니 거부됐습니다. 제품이 맞고 제 시나리오가 틀렸습니다. 되돌릴 수 있는 상태를 만들려면 그 뒤에 체크포인트가 하나 더 있어야 합니다. 덤으로 그렇게 바꾸니 쓰기·읽기·비교·복원 네 경로 모두 큰 덤프로 지나가게 됐습니다.
running 플래그만 보고 기다리면 시작 전에 통과한다. 202로 먼저 응답하고 뒤에서 진행하는 API를 기다릴 때, “처리 중이 아님”을 완료 조건으로 쓰면 요청이 시작되기도 전에 끝났다고 판단합니다. 관측 가능한 결과(체크포인트 해시가 바뀌었는지, 행 수가 돌아왔는지)를 조건으로 두면 사라집니다.
이름이 사실과 어긋나면 나중에 뚫린다. isAllowedHost라는 함수가 있었는데, 경로 규칙이 생긴 뒤로는 “이 호스트에 규칙이 있다”만 뜻하게 됐습니다. 즉 true가 “허용됨”이 아닙니다. 지금은 아무도 접근 판단에 쓰지 않지만, 이름을 믿고 쓰면 경로·메서드 검사를 통째로 건너뜁니다. hasEgressRuleFor로 바꿨습니다.
한 VM을 다른 것과 나눠 쓰고 있다면 측정에 그 사실을 적어야 한다. 6GiB Docker VM을 다른 컨테이너 11개와 공유하는 상태에서 쟀습니다. 가용 메모리가 1.8GB뿐이었고, 288MB 덤프 검증이 아무 컨테이너도 죽이지 않고 끝났다는 것까지가 측정 결과입니다. 자원 한도를 걸어 둔 값이 실제로 일을 했다는 증거이기도 합니다.
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.