G-6: 프론트엔드 보안 ③ — 바깥과의 경계
목차 46
안녕하세요, 홍순구 튜터입니다.
이런 장면을 하나 그려볼게요.
여러분이 우리 인스타 클론에 로그인해 둔 채로, 다른 탭에서 처음 보는 사이트를 엽니다. 광고 링크를 눌렀거나, 누가 보내준 주소를 열었거나요. 그 사이트에는 버튼이 하나 있고 "무료 이벤트 응모"라고 적혀 있어요. 눌러봅니다.
아무 일도 안 일어난 것처럼 보입니다. 그런데 그 순간 그 사이트는 여러분 브라우저를 시켜 우리 앱으로 요청을 하나 보냈습니다. 여러분의 로그인 쿠키를 달고서요.
지난 시간에 우리는 브라우저에게 규칙을 미리 알려뒀습니다. 우리 페이지에서 남의 코드가 도는 걸 막는 규칙이었죠. 방금 장면에서는 코드가 우리 페이지에서 안 돌고, 요청이 바깥에서 날아왔어요. 우리가 붙인 헤더는 여기에 손을 못 댑니다.
오늘은 그 바깥과의 경계를 봅니다. 먼저 지난 시간에 세운 규칙이 실제로 무엇을 막았는지 눈으로 확인하고, 그다음 그 규칙이 원리적으로 못 막는 것으로 넘어갑니다.
오늘의 여정
1. 지난 시간의 규칙이 무엇을 막았나 → 막긴 막는데 구멍은 그대로다
2. 남의 사이트가 대신 보낸다 → 하나는 이미 막혀 있고 하나는 열려 있다
3. 열려 있는 쪽은 우리가 만든 문이다 → 쿠키도 없이 통과한다
4. 그 문에 문지기를 세운다 → 쿠키로는 못 세운다
5. 그 문지기가 쥔 비밀은 어디에 두나
💡 오늘 수업의 핵심 — "막는 방법은 무엇을 확인하느냐로 갈립니다. 사람을 확인하는 문과 서버를 확인하는 문은 다릅니다"
🎯 학습 목표
- 지난 시간에 세운 규칙이 실제로 무엇을 막고 무엇을 못 막는지 직접 재보고, 막는 것과 지우는 것의 차이를 구분합니다.
- 남의 사이트가 우리 앱으로 요청을 대신 보내는 상황을 재현하고, 서버 액션과 우리가 만든 주소가 왜 다르게 동작하는지 확인합니다.
- 우리가 연 주소에 문지기를 세우고, 그 문지기가 쥐는 값을 어디에 둬야 하는지 정합니다.
Step 1: "그 규칙이 무엇을 막았을까요"
지난 시간의 약속부터 갚습니다
지난 시간 마지막에 이렇게 말씀드렸어요. 규칙을 세우긴 했는데 그게 실제로 무엇을 막고 있는지는 아직 확인 안 했다고요.
확인해봅시다. 두 시간 전에 댓글로 밀어 넣었던 그 공격 문자열 기억하시죠.
<script>document.title = "털렸습니다"</script>사진 좋네요
이걸 다시 댓글로 넣고 홈 화면을 열어보세요. 연습용 서버가 메모리에 저장하니까, 서버를 새로 켰다면 다시 넣어야 합니다.
화면을 보면 탭 제목이 그대로예요. 인스타그램 클론입니다.
여기서 결론을 내리면 안 되는데, 지금 막고 있는 게 누구인지 아직 모르거든요.
지금은 두 겹입니다
두 시간 전에 우리는 걸러내는 코드를 넣었습니다. 그리기 직전에 위험한 태그를 지우는 코드였죠. 서버가 보낸 HTML을 직접 확인해보면 이렇습니다.
curl -s -b cookie.txt http://localhost:3000/ | grep -c "털렸습니다"
0
아예 없습니다. 태그가 통째로 지워져서 나갔어요. 이 상태에서는 지난 시간에 세운 규칙이 할 일이 없습니다. 막을 대상이 도착조차 안 하니까요.
지금 화면이 멀쩡한 건 두 겹 중 첫 번째 겹 덕분이라, 두 번째 겹이 일하는 모습을 보려면 첫 번째 겹에 구멍을 내야 해요.
걸러내는 코드를 잠깐 빼봅니다
댓글을 그리는 곳에서 걸러내는 함수를 잠시 빼보세요. 실습이 끝나면 반드시 되돌려야 합니다.
// apps/web-next/app/components/CommentSection.tsx
// 잠깐 확인하려고 걸러내기를 뺀 상태입니다. 확인 후 반드시 되돌리세요.
<span dangerouslySetInnerHTML={{ __html: comment.content }} />
이제 서버가 보내는 HTML을 다시 봅니다.
curl -s -b cookie.txt http://localhost:3000/ | grep -c "털렸습니다"
1
태그가 그대로 실려 나갑니다. 두 시간 전의 그 위험한 상태로 돌아온 거예요.
그럼 이제 탭 제목이 바뀔까요? 브라우저에서 홈 화면을 새로고침해보세요.
안 바뀝니다. 제목은 여전히 인스타그램 클론입니다.
무엇이 남아 있는지 봅니다
여기가 오늘의 첫 번째 요점입니다. 개발자 도구 콘솔에서 확인해보세요.
document.title
// '인스타그램 클론'
[...document.querySelectorAll('script')]
.filter(s => s.textContent.includes('털렸습니다'))
.length
// 1
제목은 안 바뀌었는데 그 태그는 문서 안에 그대로 있습니다.
지난 시간에 세운 규칙은 위험한 것을 지우지 않아요. 브라우저에게 "이건 실행하지 마라"라고 말할 뿐이라, 결과가 이렇게 갈립니다.
걸러내기 위험한 태그를 아예 없앤다 → 문서에 안 남는다
미리 알려두기 있는 건 그대로 두고 실행만 막는다 → 문서에 남아 있다
같은 공격을 막는데 막는 방식이 다른데, 이게 왜 중요하냐면 두 번째 방식만 있는 상태에서는 위험한 문자열이 계속 우리 데이터베이스와 화면 안에 쌓인다는 뜻이기 때문이에요. 브라우저가 실행을 안 해줄 뿐이지 없어진 게 아닙니다.
브라우저 규칙 설정이 하나 바뀌거나, 그 규칙이 안 걸리는 경로가 하나 생기면 그때 전부 되살아납니다.
그럼 우리는 이걸 어떻게 알아채나요
화면은 멀쩡하고 오류도 안 떠서 아무도 안 알려주는 것처럼 보이는데, 실제로는 브라우저가 기록을 남깁니다.
개발자 도구 콘솔을 열어두고 새로고침해보세요. 이런 줄이 뜹니다.
위반한 규칙 script-src-elem
차단된 대상 inline
이름을 눈여겨보세요. 우리가 헤더에 적은 이름은 script-src였는데 보고에 찍히는 이름은 script-src-elem입니다. 스크립트를 허용하는 규칙이 안쪽에서 더 잘게 나뉘어 있고, 태그로 들어온 스크립트는 그중 -elem 쪽이 담당해요. 우리가 script-src만 적으면 나뉜 것들이 그 값을 물려받습니다.
차단된 대상이 주소가 아니라 inline이라고 찍히는 것도 같이 보세요. 파일에서 불러온 게 아니라 문서 안에 직접 적혀 있던 코드라는 뜻입니다.
⚠️ 재는 방법에 함정이 하나 있습니다
콘솔에서 그 댓글이 실제로 화면에 어떻게 보이는지 확인하려고 이렇게 읽는 분이 계실 거예요.
document.querySelector('li').textContent
// '@minjidocument.title = "털렸습니다"사진 좋네요'
이걸 보면 공격 코드가 화면에 글자로 보이는 것 같은데, 아니에요.
getComputedStyle(document.querySelector('li script')).display
// 'none'
<script> 태그는 브라우저가 기본적으로 안 보이게 처리합니다. 사용자 눈에 들어오는 글자는 사진 좋네요 뿐이에요. textContent는 안 보이는 태그의 내용까지 전부 읽어주기 때문에 생긴 착시입니다.
남아 있는 곳은 문서이지 화면이 아닙니다. 이 둘을 구분해서 말해야 정확해요.
확인이 끝났으면 걸러내는 함수를 반드시 되돌려 놓으세요.
// apps/web-next/app/components/CommentSection.tsx
<span dangerouslySetInnerHTML={{ __html: sanitizeComment(comment.content) }} />
💡 한 줄 정리
지난 시간의 규칙은 실행을 막지 내용을 지우지 않습니다. 걸러내기와 함께 두 겹으로 세워야 하는 이유가 여기에 있습니다.
🙋 학생 질문 — "그럼 걸러내기만 잘하면 지난 시간 것은 없어도 되는 거 아닌가요?"
반대로 물어보면 답이 나옵니다. 걸러내기가 완벽하다는 걸 무엇으로 증명하죠?
두 시간 전에 우리가 겪은 게 정확히 그 문제였어요. <script> 하나로 시험해보고 안전하다고 결론 냈다가, <img>의 onerror로 다시 시험하니 뚫렸습니다. 걸러내는 쪽은 우리가 아는 공격만 막을 수 있고, 목록에 없는 새 방법이 나오면 그때까지 열려 있어요.
오늘 Step 1에서 본 게 그 보험입니다. 걸러내기에 구멍이 났을 때, 들어온 것이 실행까지 가지는 못하게 막아주는 두 번째 겹이요.
거꾸로 지난 시간 것만 있어도 안 됩니다. 방금 봤듯이 위험한 문자열은 계속 쌓이니까요. 둘 중 하나를 고르는 문제가 아니라 순서와 역할이 다른 두 겹입니다.
Step 2: "남의 사이트가 대신 보냅니다"
이번엔 코드가 우리 페이지에서 안 돕니다
지금까지 다룬 건 전부 우리 페이지 안에서 남의 코드가 도는 문제였고, 그래서 우리 페이지에 규칙을 걸어 막을 수 있었어요.
오늘 오프닝에서 그린 장면은 다릅니다. 남의 사이트가 자기 페이지에서 코드를 돌리고, 그 코드가 우리 앱으로 요청을 보냅니다. 우리 페이지는 열려 있지도 않을 수 있어요.
지난 시간까지 남의 코드가 우리 페이지 안에서 돈다
→ 우리 페이지에 규칙을 걸어 막는다
오늘 남의 페이지에서 우리 서버로 요청이 온다
→ 우리 페이지에 건 규칙은 이 요청을 못 본다
핵심은 쿠키가 자동으로 따라간다는 점입니다. 브라우저는 우리 앱 주소로 요청이 나갈 때 우리 앱 쿠키를 붙여 보내고, 누가 그 요청을 시켰는지는 안 따져서, 서버 입장에서는 로그인한 사용자가 보낸 요청과 구별이 안 갑니다.
이런 공격을 요청 위조라고 부르고, 영어 앞 글자를 따서 CSRF라고 씁니다.
팔로우 버튼을 밖에서 눌러봅니다
우리 앱에서 팔로우 버튼은 서버 액션으로 동작하죠. 두 시간 전에 확인했듯이 그건 평범한 HTML 폼이라서, 폼이 보내는 것과 똑같은 요청을 밖에서 만들어 보낼 수 있습니다.
세 가지 경우로 나눠 보내볼게요. 우리 사이트에서 보낸 것처럼, 남의 사이트에서 보낸 것처럼, 그리고 출처를 아예 안 밝히고요.
요청을 보낼 때 브라우저는 Origin이라는 헤더에 "이 요청은 어느 사이트에서 시작됐다"를 적어서 보냅니다. 이 값을 바꿔가며 시험합니다.
# 남의 사이트에서 보낸 것처럼
curl -X POST -H 'Origin: https://evil.example.com' \
-b cookie.txt -F '...' http://localhost:3000/minji
결과가 이렇게 갈립니다.
우리 Origin 요청 성공 연습용 서버에 팔로우 요청이 도착함
남의 Origin 500 오류 연습용 서버에 아무것도 안 도착함
Origin 없음 요청 성공 연습용 서버에 팔로우 요청이 도착함
가운데 줄을 보세요. 막혔는데, 우리는 이걸 막는 코드를 한 줄도 안 썼어요.
이미 막고 있던 사람이 있었습니다
터미널에서 앱 서버 로그를 보면 이유가 적혀 있습니다.
`x-forwarded-host` header with value `localhost:3000` does not match
`origin` header with value `evil.example.com` from a forwarded Server
Actions request. Aborting the action.
우리가 쓰는 프레임워크가 서버 액션 요청마다 두 값을 맞춰봅니다. 요청이 도착한 우리 주소와, 요청을 시작했다고 적힌 출처요. 둘이 다르면 함수를 아예 안 부르고 중단시킵니다.
이건 우리가 켠 기능이 아니라 기본 동작이에요. 서버 액션이 주소 없이 함수로만 불린다는 점을 이용해서, 프레임워크가 그 통로 전체에 검사를 하나 걸어둔 겁니다.
그런데 세 번째 줄이 남았습니다
Origin을 아예 안 보낸 경우는 통과했습니다. 로그에는 이렇게 남아요.
⚠ Missing `origin` header from a forwarded Server Actions request.
경고만 하고 실행합니다. 왜 이렇게 만들었을까요. 브라우저가 아닌 것들이 보내는 요청에는 Origin이 원래 없는 경우가 많거든요. 서버끼리 주고받는 요청이나 명령줄 도구가 그렇습니다. 그것들까지 전부 막으면 정상적인 사용이 깨집니다.
여기서 오해하기 쉬운 게 있어요. "그럼 공격자가 Origin을 빼고 보내면 뚫리는 거 아닌가요?"라고 생각하기 쉬운데, 브라우저를 통한 공격에서는 그게 안 됩니다. Origin 헤더는 브라우저가 붙이고, 페이지의 자바스크립트는 이 값을 지우거나 바꿀 수 없어요.
지금 우리가 curl로 뺄 수 있는 건 우리가 브라우저를 안 쓰고 있기 때문이고, 그때는 피해자의 쿠키도 없죠. 오프닝에서 그린 장면의 핵심은 피해자의 브라우저를 시키는 것이었고, 그 경로에서는 Origin이 반드시 붙습니다.
💡 한 줄 정리
서버 액션으로 가는 길은 프레임워크가 출처를 대조해서 막아줍니다. 우리가 켠 게 아니라 기본으로 켜져 있습니다.
🙋 학생 질문 — "그럼 CSRF는 신경 안 써도 되는 건가요?"
서버 액션으로 가는 길만 그렇습니다.
두 시간 전에 우리가 세어봤던 걸 떠올려보세요. 우리 앱에서 서버로 가는 길이 서버 액션만 있는 게 아니었죠. D-7에서 우리가 손으로 만든 주소가 하나 있습니다.
그 주소는 프레임워크가 만들어준 통로가 아니라 우리가 직접 연 문이라, 검사도 우리가 붙였어야 합니다. 붙였을까요?
다음 Step에서 그걸 두드려봅니다.
Step 3: "우리가 연 문은 어떨까요"
D-7에서 만든 그 주소
기억을 되살려볼게요. D-7에서 우리는 화면을 그리지 않는 주소를 하나 만들었습니다. 파일 이름이 page가 아니라 route였죠.
// apps/web-next/app/api/profiles/revalidate/route.ts
export async function POST(request: Request) {
const username = new URL(request.url).searchParams.get('username');
// ...
revalidateTag(`profile:${username}`, 'max');
return Response.json({ success: true, data: { username }, message: null });
}
만든 이유는 이랬습니다. 백엔드에서 어떤 사람의 정보가 바뀌었을 때 우리에게 알려달라고 열어둔 문이에요. 알려주면 우리가 굳혀둔 화면을 버리고 새로 만듭니다.
이 문을 밖에서 두드려봅시다.
네 가지 경우 전부 두드립니다
curl -X POST -H 'Origin: https://evil.example.com' \
'http://localhost:3000/api/profiles/revalidate?username=jaehoon'
우리 Origin + 쿠키 200
남의 Origin + 쿠키 200
Origin 없음 + 쿠키 200
남의 Origin + 쿠키조차 없음 200
전부 200입니다. 마지막 줄을 보세요. 로그인도 안 했고, 남의 사이트에서 보냈는데 통과합니다.
응답도 정상이에요.
{"success":true,"data":{"username":"jaehoon"},"message":null}
Step 2에서 서버 액션이 막히던 것과 정반대죠. 같은 앱, 같은 서버인데 한쪽은 막히고 한쪽은 열려 있습니다.
이유는 단순합니다. 프레임워크가 서버 액션 통로에 걸어둔 검사는 서버 액션에만 걸려요. 우리가 손으로 만든 주소는 그 통로가 아닙니다. 우리가 연 문이니까 문지기도 우리가 세웠어야 하는데, 그때는 안 세웠습니다.
⚠️ 여기서 시험 방법을 잘못 잡기 쉽습니다
이 주소를 시험할 때 이렇게 보내는 분이 많습니다.
curl -X POST -H 'Content-Type: application/json' \
-d '{"username":"jaehoon"}' \
http://localhost:3000/api/profiles/revalidate
{"success":false,"data":null,"message":"username 이 필요합니다"}
400이 돌아옵니다. 이걸 보고 "막혀 있네"라고 결론 내리기 딱 좋아요.
그런데 이건 막힌 게 아닙니다. 이 주소는 이름을 주소 뒤 물음표 뒤에서 읽도록 만들어져 있어요. 위 코드의 searchParams 부분이 그겁니다. 본문에 JSON으로 넣으면 아무도 안 읽으니까 "이름이 없다" 고 답하는 거예요.
막혀서 나온 400이 아니라 이름을 못 찾아서 나온 400입니다. 문은 활짝 열려 있고, 제가 문을 안 두드리고 벽을 두드린 것뿐이에요.
시험할 때는 그 주소가 값을 어디서 읽는지 먼저 확인하세요. 짐작해서 보내면 열려 있는 문을 닫혀 있다고 기록하게 됩니다.
이 문의 정체를 다시 봅니다
문지기를 세우기 전에 하나 정리하고 갑시다. 이 문은 사람이 두드리는 문이 아닙니다.
파일 위에 우리가 적어둔 주석에 답이 있어요. 백엔드에서 정보가 바뀌었을 때 알려주는 문이라고 했으니, 이 문을 정상적으로 두드리는 쪽은 사용자의 브라우저가 아니라 백엔드 서버입니다.
이게 다음 Step을 정합니다. 브라우저가 없는 쪽을 상대하는 문이라, 세션 쿠키로는 확인할 방법이 없어요.
💡 한 줄 정리
프레임워크가 만들어준 통로는 프레임워크가 지켜주지만, 우리가 직접 연 주소는 우리가 지켜야 합니다.
🙋 학생 질문 — "이 문이 뚫리면 실제로 무슨 피해가 있나요? 그냥 화면을 새로 만드는 거잖아요."
좋은 질문이고, 정확히 말하면 이 주소는 데이터를 바꾸지 않습니다. 굳혀둔 화면을 버리라고 시킬 뿐이라, 누가 눌러도 누가 손해 보는 일은 없어 보이는데, 두 가지가 있습니다.
하나는 비용입니다. 이 주소를 초당 수천 번 두드리면 우리 서버는 그때마다 화면을 새로 만들어야 해요. 굳혀두는 결정을 D-5에서 왜 했는지 떠올려보면, 그 이득을 통째로 무효로 만드는 셈입니다.
다른 하나가 더 중요해요. 지금은이 문이 화면을 버리는 일만 하지만, 이 문은 백엔드가 우리에게 뭔가를 알려주는 통로이고, 앞으로 알려줄 것이 늘어나면 이 문에 하는 일도 늘어납니다. 그때 문지기를 세우려고 하면 이미 여러 곳이 이 문을 쓰고 있어서 고치기가 어려워져요.
문을 열 때 세우는 게 가장 쌉니다.
Step 4: "그 문에 문지기를 세웁니다"
쿠키로는 못 세웁니다
가장 먼저 떠오르는 방법은 로그인 확인일 거예요. 우리가 앞에서 여러 번 쓴 방법이니까요.
그런데 안 됩니다. Step 3 끝에서 확인했듯이 이 문을 두드리는 쪽은 백엔드 서버라, 서버에는 브라우저가 없고 브라우저가 없으면 쿠키도 없어요. 로그인 확인을 붙이면 공격자를 막는 게 아니라 정상적인 백엔드를 막게 됩니다.
Origin 검사는 어떨까요. 이것도 안 맞습니다. 같은 이유예요. 서버끼리 주고받는 요청에는 Origin이 원래 없습니다. Step 2에서 프레임워크가 Origin 없는 요청을 통과시킨 것과 같은 사정이에요.
그래서 이 문에서 확인해야 할 건 "누가 눌렀나"가 아니라 "어느 서버가 말하나"입니다.
사람을 확인하는 문 세션 쿠키를 본다 → 화면, 서버 액션
서버를 확인하는 문 약속한 비밀을 본다 → 오늘 이 주소
우리 서버와 백엔드만 아는 값
방법은 이렇습니다. 우리 서버와 백엔드가 같은 값을 하나 나눠 갖고, 백엔드가 문을 두드릴 때 그 값을 함께 내밉니다. 값이 맞으면 통과, 아니면 거절이에요.
G-3 마지막에 이 이야기를 꺼냈던 것 기억하시나요. 그때 마지막 질문에서 뒷문이 완전히 닫힌 게 아니라 한 칸 옮겨갔다고 했고, 진짜 서비스에서는 서버가 서버에게 자기를 증명한다고 했습니다. 그 방법이 지금 쓰는 이것입니다.
코드로 옮기면 이렇습니다.
// apps/web-next/app/api/profiles/revalidate/route.ts
export async function POST(request: Request) {
// 이 문을 두드리는 쪽에는 브라우저가 없다. 그래서 세션 쿠키로는 누구인지 확인할 수 없다.
// 대신 우리 서버와 백엔드만 아는 값을 헤더로 받아 맞춰본다.
//
// 값을 안 정해두면 아무도 못 들어온다. 열어두는 것보다 닫아두는 쪽이 안전하다.
const expected = process.env.REVALIDATE_SECRET;
if (expected === undefined || request.headers.get('x-revalidate-secret') !== expected) {
return Response.json(
{ success: false, data: null, message: '허락되지 않은 요청입니다' },
{ status: 401 },
);
}
const username = new URL(request.url).searchParams.get('username');
// 아래는 그대로입니다
검사를 맨 위에 뒀다는 점을 보세요. 이름을 읽기 전에 먼저 걸러냅니다. D-8에서 문지기의 검사 순서를 정할 때와 같은 판단이에요. 통과 못 할 요청에 일을 시키지 않습니다.
다시 두드려봅니다
이제 아까와 같은 방법으로 두드려보세요.
비밀 맞음(백엔드가 부르는 방식) 200
비밀 없음 + 남의 Origin 401
비밀 없음 + 우리 세션 쿠키까지 있음 401
비밀 틀림 401
비밀 맞음 + 이름 없음 400
세 번째 줄이 오늘 Step의 결정타입니다. 로그인한 사람의 쿠키를 그대로 들고 가도 401입니다.
당연해 보이지만 중요해요. 이 문은 사용자를 확인하는 문이 아니라는 걸 코드가 실제로 그렇게 동작한다는 뜻이니까요. 오프닝에서 그린 장면, 그러니까 피해자의 브라우저를 시켜서 쿠키를 달고 보내는 공격이 여기서는 통하지 않습니다. 브라우저는 우리 비밀을 모르거든요.
마지막 줄도 보세요. 비밀이 맞으면 그 뒤로 넘어가고, D-7에서 만든 이름 검사가 그대로 살아 있습니다. 문지기를 세우면서 원래 있던 동작을 안 건드렸어요.
비밀을 안 정해두면 어떻게 될까요
REVALIDATE_SECRET을 아예 안 정해둔 채로 두드려보면 이렇습니다.
헤더 없음 401
아무 헤더나 넣어봄 401
빈 값 헤더 401
전부 막힙니다. 정해둔 값이 없으면 아무도 못 들어옵니다.
이건 의도한 동작이에요. 설정을 빠뜨렸을 때 문이 열린 채로 있는 것과 닫힌 채로 있는 것 중에 고르라면 닫힌 쪽이 낫습니다. 열려 있으면 아무도 모르고, 닫혀 있으면 백엔드 담당자가 바로 연락하거든요.
💡 한 줄 정리
문지기가 무엇을 확인할지는 그 문을 누가 두드리는지가 정합니다. 브라우저가 없는 쪽을 상대하는 문은 쿠키로 못 지킵니다.
🙋 학생 질문 — "비밀번호 비교하듯이 그냥 !==로 해도 되나요? 시간 차이로 알아낸다는 얘기를 들었어요."
들으신 게 맞습니다. 두 문자열을 !==로 비교하면 앞에서부터 비교하다가 다른 글자가 나오는 순간 멈춰요. 그래서 맞는 글자가 많을수록 아주 조금 더 오래 걸립니다. 이 차이를 수없이 반복해서 재면 값을 한 글자씩 알아낼 수 있다는 게 알려진 공격이고, 이걸 막는 비교 함수가 따로 있습니다.
오늘 우리 코드에는 안 넣었는데, 이유는 두 가지입니다.
하나는 이 공격이 성립하려면 조건이 꽤 까다롭습니다. 네트워크를 사이에 두고 나노초 단위 차이를 재야 하는데, 실제로는 네트워크 지연이 그 차이보다 훨씬 커서 묻힙니다.
다른 하나는 오늘 배울 것에 집중하기 위해서예요. 오늘 요점은 "이 문은 사람이 아니라 서버를 확인한다"이고, 비교를 어떻게 하느냐는 그다음 이야기입니다.
실무에서 이런 비밀값을 다루신다면 그때는 찾아보시되, 먼저 확인할 건 비교 방법이 아니라 그 값이 충분히 길고 예측 불가능한가입니다. 짧고 뻔한 값이면 비교 방법을 아무리 잘 골라도 소용없어요.
Step 5: "그 비밀은 어디에 둘까요"
코드 안에 적으면 안 됩니다
Step 4에서 우리는 process.env.REVALIDATE_SECRET을 읽었어요. 파일에 값을 직접 적지 않았습니다. 왜 그랬을까요.
코드에 직접 적으면 그 값이 소스 저장소에 들어갑니다. 저장소를 볼 수 있는 사람은 전부 그 값을 알게 되고, 값을 바꾸려면 코드를 고쳐서 다시 배포해야 해요. 개발용과 운영용을 다르게 쓸 수도 없어서, 이런 값은 코드 밖에 두고 실행할 때 읽어옵니다. D-10에서 주소를 그렇게 다뤘던 것과 같은 방법이에요.
우리 저장소에는 다른 사람이 따라 할 수 있게 예시 파일을 둡니다.
# apps/web-next/.env.example
# 백엔드가 "이 사람 정보가 바뀌었다" 고 알려올 때 함께 내미는 값.
# 우리 서버와 백엔드만 알아야 한다 — 브라우저로 나가면 안 된다.
# openssl rand -base64 32
REVALIDATE_SECRET=여기에-직접-만든-값을-넣으세요
실제 값은 .env.local에 넣고, 이 파일은 저장소에 안 올라갑니다.
이름 하나로 운명이 갈립니다
여기가 이 Step의 핵심입니다. 우리가 쓰는 프레임워크에는 규칙이 하나 있어요.
환경 변수 이름이 NEXT_PUBLIC_으로 시작하면 그 값은 브라우저로 나갈 수 있게 됩니다. 그렇지 않으면 무슨 수를 써도 서버에만 남아요.
REVALIDATE_SECRET 브라우저로 나갈 길이 없다
NEXT_PUBLIC_REVALIDATE_SECRET 나갈 수 있다 — 읽는 코드가 브라우저로 가면
두 번째 줄을 정확히 읽어야 합니다. 이름을 그렇게 짓는 것만으로 값이 자동으로 새어 나가지는 않아요. 그 값을 읽는 코드가 브라우저로 실려 갈 때 비로소 값이 그 파일 안에 글자 그대로 박힙니다.
D-3에서 그은 선이 여기서 다시 나옵니다. 그때 'use client'가 긋는 선이 가져오기를 따라 내려간다고 했죠. 값이 새는 조건도 똑같아요. 그 선 안쪽에서 읽으면 새고, 바깥에서만 읽으면 안 샙니다.
위험해지려면 두 가지가 동시에 필요합니다. 이름이 NEXT_PUBLIC_으로 시작할 것, 그리고 그 값을 브라우저로 가는 코드가 읽을 것이요.
둘이 만나면 개발자 도구를 열어 파일을 뒤지는 누구나 그 값을 읽을 수 있고, Step 4에서 세운 문지기는 아무 의미가 없어집니다. 공격자가 값을 보고 그대로 헤더에 넣으면 되니까요.
무서운 건 아무것도 안 깨진다는 점입니다. 빌드도 되고, 타입 검사도 통과하고, 백엔드도 정상 동작하고, 화면도 멀쩡해요. 오늘 우리 과제가 이 둘을 하나씩 붙여가며 확인합니다.
우리 앱을 확인해봅니다
그럼 우리 앱에는 이런 이름이 몇 개나 있을까요. 세어봅시다.
grep -rn "NEXT_PUBLIC_" app lib proxy.ts next.config.ts .env.example
0
한 개도 없습니다.
여기서 잠깐 멈춰야 해요. 0이 나왔을 때 두 가지 가능성이 있습니다. 진짜로 없는 것과, 명령이 제대로 안 돌아서 아무것도 못 찾은 것이요. 둘 다 화면에는 0으로 똑같이 보입니다.
같은 명령이 실제로 뭔가를 찾아내는지 확인해보세요, 반드시 있는 걸로요.
grep -rn "process.env" app lib proxy.ts next.config.ts | wc -l
4
찾습니다. 아까 0은 그러니 진짜 0이에요. 앞으로도 "없다"를 근거로 뭔가를 결론 내릴 때는 이렇게 한 번 더 확인하는 습관을 들이세요.
찾아낸 네 곳이 전부 서버에서만 읽는 값입니다. 백엔드 주소, 소셜 로그인 열쇠 두 개, 그리고 개발 서버인지 판단하는 값이요.
D-10에서 넘겨둔 숙제
D-10에서 앱을 컨테이너에 담을 때 이 이야기를 잠깐 꺼내고 넘어갔습니다. 그때 표로 정리했던 걸 이제 채울 수 있어요.
서버에서 읽는 값 실행할 때 정해진다
이미지 하나로 개발용·운영용을 다르게 띄울 수 있다
방문자가 볼 수 없다
NEXT_PUBLIC_ 값 빌드할 때 정해져 파일에 박힌다
환경을 바꾸려면 다시 빌드해야 한다
방문자가 전부 볼 수 있다
그래서 브라우저가 알아도 되는 것만 두 번째에 두는데, 지도 서비스의 공개 키나 화면에 표시할 버전 번호 같은 것들이 그렇습니다.
우리 앱에 그런 값이 하나도 없는 건 우연이 아닙니다. D-3에서 서버와 클라이언트의 경계를 그은 뒤로, 바깥과 이야기하는 일을 전부 서버 쪽에 뒀거든요. 브라우저가 직접 백엔드를 부르지 않으니 브라우저가 알아야 할 비밀도 없습니다.
💡 한 줄 정리
비밀은 코드 밖에 두고 실행할 때 읽고, 이름 앞에 붙는 글자 하나가 그 값이 브라우저로 나가는지를 정합니다.
🙋 학생 질문 — ".env.local을 저장소에 안 올리면 팀원들은 값을 어떻게 아나요?"
값 자체를 코드 저장소로 전달하지 않는 게 요점이라, 다른 통로를 씁니다.
먼저 .env.example이 하는 일이 있어요. 이 파일은 어떤 이름이 필요한지를 알려줍니다. 값은 안 들어 있고 이름과 설명만 있죠. 새로 합류한 사람이 이 파일을 보고 무엇을 채워야 하는지 압니다.
실제 값은 팀마다 다른데, 개발용은 보통 그냥 각자 아무 값이나 만들어 씁니다. 오늘 우리가 만든 것처럼요. 우리 서버와 백엔드가 같기만 하면 되니까요.
운영용 값은 사람이 주고받지 않습니다. 배포하는 곳에 값을 넣어두는 자리가 따로 있고, 거기 넣으면 실행될 때 앱이 읽어갑니다. 개발자가 그 값을 몰라도 앱은 돌아가요.
그래서 값을 아는 사람 수를 줄이는 게 가능해지고, 이게 D-10에서 본 "이미지 하나로 여러 환경"과 이어지는 이야기예요.
마무리
오늘 배운 핵심 세 가지
💡 하나. 막는 것과 지우는 것은 다릅니다. 지난 시간에 세운 규칙은 위험한 태그를 없애지 않았어요. 문서 안에 그대로 두고 실행만 막았고, 그래서 걸러내기와 함께 두 겹으로 세워야 합니다. 한 겹만 있으면 다른 한 겹이 하던 일이 통째로 비어요.
💡 둘. 우리 페이지에 건 규칙은 우리 페이지 밖에서 오는 요청을 못 봅니다. 남의 사이트가 자기 쪽에서 우리 앱으로 요청을 보내면 그 규칙은 손댈 데가 없습니다. 그건 다른 방법으로 막아야 하고, 서버 액션에 대해서는 프레임워크가 이미 막고 있었어요.
💡 셋. 문지기가 무엇을 확인할지는 그 문을 누가 두드리는지가 정합니다. 사람이 두드리는 문은 세션 쿠키를 보고, 서버가 두드리는 문은 약속한 비밀을 봅니다. 브라우저가 없는 쪽에 쿠키를 요구하면 공격자가 아니라 정상적인 상대를 막게 됩니다.
다음 시간 예고
이걸로 보안 세 시간이 끝났습니다. 우리 화면에서 남의 코드가 도는 문제, 브라우저에게 미리 알려두는 방법, 그리고 오늘 바깥과의 경계까지요.
돌아보면 오늘 우리가 한 일은 대부분 재보는 것이었어요. 두드려보고, 응답을 읽고, 열려 있는지 닫혀 있는지 확인했습니다. 코드는 Step 4에서 몇 줄 고친 게 전부였죠.
그런데 오늘 우리가 재본 건 전부 우리 노트북에서였습니다. 진짜 사용자가 쓰는 앱에서는 우리가 옆에서 지켜볼 수가 없어요. 누군가의 화면에서 무언가 터졌을 때, 우리는 그걸 어떻게 알게 될까요.
다음 시간부터는 그 이야기입니다. 배포한 다음에 벌어지는 일을 우리가 어떻게 알아채고, 사용자가 실제로 느끼는 속도를 어떻게 재는지요.
과제
[구현] 문지기를 "고쳐" 보세요
Step 4에서 우리가 쓴 검사에는 이런 줄이 있었습니다.
const expected = process.env.REVALIDATE_SECRET;
if (expected === undefined || request.headers.get('x-revalidate-secret') !== expected) {
expected === undefined 부분이 군더더기처럼 보일 수 있어요. 값이 없으면 어차피 헤더와 안 맞을 테니까요.
먼저 확인해보세요. 그 조건을 지우고, REVALIDATE_SECRET도 안 정해둔 채로 두드려보세요. 통과하나요?
그다음이 진짜 과제입니다. 값이 없을 때를 대비해서 이렇게 "고쳐" 봅시다. 더 안전해 보이는 코드예요.
const expected = process.env.REVALIDATE_SECRET ?? '';
이 상태에서 REVALIDATE_SECRET을 안 정해두고, 이번엔 빈 값 헤더 하나를 보내보세요.
curl -X POST -H 'x-revalidate-secret;' \
'http://localhost:3000/api/profiles/revalidate?username=jaehoon'
무슨 일이 일어나는지 확인하고 왜 그렇게 되는지 설명한 다음, 빌드도 되고 타입 검사도 통과하는 이 수정을 코드 리뷰에서 어떻게 발견할 수 있을지도 답해보세요.
확인이 끝나면 원래대로 되돌리세요.
[구현] 이름 하나를 바꿔보세요 — 예상과 다를 겁니다
Step 5에서 이름 앞에 붙는 글자가 값의 운명을 가른다고 했죠. 직접 확인하는 과제입니다.
REVALIDATE_SECRET을 NEXT_PUBLIC_REVALIDATE_SECRET으로 바꾸세요. .env.local과 문지기 코드 양쪽 다요. 다 바꿨으면 프로덕션으로 빌드하세요.
이제 빌드 결과물에서 그 값을 찾아보세요. 브라우저가 받아가는 파일들이 어디에 모이는지는 D-10에서 확인했습니다.
아마 못 찾으실 겁니다. 왜 그런지 설명해보세요. 이름은 분명히 바꿨는데요.
힌트는 D-3에 있습니다. 그때 우리는 'use client'가 긋는 선이 어디를 따라 내려가는지 봤어요. 지금 그 값을 읽는 파일은 그 선의 어느 쪽에 있나요?
그다음, 값이 실제로 새어 나가게 만들어보세요. 클라이언트 컴포넌트를 아주 작게 하나 만들어서 그 값을 읽게 하고, 화면 어딘가에 붙인 다음 다시 빌드해서 같은 방법으로 찾아보세요.
두 상태의 결과를 표로 정리하고, 마지막으로 답해보세요. 값이 새어 나가려면 무엇과 무엇이 동시에 있어야 하나요?
⚠️ 값을 찾을 때 대상 폴더를 정확히 고르세요. 서버만 쓰는 파일에서 나온 것과 브라우저가 받아가는 파일에서 나온 것은 뜻이 완전히 다릅니다.
확인이 끝나면 반드시 전부 되돌리세요.
[탐구] 서버 액션도 두드려보세요
Step 2에서는 제가 결과를 보여드렸는데, 이번엔 직접 재보는 과제입니다.
팔로우 폼을 밖에서 제출해보세요. Origin을 세 가지로 바꿔가면서요. 우리 주소, 남의 주소, 그리고 헤더를 아예 빼고요.
⚠️ 두 가지를 조심하세요. 첫째, 폼이 선언한 형식과 같은 방식으로 보내야 합니다. 화면 소스에서 폼 태그를 열어 어떤 형식인지 먼저 확인하세요. 둘째, 좋아요 버튼은 이 방법으로 못 누릅니다. 왜 그런지도 함께 확인해보세요. D-8에서 그 버튼에 무엇을 얹었는지 떠올려보면 답이 보입니다.
판정은 응답 코드만 보지 말고 연습용 서버 로그로 하세요. 요청이 실제로 거기까지 갔는지가 진짜 답입니다.
그리고 마지막으로 답해보세요. 세 경우 중 통과한 것이 있다면, 그게 실제 공격에서도 통하는 경로일까요?
[탐구] 두 문을 나란히 놓아보세요
오늘 우리는 같은 앱 안에서 서로 다르게 지켜지는 두 개의 문을 봤습니다. 팔로우로 가는 길과 우리가 만든 주소요.
표를 하나 만들어서 채워보세요.
| 서버 액션(팔로우) | 우리가 만든 주소 | |
|---|---|---|
| 누가 두드리나 | ||
| 무엇으로 확인하나 | ||
| 누가 그 검사를 붙였나 | ||
| 검사에 실패하면 |
그리고 답해보세요. 우리가 앞으로 새 주소를 하나 더 만든다면, 이 표의 어느 칸을 먼저 정해야 할까요?
생각해볼 주제
1. 프레임워크가 대신 막아주는 것을 우리는 어떻게 알 수 있을까요
오늘 Step 2에서 우리는 아무 코드도 안 썼는데 공격이 막히는 걸 봤습니다. 알고 막은 게 아니라 재보다가 알게 됐어요.
이런 게 얼마나 더 있을지, 우리가 모르는 채로 지켜지고 있는 것들이 무엇일지, 그리고 그걸 모르는 상태가 왜 위험한지도 함께 생각해보세요. 지켜주는 줄 모르고 직접 또 막는 것과, 안 지켜주는데 지켜준다고 믿는 것 중에 어느 쪽이 더 위험할까요.
2. 비밀을 나눠 가진 두 쪽 중 한쪽이 바뀌면
Step 4에서 우리 서버와 백엔드가 같은 값을 나눠 가졌고, 잘 동작하죠.
그 값을 바꿔야 할 때가 오면 문제가 생깁니다. 누가 실수로 유출했거나, 정기적으로 바꾸기로 했거나요. 두 쪽이 동시에 바뀌지 않으면 그사이에 요청이 전부 거절됩니다.
이걸 어떻게 다뤄야 할지, 그리고 이 문제가 오늘 우리가 고른 방법에만 있는 것인지 아니면 나눠 가진 비밀을 쓰는 모든 방법에 있는 것인지도 함께 생각해보세요.
3. 두 겹 중 하나만 할 수 있다면 어디에 두시겠어요
지난 시간에 이미 비슷한 질문을 드렸는데, 오늘 본 것을 얹어서 다시 생각해보세요.
오늘 Step 1에서 우리는 실행만 막는 것과 아예 지우는 것이 다르다는 걸 봤습니다. 실행만 막으면 위험한 값이 데이터베이스에 계속 쌓여요.
이 관점에서 두 겹의 순서를 다시 보면 어떨지, 그리고 이미 쌓여버린 값들은 어떻게 해야 할지도 함께 생각해보세요. 나중에 걸러내기를 강화하면 그것들도 안전해지나요?
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 문지기를 "고쳐" 보세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 조건을 지워도 401 이 그대로 나오는 것을 확인했는가 | 6 | 지운 상태에서 직접 두드려봤다 |
?? ''로 바꾼 뒤 빈 값 헤더로 200 을 재현했는가 |
8 | 재현 결과를 적었다 |
| 왜 통과하는지 빈 문자열 비교로 설명했는가 | 6 | 두 값이 같아지는 과정을 짚었다 |
| 코드 리뷰에서 발견할 방법을 제시했는가 | 5 | 도구가 아니라 사람이 볼 지점을 지목했다 |
| 합계 | 25 |
풀이 예시
먼저 조건을 지워봅니다.
// apps/web-next/app/api/profiles/revalidate/route.ts
const expected = process.env.REVALIDATE_SECRET;
if (request.headers.get('x-revalidate-secret') !== expected) {
REVALIDATE_SECRET을 안 정해둔 채로 두드리면 결과는 이렇습니다.
헤더 없음 401
헤더 아무거나 401
통과하지 않습니다. 그러니까 그 조건은 그 자체로는 없어도 되는 줄이에요. 헤더가 없으면 null이 나오고, 정해둔 값이 없으면 undefined라서, 둘을 비교하면 어차피 다르다고 나옵니다.
여기까지만 보면 "군더더기니까 지워도 되겠네"가 됩니다. 그런데 문제는 지운 다음에 벌어져요.
이제 값이 없을 때를 대비해서 이렇게 "고쳐" 봅니다.
const expected = process.env.REVALIDATE_SECRET ?? '';
더 안전해 보이죠. 값이 없어도 문자열이 들어오니까요. 이 상태에서 빈 값 헤더 하나를 보내면 이렇게 됩니다.
curl -X POST -H 'x-revalidate-secret;' \
'http://localhost:3000/api/profiles/revalidate?username=jaehoon'
{"success":true,"data":{"username":"jaehoon"},"message":null}
200입니다. 문이 열렸어요.
이유는 단순합니다. ?? '' 때문에 기대값이 빈 문자열이 됐고, 헤더를 이름만 쓰고 값을 비워 보내면 받는 값도 빈 문자열입니다. 빈 문자열끼리 비교하니 같다고 나오고, 통과합니다.
정해둔 값 없음 + `?? ''` 기대값이 ''
빈 값 헤더 받은 값이 ''
→ 같다 → 통과
자주 나오는 실수
헤더 없이 보내보고 401이 나오니까 안전하다고 결론 내리는 경우가 많습니다. 헤더를 아예 안 보내는 것과 이름만 보내고 값을 비우는 것은 다릅니다. 앞엣것은 null이고 뒤엣것은 ''이에요. 빈 문자열을 만들어내는 쪽으로 시험해야 이 결함이 드러납니다.
코드 리뷰에서 어떻게 발견하나
도구로는 못 잡습니다. 빌드도 되고 타입 검사도 통과하고 린트도 조용해요. 문법적으로 완벽하게 올바른 코드거든요.
사람이 볼 지점은 하나입니다. 비밀을 비교하는 코드에 기본값을 주는 모든 곳. ?? '' 든 || '' 든, 없을 때를 대비한 기본값이 비교 대상이 되는 순간 "아무것도 없음"이 "유효한 값"으로 바뀝니다.
Step 4의 그 조건을 남겨둔 이유가 여기에 있습니다. "이 줄이 없으면 뚫린다"는 게 아니라, "비밀이 없으면 아무도 못 들어온다"를 코드가 말로 못 박아두는 것이에요. 그래야 나중에 누가 기본값으로 "고치려" 할 때 그 줄이 먼저 눈에 걸립니다.
💡 튜터의 한마디 — 오늘 과제 중에 가장 실무에 가깝습니다. 보안 결함은 대개 이렇게 들어와요. 누가 나쁜 마음으로 넣는 게 아니라, 좋은 의도로 방어적으로 고치다가 조건 하나를 뒤집고, 모든 검사가 초록이라 아무도 안 알려줍니다.
🎯 [과제 2 예시답안] 이름 하나를 바꿔보세요 — 예상과 다를 겁니다
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 이름만 바꾼 상태에서 값이 안 나오는 것을 확인했는가 | 6 | 0건을 직접 봤다 |
| 안 나오는 이유를 D-3의 선으로 설명했는가 | 7 | 파일이 선의 어느 쪽인지 짚었다 |
| 클라이언트 컴포넌트를 붙여 실제로 새게 만들었는가 | 7 | 새어 나온 파일을 찾았다 |
| 새는 조건 두 가지를 함께 말했는가 | 5 | 이름과 위치를 둘 다 들었다 |
| 합계 | 25 |
풀이 예시
이름을 바꾸고 빌드한 뒤 브라우저가 받아가는 폴더에서 찾아봅니다.
grep -rl "그 값" .next/static | wc -l
0
안 나옵니다. 이름은 분명히 바꿨는데요.
서버 쪽도 같이 세어보면 그림이 완성됩니다.
이름만 바꿈 .next/static 0건 .next/server 1건
값은 존재하고 서버는 잘 읽고 있어요. 브라우저 쪽으로만 안 갔습니다.
왜 안 갔을까요
그 값을 읽는 파일이 route.ts 이기 때문입니다.
D-3에서 우리는 'use client'가 긋는 선이 가져오기를 따라 내려간다는 걸 봤어요. 브라우저로 실려 가는 파일은 그 선 안쪽에서 가져와지는 것들이고, 나머지는 서버에만 남습니다.
route.ts는 화면을 그리지 않는 파일이라 어떤 클라이언트 컴포넌트도 이걸 가져오지 않아요. 그러니 클라이언트 번들에 애초에 들어갈 일이 없고, 그 안에서 읽는 값도 따라가지 않습니다.
이름 앞의 NEXT_PUBLIC_은 나갈 수 있는 자격을 준 것이지 나가라고 시킨 게 아니었어요.
실제로 새게 만들기
클라이언트 컴포넌트를 아주 작게 하나 만들어 그 값을 읽게 하고 화면에 붙입니다.
// 확인용 임시 파일입니다. 확인 후 반드시 지우세요.
'use client';
export function Probe() {
return <span data-probe={process.env.NEXT_PUBLIC_REVALIDATE_SECRET} />;
}
다시 빌드하고 같은 방법으로 찾으면 이렇게 바뀝니다.
이름만 바꿈 .next/static 0건 .next/server 1건
+ 클라이언트가 그 값을 읽음 .next/static 1건 .next/server 3건
이제 브라우저가 받아가는 파일 안에 값이 글자 그대로 들어 있습니다. 그 파일을 받는 사람은 누구나 읽을 수 있어요.
새는 조건은 둘입니다
① 이름이 NEXT_PUBLIC_ 으로 시작한다 (자격)
② 그 값을 읽는 코드가 브라우저로 실려 간다 (경로)
둘 중 하나만으로는 안 새서, 위험을 판단할 때 이름만 보면 부족하고 그 값을 누가 읽는지를 같이 봐야 해요.
자주 나오는 실수
.next 폴더 전체에서 찾아보고 "나온다" 고 결론 내리는 경우입니다. 서버만 쓰는 파일에서 나온 것은 아무 문제가 아니에요. 그건 원래 서버가 알아야 하는 값이니까요. 반드시 브라우저가 받아가는 폴더로 좁혀서 세야 합니다.
💡 튜터의 한마디 — D-3에서 그은 선이 오늘 보안 문제의 답이 됐습니다. 그때는 번들 크기 이야기였죠. 같은 선이 "무엇이 새어 나가는가"도 정합니다. 경계를 한 번 잘 그어두면 나중에 전혀 다른 문제에서 값을 합니다.
🎯 [과제 3 예시답안] 서버 액션도 두드려보세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 폼 형식을 먼저 확인하고 그에 맞게 보냈는가 | 6 | multipart로 보냈다 |
| 세 경우의 결과를 연습용 서버 로그로 판정했는가 | 8 | 응답 코드만 보지 않았다 |
| 좋아요 버튼이 왜 안 되는지 설명했는가 | 6 | 폼 태그의 action을 근거로 들었다 |
| 통과한 경우가 실제 공격 경로인지 판단했는가 | 5 | 브라우저가 헤더를 붙인다는 점을 짚었다 |
| 합계 | 25 |
풀이 예시
먼저 폼이 어떤 형식인지 확인합니다.
<form class="ml-2 flex items-center gap-2" action="" encType="multipart/form-data" method="POST">
multipart/form-data예요. 그러니 보낼 때도 그 형식으로 보내야 합니다. 형식이 다르면 함수가 아예 안 불려서, 막힌 것도 아닌데 막힌 것처럼 보입니다.
세 경우의 결과입니다.
우리 Origin 요청 성공 연습용 서버에 팔로우 요청 도착
남의 Origin 500 오류 연습용 서버에 아무것도 안 도착
Origin 없음 요청 성공 연습용 서버에 팔로우 요청 도착
판정을 응답 코드가 아니라 연습용 서버 로그로 한 이유가 있습니다. 응답만 보면 성공했는지 애매한 경우가 있는데, 서버에 요청이 실제로 도착했는지는 애매하지 않아요. 그게 진짜 답입니다.
가운데 줄이 막힌 이유는 앱 서버 로그에 그대로 적혀 있습니다. 도착한 주소와 출처가 안 맞아서 함수를 안 부르고 중단시켰다고요.
좋아요 버튼은 왜 안 되나요
화면 소스에서 좋아요 폼을 열어보면 이렇게 생겼습니다.
<form action="javascript:throw new Error('React form unexpectedly submitted.')">
보낼 주소가 없습니다. 자바스크립트를 실행하라고 적혀 있고, 그나마도 오류를 던지는 코드예요. 숨은 입력값도 하나도 없습니다.
D-8에서 이 버튼에 즉시 반응하는 기능을 얹었기 때문입니다. 그걸 얹으려면 눌렀을 때 자바스크립트가 먼저 개입해야 하고, 그 순간 이 폼은 평범한 HTML 폼이기를 그만둡니다. 자바스크립트가 없으면 아예 안 눌리는 버튼이 된 거라, 같은 앱 안에 두 종류의 폼이 함께 있습니다. 홈 화면에서 세어보면 자바스크립트 전용이 10개, 평범한 폼이 11개예요.
통과한 경우가 실제 공격 경로일까요
아닙니다.
Origin을 뺄 수 있었던 건 우리가 curl로 보냈기 때문이에요. 브라우저로 하는 공격에서는 이 헤더를 브라우저가 붙이고, 페이지의 자바스크립트는 이 값을 지우거나 바꿀 수 없습니다.
그리고 우리가 curl로 보낼 때는 피해자의 로그인 쿠키가 없죠. 공격이 성립하려면 피해자의 브라우저를 시켜야 하는데, 그 경로에서는 Origin이 반드시 붙습니다.
그러니까 세 번째 줄은 "브라우저가 아닌 것이 보내는 요청"을 위한 통로이지 뚫린 구멍이 아닙니다.
💡 튜터의 한마디 — 보안을 확인할 때 가장 흔한 실수가 "내가 뚫었으니 공격자도 뚫는다"입니다. 내가 쓴 도구가 공격자가 쓸 수 있는 도구인지를 먼저 물어야 해요. 오늘 경우엔 브라우저라는 조건이 그 차이를 만듭니다.
🎯 [과제 4 예시답안] 두 문을 나란히 놓아보세요
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 네 칸을 두 문 모두 채웠는가 | 8 | 빈칸 없이 채웠다 |
| "누가 두드리나" 를 정확히 갈랐는가 | 6 | 브라우저 유무로 갈랐다 |
| "누가 붙였나" 에서 기본 동작과 우리 코드를 구분했는가 | 6 | 프레임워크와 우리를 갈랐다 |
| 새 주소를 만들 때 먼저 정할 칸을 골랐는가 | 5 | 첫 칸을 골랐다 |
| 합계 | 25 |
풀이 예시
| 서버 액션(팔로우) | 우리가 만든 주소 | |
|---|---|---|
| 누가 두드리나 | 사용자의 브라우저 | 백엔드 서버 |
| 무엇으로 확인하나 | 요청이 시작된 출처 | 나눠 가진 비밀 |
| 누가 그 검사를 붙였나 | 프레임워크가 기본으로 | 우리가 Step 4에서 손으로 |
| 검사에 실패하면 | 500, 함수가 아예 안 불림 | 401, 그 뒤로 안 넘어감 |
새 주소를 만든다면 어느 칸을 먼저 정할까요
첫 번째 칸입니다. "누가 두드리나" 요.
나머지 세 칸이 전부 그 칸에서 따라 나오기 때문이에요. 사람이 두드리는 문이면 세션 쿠키를 보면 되고, 프레임워크가 만들어준 통로 위에 있다면 출처 검사도 딸려옵니다. 서버가 두드리는 문이면 쿠키도 출처도 못 쓰니 다른 방법을 찾아야 하고, 그 방법은 우리가 직접 붙여야 해요.
오늘 Step 4에서 우리가 겪은 게 정확히 이 순서였습니다. 로그인 확인을 붙이려다가 "이 문을 두드리는 쪽에는 브라우저가 없다"는 걸 깨닫고 방법을 바꿨죠. 첫 칸을 먼저 정했으면 한 번에 갔을 겁니다.
자주 나오는 실수
"무엇으로 확인하나"를 먼저 정하는 경우입니다. 익숙한 방법이 있으니 그걸 쓰려고 하는 건데, 그러면 오늘처럼 정상적인 상대를 막는 검사를 붙이게 됩니다. 공격자는 그대로 통과하고 백엔드만 막히는 상태요.
💡 튜터의 한마디 — 표의 세 번째 칸을 특히 눈여겨보세요. 우리가 안 붙였는데 지켜지고 있는 것과, 우리가 안 붙여서 안 지켜지는 것이 한 앱 안에 함께 있었습니다. 어느 쪽인지 아는 것이 실무에서 훨씬 중요합니다.
🤔 [생각해볼 주제 1] 프레임워크가 대신 막아주는 것을 우리는 어떻게 알 수 있을까요
문제 상황 요약
오늘 Step 2에서 우리는 코드를 한 줄도 안 쓰고 요청 위조가 막히는 것을 봤습니다. 알고 있어서 확인한 게 아니라 재보다가 알게 됐는데, 우리가 모르는 채로 지켜지고 있는 것이 또 얼마나 있을까요.
튜터의 가이드 및 해설
두 가지 오해 중 어느 쪽이 더 위험한지부터 갈라봅시다.
지켜주는 줄 모르고 또 막는 경우는 비용이 듭니다. 필요 없는 코드가 늘고, 그 코드도 유지보수 대상이 되고, 가끔은 원래 동작과 충돌해서 정상적인 요청을 막기도 하지만, 안전 자체가 깨지지는 않습니다.
안 지켜주는데 지켜준다고 믿는 경우는 다릅니다. 아무도 안 막고 있는데 다들 막힌다고 생각하는 상태이고, 이건 아무 신호도 안 냅니다. 오늘 Step 3에서 본 그 주소가 정확히 그랬죠. 응답은 200이고 화면도 멀쩡하고 로그도 조용했습니다.
두 번째가 압도적으로 위험한 이유가 여기에 있고, 실무에서 실제로 사고가 나는 쪽도 이쪽입니다.
어떻게 알 수 있을까요. 세 가지를 권합니다.
첫째, 문서에서 기본값 항목을 먼저 읽으세요. 우리가 쓰는 도구들은 대개 "기본으로 켜져 있는 것" 목록을 갖고 있습니다. 새 도구를 들일 때 기능 목록보다 이 목록을 먼저 보는 게 낫습니다.
둘째, 그 보호가 어디까지 걸리는지를 물으세요. 오늘이 그 예입니다. 요청 위조 보호가 있긴 있었는데 서버 액션에만 걸렸어요. "있다/없다"가 아니라 "어디에 걸리나"를 물어야 오늘 같은 갈림을 찾습니다.
셋째, 그리고 이게 제일 확실한데, 직접 두드려보세요. 오늘 우리가 한 게 이겁니다. 문서를 읽고 추측하는 대신 실제로 남의 출처에서 요청을 보내봤어요. 문서는 조건을 다 안 적어주지만 응답은 거짓말을 안 합니다.
한 가지 덧붙이면, 이 확인은 도구를 새로 들일 때만이 아니라 버전을 올릴 때도 필요합니다. 기본값은 조용히 바뀌거든요.
🎯 면접관을 홀리는 핵심 멘트
"프레임워크가 막아주는 것과 안 막아주는 것을 가르는 기준은 '기능이 있느냐'가 아니라 '어디까지 걸리느냐' 였습니다. 저희 앱에서는 요청 위조 보호가 서버 액션에는 기본으로 걸려 있었지만, 직접 만든 Route Handler에는 안 걸렸어요. 둘 다 같은 서버, 같은 인증을 쓰는데도요. 그래서 저는 새 통로를 열 때마다 '이건 어느 보호 아래에 있나'를 먼저 확인하고, 확신이 안 서면 실제로 남의 출처에서 요청을 보내봅니다."
🤔 [생각해볼 주제 2] 비밀을 나눠 가진 두 쪽 중 한쪽이 바뀌면
문제 상황 요약
Step 4에서 우리 서버와 백엔드가 같은 값을 나눠 가졌습니다. 그런데 그 값을 바꿔야 할 때가 옵니다. 두 쪽이 정확히 같은 순간에 바뀌지 않으면 그사이 요청이 전부 거절돼요.
튜터의 가이드 및 해설
먼저 문제를 정확히 봅시다. 값이 하나뿐이면 교체는 반드시 끊김을 만듭니다. 우리 쪽을 먼저 바꾸면 백엔드가 옛 값을 내미는 동안 거절되고, 백엔드를 먼저 바꾸면 반대가 되죠. 배포에 몇 분이 걸린다면 그 몇 분 동안 알림이 전부 실패합니다.
해결의 핵심은 한동안 둘 다 받아주는 것입니다.
받는 쪽이 값을 하나가 아니라 목록으로 들고 그중 하나만 맞으면 통과시키게 만들면, 교체가 이런 순서로 끊김 없이 진행돼요.
1. 받는 쪽 목록에 새 값을 추가한다 (옛 값도 그대로 둔다)
2. 보내는 쪽을 새 값으로 바꾼다 (이때부터 새 값이 쓰인다)
3. 받는 쪽 목록에서 옛 값을 뺀다 (여기서 교체 완료)
각 단계 사이에 시간 여유가 있고, 어느 시점에도 거절되는 요청이 없습니다. 3번을 하기 전에 옛 값으로 들어오는 요청이 정말 0이 됐는지 확인할 수 있으면 더 좋고요.
여기서 중요한 건 2번과 3번 사이에 되돌릴 수 있다는 점입니다. 새 값에 문제가 생기면 보내는 쪽만 옛 값으로 되돌리면 되니까요. 값이 하나뿐이면 이 여유가 없습니다.
이 문제가 우리가 고른 방법에만 있는지도 물으셨죠. 나눠 가진 비밀을 쓰는 모든 방법에 있습니다. 이름이 무엇이든, 양쪽이 같은 값을 알아야 하는 구조라면 교체할 때 같은 문제를 만나요.
그래서 다른 계열의 방법도 있습니다. 나눠 갖는 대신 한쪽만 값을 갖고 다른 쪽은 그걸 확인만 할 수 있게 만드는 방식이요. 이러면 확인하는 쪽이 비밀을 아예 안 들고 있어서, 그쪽이 뚫려도 남을 사칭할 수는 없습니다. 우리 규모에서는 그래도 나눠 갖는 쪽이 훨씬 간단하고, 위에 적은 순서만 지키면 교체도 안전합니다.
마지막으로 하나. 이 이야기 전체가 교체가 언젠가 반드시 일어난다는 전제 위에 있습니다. 값을 처음 정할 때부터 "이걸 어떻게 바꿀 것인가"를 정해두는 게, 유출된 다음에 급하게 고민하는 것보다 훨씬 낫습니다.
🎯 면접관을 홀리는 핵심 멘트
"공유 비밀을 쓸 때 저는 값을 정하는 것보다 교체 절차를 먼저 정합니다. 받는 쪽이 값을 목록으로 들고 한동안 옛 값과 새 값을 함께 받아주면, 추가하고 전환하고 제거하는 세 단계로 무중단 교체가 되고 중간에 되돌릴 여유도 생깁니다. 값이 하나뿐이면 교체가 곧 장애라서, 결국 아무도 안 바꾸게 되고 그게 더 위험해집니다."
🤔 [생각해볼 주제 3] 두 겹 중 하나만 할 수 있다면 어디에 두시겠어요
문제 상황 요약
지난 시간에도 비슷한 질문을 드렸는데, 오늘 Step 1에서 본 것을 얹으면 답이 조금 달라집니다. 실행만 막는 방법은 위험한 값이 계속 쌓이게 두거든요.
튜터의 가이드 및 해설
오늘 Step 1에서 확인한 것부터 정리합시다. 걸러내기를 켜두면 위험한 태그가 아예 저장소를 통과해 화면까지 안 갑니다. 반면 미리 알려두는 방법은 그것들이 데이터베이스에 그대로 쌓이고 화면 문서 안에도 들어오되 실행만 안 됩니다.
이 차이가 순서를 정합니다. 걸러내기가 먼저입니다.
이유는 실행을 막는 쪽이 브라우저에 의존하기 때문이에요. 우리 서버가 규칙을 보내면 브라우저가 그걸 읽고 지켜줍니다. 그런데 그 데이터를 브라우저만 읽는 게 아니죠. 검색을 만들거나, 통계를 내거나, 알림 메일 본문에 넣거나, 다른 앱이 같은 API를 쓰거나 하면 그때는 우리가 보낸 규칙이 아무 데도 안 걸립니다. 데이터에 담긴 것은 그대로 위험한 채로 나가서, 값이 들어오는 길목에서 처리하는 쪽이 더 넓게 지켜집니다.
지난 시간에는 이걸 "잘못 켰을 때의 대가"로 이야기했었죠. 그 관점도 여전히 유효합니다. 규칙을 잘못 걸면 앱이 통째로 멈추고, 걸러내기를 잘못 걸면 서식 몇 개가 사라지는 정도예요. 두 관점이 같은 답을 가리킵니다.
이미 쌓여버린 값은 어떻게 하나요. 이게 이 주제의 진짜 질문입니다.
나중에 걸러내기를 강화해도 이미 저장된 것은 안 바뀝니다. 우리가 두 시간 전에 이걸 실행으로 확인했었죠. 그리기 직전에 거르는 방식이었기 때문에 옛 데이터도 화면에서는 안전했지만, 만약 저장할 때만 걸렀다면 그 전에 들어온 것들은 계속 위험한 채로 남았을 겁니다.
답은 두 갈래입니다. 하나는 오늘 우리 앱처럼 그리는 시점에 거르는 것이라, 언제 저장됐든 나가는 길목에서 한 번 걸립니다. 다른 하나는 이미 저장된 값을 훑어서 정리하는 것이고, 이건 한 번은 해야 합니다. 데이터가 다른 곳으로도 나가니까요.
정리할 때 원본을 덮어쓰면 되돌릴 수 없다는 점도 생각해두세요. 무엇을 지웠는지 남겨두지 않으면, 나중에 걸러내기가 너무 과했다는 걸 알아도 복구할 방법이 없습니다.
🎯 면접관을 홀리는 핵심 멘트
"저는 걸러내기를 먼저 둡니다. 실행을 막는 규칙은 브라우저가 지켜주는 것이라, 같은 데이터를 검색이나 알림 메일처럼 브라우저를 안 거치는 경로가 쓰면 아무 보호도 안 받거든요. 그리고 저장 시점이 아니라 화면으로 내보내는 시점에 거릅니다. 그래야 규칙을 만들기 전에 이미 들어와 있던 값까지 같은 관문을 지나갑니다. 물론 이미 쌓인 데이터는 별도로 정리해야 하고, 그때는 무엇을 지웠는지 기록을 남깁니다."