<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>보안 on 얼쑤 · Team Edo</title><link>https://team-edo.github.io/categories/security/</link><description>Recent content in 보안 on 얼쑤 · Team Edo</description><generator>Hugo</generator><language>ko-kr</language><lastBuildDate>Tue, 03 Nov 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://team-edo.github.io/categories/security/index.xml" rel="self" type="application/rss+xml"/><item><title>206 응답 뒤에 숨은 CORS 오류</title><link>https://team-edo.github.io/posts/2026/11/cors-after-audio-redirect/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/cors-after-audio-redirect/</guid><description>&lt;p>얼쑤의 모범 연주 음원 API를 Swagger에서 확인하다가 &lt;code>Failed to fetch&lt;/code>를 만났습니다. 같은 음원의 처음 2바이트를 &lt;code>curl&lt;/code>로 요청하니 저장소는 HTTP 206을 반환했습니다. 저장소가 바이트를 보내고도 브라우저에서 읽을 수 없는 이유를 찾으려면, 처음 요청한 API와 음원을 실제로 돌려주는 서버를 나눠 봐야 했습니다.&lt;/p>
&lt;h2 id="302-뒤의-응답을-확인했다">302 이후의 응답 추적&lt;/h2>
&lt;p>Swagger 페이지와 음원 API는 같은 출처에 있었습니다. API는 곡과 음원의 게시 상태를 확인한 뒤 HTTP 302의 &lt;code>Location&lt;/code> 헤더에 R2 서명 URL을 담았습니다. 브라우저가 리다이렉트를 따라가면 음원 본문은 다른 출처인 R2에서 내려옵니다. 그 응답에는 Swagger 페이지의 출처를 허용하는 &lt;code>Access-Control-Allow-Origin&lt;/code>이 없었습니다. 브라우저는 저장소 응답을 받았지만 페이지의 자바스크립트에 넘기지 않았습니다. &lt;a href="https://fetch.spec.whatwg.org/#cors-check">Fetch 표준의 CORS 검사&lt;/a>&lt;/p></description></item><item><title>Docker 내부망으로 연결한 분석 서버</title><link>https://team-edo.github.io/posts/2026/10/analysis-service-network-boundary/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/analysis-service-network-boundary/</guid><description>&lt;p>얼쑤의 백엔드는 녹음 분석을 요청할 때 파일을 읽을 수 있는 서명 URL을 넘깁니다. 분석 서버는 그 주소에서 녹음을 내려받아 채점합니다. 이 서버를 운영에 추가할 때 외부에서 직접 호출할 수 있는 포트가 필요한지 먼저 살폈습니다. 앱은 이미 백엔드로 녹음을 올리고 결과를 조회하고 있어서, 분석 서버는 백엔드에서만 호출하면 됐습니다.&lt;/p>
&lt;h2 id="녹음-주소를-받는-api의-접근-범위">녹음 URL을 받는 API의 접근 범위&lt;/h2>
&lt;p>당시 분석 API는 호출자를 인증하거나 다운로드 목적지의 호스트를 제한하지 않았습니다. URL이 HTTP나 HTTPS로 시작하는지는 확인했지만, 사설 주소도 그 조건을 충족합니다. 외부 호출자가 이 API에 요청을 보낼 수 있다면 서버가 대신 내부 주소에 접속하게 할 수 있습니다. 응답 차이만으로도 어떤 주소에 연결이 됐는지 짐작할 여지가 생깁니다.&lt;/p></description></item><item><title>Lax 쿠키에 Origin·JSON 검사를 더한 이유</title><link>https://team-edo.github.io/posts/2026/10/layer-cookie-csrf-request-checks/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/layer-cookie-csrf-request-checks/</guid><description>&lt;p>서비스에서는 브라우저와 네이티브 앱이 로그인 쿠키를 사용했습니다. 브라우저는 조건이 맞으면 쿠키를 요청에 자동으로 실으므로, 다른 페이지에서 시작된 요청도 상태를 바꿀 수 있는지 확인해야 했습니다. 팀에서 쿠키에 &lt;code>SameSite=Lax&lt;/code>를 적용하고, 상태를 바꾸는 메서드에 출처와 본문 검사를 추가했습니다.&lt;/p>
&lt;h2 id="쿠키와-요청-필터가-각각-보는-것">쿠키와 요청 필터의 역할&lt;/h2>
&lt;p>&lt;code>Lax&lt;/code> 쿠키는 다른 사이트의 일반적인 POST에 실리지 않습니다. 다른 사이트에서 들어오는 최상위 GET 이동에는 실릴 수 있고, 호스트가 달라도 브라우저에서 같은 사이트로 분류될 수 있습니다. 상태 변경 API는 POST·PUT·PATCH·DELETE를 사용하고, 필터는 &lt;code>Origin&lt;/code>이 있는 요청에 대해 허용한 출처 또는 서비스의 호스트 규칙을 검사합니다. 네이티브 앱처럼 헤더가 없는 경우에는 본문과 인증 검사로 이어집니다. &lt;a href="https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html">HTTP Working Group의 SameSite 사양 초안&lt;/a>&lt;/p></description></item><item><title>MySQL 모니터링 계정에 회원 테이블 9개 열만 허용</title><link>https://team-edo.github.io/posts/2026/10/mysql-monitoring-column-privileges/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/mysql-monitoring-column-privileges/</guid><description>&lt;p>모니터링 화면에는 회원 수와 최근 가입 시점이 필요했습니다. 그 화면을 만드는 SQL에는 이메일이나 인증 토큰이 필요 없었지만, 모니터링 계정의 데이터베이스 전체 조회 권한으로는 그런 값도 읽을 수 있었습니다. 화면 쿼리에서 열을 빼는 것과 계정이 그 열을 읽을 수 없는 것은 다른 문제였습니다.&lt;/p>
&lt;h2 id="넓은-권한을-회수하고-필요한-열을-적었다">전체 조회 권한을 열 단위로 축소&lt;/h2>
&lt;p>MySQL은 계정에 부여된 조회 권한을 합칩니다. 데이터베이스 전체에 &lt;code>SELECT&lt;/code>가 남아 있는 상태에서 특정 열만 다시 &lt;code>GRANT&lt;/code>하면, 이전 권한으로 여전히 모든 열을 읽을 수 있습니다. 먼저 넓은 권한을 회수한 뒤, 모니터링에 필요한 테이블과 열을 다시 부여했습니다. 회원 테이블에서는 &lt;strong>9개 열&lt;/strong>만 열었습니다. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/grant.html">MySQL의 열 단위 GRANT 문서&lt;/a>&lt;/p></description></item><item><title>분석 오류 응답과 로그에서 서명 URL 숨기기</title><link>https://team-edo.github.io/posts/2026/10/keep-signed-urls-out-of-analysis-errors/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/keep-signed-urls-out-of-analysis-errors/</guid><description>&lt;p>얼쑤의 분석 서버는 요청에 담긴 서명 URL로 녹음을 내려받습니다. URL에 읽기 권한이 담겨 있어 유효 시간 안에는 주소만으로 파일을 요청할 수 있습니다. 분석 API를 구현할 때 이 주소가 어디에 남는지 점검했습니다. 제가 작성한 코드에서 URL을 로그에 쓰지 않아도 두 곳에 남을 수 있었습니다. 요청을 검증하는 프레임워크의 응답과 녹음을 내려받는 HTTP 라이브러리의 로그입니다.&lt;/p>
&lt;h2 id="bpm을-틀리게-보냈을-때는-문제가-드러나지-않았다">BPM을 누락해야 드러난 응답 노출&lt;/h2>
&lt;p>당시 FastAPI 기본 검증 응답은 HTTP 422와 함께 오류가 난 입력값을 &lt;code>input&lt;/code>에 넣었습니다. &lt;code>bpm&lt;/code>에 0을 보내면 오류의 입력값도 0이었습니다. 이 경우만 검사하면 기존 처리기를 써도 서명 URL이 응답에 없었습니다. &lt;code>bpm&lt;/code>을 아예 빼자 검증기가 그 필드가 있어야 할 요청 객체를 오류에 담았습니다. 같은 객체의 &lt;code>audioUrl&lt;/code>도 응답에 실릴 수 있었습니다.&lt;/p></description></item><item><title>빈 로그에서도 통과하는 비밀값 검사</title><link>https://team-edo.github.io/posts/2026/10/capture-isolated-python-logs/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/capture-isolated-python-logs/</guid><description>&lt;p>얼쑤의 분석 API는 시도마다 처리 결과를 한 줄로 남깁니다. 개발 환경에 따라 루트 로거에 이미 처리기가 붙어 있을 수 있어서, 모듈 처리기에서도 같은 줄을 출력하면 기록이 두 번 나타날 수 있습니다. 모듈의 처리기는 유지하고 &lt;code>propagate = False&lt;/code>를 설정해 그 기록을 루트 쪽으로 다시 보내지 않도록 했습니다.&lt;/p>
&lt;p>이 설정을 바꾸자 테스트에서 별도의 경로를 고려해야 했습니다. pytest의 &lt;code>caplog&lt;/code>가 루트에 붙어 있는 상태에서는 이 모듈이 로그를 남겨도 캡처에 들어오지 않습니다. 검사 내용이 “서명값이 로그에 없다”뿐이면 빈 캡처에서도 통과합니다. 확인하려던 로거에 실제 이벤트가 들어오는지 먼저 확인해야 했습니다.&lt;/p></description></item><item><title>녹음 저장소를 바꿔도 지킨 15분 읽기 권한</title><link>https://team-edo.github.io/posts/2026/10/recording-storage-signed-read/</link><pubDate>Tue, 27 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/recording-storage-signed-read/</guid><description>&lt;p>얼쑤에서 연습 녹음을 받으면 백엔드는 원본을 객체 저장소에 보관하고, 분석 서버는 필요한 파일을 가져와 처리합니다. 처음에는 서울 리전과 신규 계정 크레딧을 고려해 Google Cloud Storage(GCS)를 쓰는 설계를 잡았습니다. 다음 날에는 크레딧이 끝난 뒤의 비용과 다운로드 조건, 저장소를 바꿀 때의 구현 부담을 다시 검토해 S3 호환 API를 쓰는 Cloudflare R2로 계획을 바꿨습니다.&lt;/p>
&lt;h2 id="분석-서버에는-15분-동안-한-파일을-읽을-권한만-준다">한 파일에 15분 읽기 권한&lt;/h2>
&lt;p>분석 서버가 해야 할 일은 전달받은 녹음 한 건을 읽는 일이었습니다. 그래서 백엔드가 특정 객체에 대해 &lt;code>GET&lt;/code> 요청만 &lt;strong>15분(900초)&lt;/strong> 동안 허용하는 URL을 서명하고, 분석 서버는 그 주소로 원본을 내려받도록 했습니다. 저장소의 자격 증명은 서명하는 백엔드에만 둡니다. 유효한 URL을 가진 사람도 파일을 읽을 수 있으므로 주소 자체를 노출하지 않는 것도 이 설계의 일부입니다. &lt;a href="https://docs.cloud.google.com/storage/docs/access-control/signed-urls">Google Cloud Storage 서명 URL&lt;/a> · &lt;a href="https://developers.cloudflare.com/r2/api/s3/presigned-urls/">Cloudflare R2 서명 URL&lt;/a>&lt;/p></description></item></channel></rss>