레시피 수정 화면의 조리 단계 사진 칸은 문자열 하나를 서버로 보낸다. 이미 올려 둔 사진의 경로다. 그 문자열을 서버가 어디까지 믿고 있는지, 나는 그때까지 한 번도 따져 본 적이 없었다.
식단서랍은 계속 자라 왔다. 레시피 서랍, 월간 메뉴, 공유 달력, 장보기, 레시피 API, 카카오 로그인. 기능을 붙일 때마다 테스트를 쓰고 화면을 눌러 봤다. 그런데 그 확인은 전부 "제대로 쓰는 사람"의 확인이었다. 폼이 보내는 값을 바꿔 보내는 사람, 남의 데이터를 보려고 번호를 바꿔 보는 사람은 그 안에 없었다.
그래서 기능 개발을 멈추고 처음부터 다시 읽었다. 컨트롤러가 받는 값 하나하나를 "이걸 조작하면 무엇이 되는가"로 물었다. 열한 가지가 나왔다. 열 가지는 바로 고쳤고, 하나는 화면 전체에 영향이 커서 잠깐 미뤘다가 따로 마무리했다.
사진 경로 하나가 서버 파일을 가리킬 때
레시피를 수정하면 서버는 조리 단계를 새로 만든다. 사진을 그대로 둔 단계는 화면이 보낸 기존 경로를 받아 쓰는데, 그게 정말 이 레시피의 사진인지 확인하지 않았다.
그 경로는 두 군데로 흘러간다. 남의 레시피를 내 서랍에 담을 때 사진을 복제하는 곳과, 사진을 지우는 곳. 둘 다 웹 경로를 wwwroot 아래 실제 경로로 바꿔 그대로 파일 작업을 했다. 경로에 상위 폴더로 올라가는 조각이 섞이면 업로드 폴더 밖의 파일이 복제되거나 지워질 수 있었다. 사진 하나 다루는 코드가 서버 파일 시스템으로 통하는 문이었다.
고칠 곳은 두 겹이었다. 먼저 입구에서, 화면이 보낸 경로는 이 레시피에 원래 있던 단계 사진 목록에 들어 있을 때만 받는다. 그다음 파일을 실제로 만지는 곳에서, 경로를 끝까지 풀어 본 뒤 업로드 폴더 바로 아래 파일이 아니면 거부한다.
private string? ResolveUploadPath(string webPath)
{
var folder = Path.GetFullPath(Path.Combine(_env.WebRootPath, UploadFolderRelative));
var rel = webPath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
string full;
try { full = Path.GetFullPath(Path.Combine(_env.WebRootPath, rel)); }
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{ return null; }
return string.Equals(Path.GetDirectoryName(full), folder, StringComparison.OrdinalIgnoreCase)
? full : null;
}
핵심은 문자열을 검사하지 않는다는 데 있다. 패턴으로 걸러 내려 하면 언제나 빠지는 모양이 생긴다. 대신 Path.GetFullPath 로 운영체제가 실제로 해석할 경로를 먼저 만들고, 그 부모 폴더가 업로드 폴더와 정확히 같은지만 본다. 상위 폴더 이동이든 절대 경로든 하위 폴더든 같은 이유로 떨어진다. 복제와 삭제 양쪽에 걸었으니, 이미 DB 에 들어간 이상한 경로도 파일 작업 직전에서 막힌다. 입구의 검사가 빠져도 출구가 한 번 더 버틴다.
달력에 올리면 열리는 문
두 번째는 하나하나 보면 다 맞는 규칙 두 개가 만나는 자리였다.
비공개 레시피는 주인만 본다. 다만 달력에 올라간 레시피는 비공개여도 상세가 열린다. 달력을 공유한 가족이 내 비공개 레시피로 짠 저녁 메뉴를 눌러 봐야 하기 때문이다. 메뉴 추가 창의 선택 목록도 처음부터 "보는 사람과 달력 주인의 것, 공개, 공용"만 보여 줬다.
빠진 건 서버였다. 메뉴 추가·수정 액션은 레시피 번호가 존재하는지만 봤다. 선택 목록은 화면의 편의일 뿐이라, 요청에 다른 번호가 들어오면 남의 비공개 레시피가 달력에 올라갔고, 올라갔으니 상세가 열렸다.
private IQueryable<Recipe> PickableRecipes(CalendarAccess access)
=> _db.Recipes.AsNoTracking().Where(r =>
r.UserId == access.ViewerUserId || r.UserId == access.OwnerUserId
|| r.IsPublic || r.IsOfficial);
public async Task<int> AddMenuAsync(MenuViewModel model, CalendarAccess access, CancellationToken ct = default)
{
var requested = model.RecipeIds.Distinct().ToList();
var allowed = await PickableRecipes(access)
.Where(r => requested.Contains(r.Id))
.Select(r => r.Id)
.ToListAsync(ct);
foreach (var rid in requested.Where(allowed.Contains)) { /* 메뉴 저장 */ }
}
선택 목록이 쓰던 범위를 서버의 규칙으로 끌어올렸다. 달력 주인 아이디만 받던 메서드가 보는 사람과 주인을 함께 담은 CalendarAccess 를 받는다. 화면이 보여 주는 범위와 서버가 받아 주는 범위가 같은 쿼리에서 나오니 어긋날 일이 없다.
추가는 못 보는 번호를 조용히 건너뛰고, 수정은 거부한다. 수정은 레시피를 바꿀 때만 검사한다. 볼 수 있던 레시피가 나중에 비공개로 바뀌었다고 이미 올라간 메뉴의 날짜나 끼니를 못 옮기게 되면, 그건 보안이 아니라 고장이다.
나머지 여덟, 그리고 미뤄 둔 하나
남은 것들은 하나씩 보면 짧다. 원리만 적는다.
| 무엇이 문제였나 | 어떻게 막았나 |
|---|---|
| 공유 멤버 검색이 부분일치로 이메일 원문을 돌려줘, 짧은 검색을 반복하면 회원 정보를 모을 수 있었다 | 이메일·전화는 정확히 일치할 때만, 이름은 두 글자 이상. 응답 이메일은 가림 |
| 로그인 비밀번호 무차별 대입에 방어가 없었다 | Identity 계정 잠금 + 로그인 요청에 IP 별 제한 |
| 영상 URL 에 임의 스킴이 저장되고 상세 화면이 그대로 링크로 냈다 | 폼·저장·출력 세 곳에서 http(s) 만 통과 |
| 보안 헤더가 없고 서버 정보가 응답 헤더에 드러났다 | nosniff·Referrer-Policy·기본 CSP 추가, 서버 정보 헤더 제거, 이미지가 아닌 업로드 거부 |
| X-Forwarded-For 를 누가 보내든 믿어 IP 를 위조할 수 있었다 | 신뢰 범위를 루프백으로, 프록시는 설정으로만 등록 |
| 장보기 체크 토글 하나에 CSRF 검사가 빠져 있었다 | 검사 특성 추가 (화면은 이미 토큰을 보냄) |
| 가입 오류 문구로 이메일 가입 여부를 알 수 있었다 | 새 가입과 같은 화면, 실제 안내는 그 주소로 메일 |
| API 제한이 키별이라 매번 다른 가짜 키로 피할 수 있었다 | /api 전체에 IP 별 상한을 함께 |
가입 오류 문구는 그냥 지우는 걸로 끝나지 않았다. "가입할 수 없습니다"로 뭉뚱그리면 정상 사용자가 이유를 모른다. 그래서 화면은 누구에게나 같게 보여 주고, 진짜 이유는 그 주소의 주인만 받는 메일로 보냈다. 화면만 보는 사람에게는 두 경우가 구별되지 않는다.
X-Forwarded-For 는 다른 항목과 엮여 있었다. 로그인과 API 의 요청 제한이 둘 다 IP 로 나누는데, 그 IP 를 요청하는 쪽이 정할 수 있다면 제한은 없는 것과 같다. 로그인 기록의 IP 도 마찬가지다.
그리고 미뤄 둔 하나가 CSP 의 script-src 였다.
붙은 스크립트만 실행되게
보안 헤더를 걸 때 CSP 에서 스크립트 항목만 뺐다. 화면 곳곳에 onclick= 같은 인라인 핸들러가 있었고, Alpine.js 는 x-data·@click 식을 new Function 으로 실행한다. 그냥 걸면 화면이 멈춘다.
택한 방식은 nonce 다. 요청마다 예측할 수 없는 값을 만들어 정책에 적고, 서버가 렌더링한 <script> 에만 같은 값을 붙인다. 브라우저는 값이 맞는 스크립트만 실행한다. 페이지에 끼어든 스크립트는 그 요청의 nonce 를 모르니 실행되지 않는다.
public static string GetCspNonce(this HttpContext context)
{
if (context.Items.TryGetValue(NonceKey, out var existing) && existing is string nonce)
return nonce;
nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
context.Items[NonceKey] = nonce;
return nonce;
}
public static string ScriptPolicy(string nonce)
=> $"script-src 'nonce-{nonce}' 'strict-dynamic' 'unsafe-eval' 'self' https:; report-uri {ReportPath}";
nonce 는 HttpContext.Items 에 한 번만 만들고, 헤더를 쓰는 미들웨어와 <script> 에 붙이는 쪽이 같은 값을 꺼내 쓴다. 16진수로 만든 건 base64 의 +, /, = 처럼 HTML 속성에서 이스케이프될 문자를 피하려는 것이다. 붙이는 일은 script 태그 전체에 걸린 태그 헬퍼 하나가 한다. 뷰마다 nonce 를 적을 필요가 없고, 나중에 넣는 스크립트도 빠지지 않는다. 사람이 기억해야 할 규칙을 하나 없앤 것이 이 방식의 가장 큰 장점이었다.
'strict-dynamic' 은 nonce 스크립트가 불러오는 모듈까지 믿어 주고, 뒤의 'self' https: 는 이 키워드를 모르는 옛 브라우저용 대체다. 'unsafe-eval' 은 Alpine 때문에 남겼다. 이름은 꺼림칙하지만 인라인 핸들러와 주입된 <script> 를 막는 효과는 그대로다.
인라인 핸들러는 전부 걷어냈다. 삭제 확인창은 data-confirm, 바꾸면 바로 제출하는 선택 상자는 data-autosubmit, 인쇄 버튼은 data-print 로 바꾸고 site.js 가 이벤트를 위임받아 처리한다. 마크업에는 의도만 남고 실행은 한 파일에 모였다.
이런 정책은 강제하는 순간 어딘가 조용히 깨진다. 그래서 위반을 /csp-report 로 받아 로그로 남기고, 설정 하나로 보고만 하는 모드로 돌릴 수 있게 했다. 다만 frame-ancestors 는 보고 전용 모드에서 동작하지 않아서, 스크립트와 상관없는 항목은 어느 모드에서든 강제 헤더에 남긴다.
다시 읽으면 또 나온다
열한 가지를 닫고 나서도 다시 읽을 때마다 조금씩 더 나왔다.
관리자 화면의 레시피 검색 폼은 GET 인데 CSRF 토큰을 넣고 있어서, 토큰이 주소에 실려 로그와 Referer 로 샐 수 있었다. GET 폼에서는 토큰을 뺐다.
외부 CDN 스크립트 하나는 메이저 버전만 적혀 있어 새 버전이 나오면 검증 없이 운영 화면에 들어왔다. 'strict-dynamic' 이 nonce 스크립트가 불러오는 것까지 믿어 주니, 그 스크립트가 내가 고르지 않은 버전으로 바뀌게 두는 건 CSP 의 신뢰를 남에게 넘기는 일이었다. 외부 스크립트 넷을 정확한 버전으로 받아 wwwroot/lib/vendor 에 두고 같은 출처에서 제공했다. 남은 글꼴 CSS 에는 integrity 값을 붙였다.
마지막은 검색엔진이었다. 토큰 링크로 여는 공유 달력이 어딘가 게시되면 이름과 식단이 검색에 잡힐 수 있었다. robots.txt 로 막으면 검색엔진이 noindex 를 읽지 못해 주소만 색인될 수 있어서, 공유 달력과 공유 문맥 레시피 응답에 X-Robots-Tag: noindex, nofollow 헤더를 붙였다.
돌아보면 열한 가지 중 어느 것도 어려운 코드가 아니었다. 경로를 풀어서 폴더를 비교하고, 선택 목록의 쿼리를 서버로 옮기고, 헤더 몇 줄을 적는 일이다. 어려웠던 건 그걸 보는 눈이었다. 기능을 만드는 동안의 나는 늘 "이게 되는가"를 물었고, 그 질문으로는 이 구멍들이 보이지 않았다. "이걸 바꿔 보내면 무엇이 되는가"를 묻는 시간은 따로 떼어 내야만 생겼다.
다음 편은 다시 기능 쪽으로 돌아간다. 배포했는데 폰에는 예전 화면이 남아 있던, 서비스 워커와 캐시 이야기다.