세션 요청과 CSRF 방어
85분 안팎
학습 목표
세션 쿠키를 쓰는 변경 요청의 출처와 토큰을 검증합니다.
개념
유효한 세션만으로 변경 의도를 알 수 없습니다
쿠키를 사용하는 앱에서 브라우저는 조건이 맞는 요청에 쿠키를 자동으로 붙입니다. 따라서 서버가 유효한 세션을 확인했다는 사실만으로 사용자가 그 변경을 의도했다고 판단하기 어렵습니다. CSRF는 이 자동 인증 전달을 이용해 원치 않는 상태 변경을 일으키는 문제입니다. 자료 소유자 검사는 요청 주체가 자료를 바꿀 권한이 있는지 판단하지만 사용자가 요청을 의도했는지는 다루지 않습니다.
이번 교재는 변경 요청에 고정 Origin과 세션별 CSRF 토큰을 요구합니다. 허용 출처는 http://127.0.0.1:8765 한 개입니다. localhost나 다른 포트는 이 계약에서 다른 출처이므로 거절합니다. Origin은 서버 설정과 비교하고 요청이 보내는 Host를 기준으로 허용 출처를 만들지 않습니다. 임의 HTTP 클라이언트는 Origin 헤더를 조작할 수 있으므로 이것을 사용자 신원 증명이라고 부르지 않습니다.
발급·전달·검증 경로를 연결합니다
로그인이 성공하면 서버는 새 세션 ID와 무작위 CSRF 토큰을 만들고 둘을 연결합니다. 쿠키는 세션 전달에 사용하고 GET /csrf는 로그인한 세션에 연결된 토큰을 반환합니다. 클라이언트는 이 값을 변경 요청의 X-CSRF-Token 헤더에 담습니다. 서버는 세션에서 기대 토큰을 찾은 후 전달된 토큰과 비교합니다. 토큰을 요청 값만으로 다시 만들거나 공통 상수로 두면 세션 연결의 의미가 사라집니다.
newToken은 Node crypto의 randomBytes(32)를 16진수로 바꿉니다. 비교 전에 두 값이 64자리 소문자 16진 문자열인지 확인합니다. 빈 값끼리 같다고 허용하지 않으며 길이가 다를 때 timingSafeEqual을 바로 호출하지 않습니다. 형식 확인 후 동일 길이 Buffer로 비교합니다. 이는 실습이 정한 토큰 표현이며 토큰 길이를 외우는 것이 목표가 아닙니다. 공격자가 예상할 수 없는 값, 세션 연결, 누락 거절이 핵심입니다.
어느 요청을 보호할지 명시합니다
자료 생성 POST, 자료 변경 PATCH, 자료 삭제 DELETE, 로그아웃 POST에 Origin과 토큰 검사를 적용합니다. GET은 자료 조회와 토큰 조회 및 health만 수행하며 자료를 변경하지 않습니다. URL 쿼리에 토큰을 붙이지 않아 브라우저 기록과 접근 로그에 남는 경로를 줄입니다. 로그인 전에는 세션별 토큰이 없으므로 이번 합성 로그인은 고정 Origin 확인으로 별도 보호합니다. 실제 로그인 폼의 사전 토큰 발급 흐름은 후속 설계 대상이라고 기록합니다.
검사 순서는 세션 인증, 변경 요청의 CSRF, 자료 인가, 입력 검증, 저장 변경입니다. 유효한 세션이 없는 요청은 401, 유효 세션이지만 토큰이나 출처가 틀리면 403입니다. 타인 자료는 올바른 CSRF 토큰으로 요청해도 404입니다. 이 순서에서는 타인 요청의 토큰도 틀리면 먼저 403이 나올 수 있으므로 권한 시험은 올바른 토큰을 준비합니다. 한 오류 상태만 보고 모든 방어가 작동했다고 판단하지 않습니다.
SameSite와 토큰의 역할을 분리합니다
앞 모듈의 쿠키에는 HttpOnly와 SameSite=Strict가 있습니다. HttpOnly는 스크립트의 쿠키 읽기를 제한하는 속성이며 브라우저의 자동 쿠키 전송을 없애지 않습니다. SameSite는 사이트 관계에 따른 쿠키 전송 제약을 제공하지만 사이트와 출처는 같은 개념이 아닙니다. 출처가 달라도 같은 사이트일 수 있습니다. 기존 쿠키 정책을 유지하면서 토큰과 정확한 Origin 검사를 보완 통제로 추가합니다.
이 교재는 실제 브라우저의 쿠키 전송 정책을 자동 검사하지 않습니다. 테스트는 쿠키 헤더를 직접 넣고 HTTP request 리스너를 호출합니다. 따라서 SameSite 덕분에 외부 페이지 요청이 차단되었다고 보고하지 않습니다. 이미 같은 출처에서 스크립트가 실행되는 XSS 상황이라면 토큰을 얻어 요청을 만들 수 있으므로 CSRF 토큰이 XSS를 해결하지도 않습니다. 이전 출력 레슨의 맥락 처리와 이번 요청 검사를 함께 유지하는 이유입니다.
수명과 비밀 노출 경로를 관리합니다
재로그인 시 기존 sid를 폐기하고 새 sid·토큰을 발급합니다. 이전 토큰을 새 세션에서 보내면 거절되어야 합니다. 로그아웃은 세션과 토큰을 지우고 만료 시각 경계에서도 인증을 거절합니다. 토큰 Map의 미사용 항목 정리와 분산 세션 저장은 교재에서 완성하지 않았으므로 메모리 보관이라는 잔여 제약을 남깁니다. 화면 토큰과 세션 쿠키를 그대로 보고서에 붙여 재현성을 높이려 하지 않습니다.
GET /csrf 응답에는 no-store를 붙입니다. 로그는 요청 ID·메서드·경로·상태만 남기며 토큰·쿠키·비밀번호·자료 본문은 제외합니다. 로그아웃 응답도 성공한 때에만 쿠키를 지웁니다. 토큰 검증이 실패한 요청에서 쿠키를 먼저 지우면 정상 사용자 세션을 제3자가 방해할 수 있습니다. 실패가 상태 변경을 일으키지 않는지 자료뿐 아니라 인증 상태도 검토합니다.
실습과 회귀 시험의 기준입니다
security-csrf-guard starter는 csrfAllowed에서 모든 입력을 허용합니다. 함수를 수정해 누락, 빈 값, 다른 세션 토큰, 잘못된 길이, 다른 Origin을 거절하고 정상 조합만 허용합니다. csrf-test.cjs는 아홉 거절 fixture와 정상 대조를 검사합니다. 미션 input-test.cjs에서는 실제 세션 발급과 GET /csrf, 자료 생성·변경·삭제, 로그아웃, 만료까지 연결합니다. 단위 함수 통과와 앱 연결 통과를 각각 확인합니다.
403 csrf_rejected가 나오면 로그인 쿠키가 같은 세션인지, GET /csrf에서 얻은 토큰인지, Origin이 고정 출처와 같은지 차례로 확인합니다. timingSafeEqual의 길이 오류는 문자열 형식 검사 전에 비교한 경우를 의심합니다. 401은 토큰만 고쳐 해결할 수 없고 세션 만료나 폐기를 먼저 읽어야 합니다. 404는 정상 토큰으로도 타인 자료에 접근할 수 없다는 정책일 수 있습니다. 검사를 꺼서 정상 요청을 통과시키지 않습니다.
최종 보고서에는 각 거절의 조건과 상태, 정상 변경의 결과, 거절 뒤 원본 보존, 이전 토큰 폐기 근거를 씁니다. Python 저장 어댑터의 동기 처리, 실제 TLS와 Secure 쿠키, 사전 로그인 폼, 실제 브라우저 동작, Docker external 검사는 범위를 나누어 인계합니다. 레슨을 마치면 쿠키 인증 요청에서 인증·인가·CSRF의 질문을 구별하고 어느 검사가 실패했는지 근거로 설명할 수 있어야 합니다.
이 기법의 공식 참고: OWASP CSRF 토큰과 출처 검증 안내. 서재 더 읽기에서는 같은 원리를 다른 언어와 운영 맥락에서 확장합니다.
따라하기
빈 토큰과 다른 토큰을 거절합니다
반복 문자는 판정 비교용 fixture이며 실제 발급에 쓰지 않습니다. 누락과 빈 문자열도 모두 거절하는지 확인합니다.
const {timingSafeEqual}=require('node:crypto');
function allowed(origin,saved,sent) {
return origin==='http://127.0.0.1:8765' && typeof saved==='string'
&& /^[0-9a-f]{64}$/.test(saved) && typeof sent==='string'
&& /^[0-9a-f]{64}$/.test(sent)
&& timingSafeEqual(Buffer.from(saved),Buffer.from(sent));
}
const saved='a'.repeat(64);
for(const sent of [undefined,'','b'.repeat(64),saved])console.log(allowed('http://127.0.0.1:8765',saved,sent));실행 결과
false false false true
같은 토큰이어도 출처를 확인합니다
localhost를 같은 출처로 취급하지 않습니다. Origin이 없는 요청도 이번 고정 계약에서는 거절합니다.
const {timingSafeEqual}=require('node:crypto');
function allowed(origin,saved,sent) {
return origin==='http://127.0.0.1:8765' && typeof saved==='string'
&& /^[0-9a-f]{64}$/.test(saved) && typeof sent==='string'
&& /^[0-9a-f]{64}$/.test(sent)
&& timingSafeEqual(Buffer.from(saved),Buffer.from(sent));
}
const token='a'.repeat(64);
for(const origin of ['http://127.0.0.1:8765','http://localhost:8765',undefined,'null'])console.log(allowed(origin,token,token));실행 결과
true false false false
이전 세션 토큰을 새 세션에 적용하지 않습니다
새 세션의 기대 토큰을 기준으로 비교합니다. 잘못된 유형이나 길이가 비교 함수 예외를 일으키지 않고 거절되는지도 확인합니다.
const {timingSafeEqual}=require('node:crypto');
function allowed(origin,saved,sent) {
return origin==='http://127.0.0.1:8765' && typeof saved==='string'
&& /^[0-9a-f]{64}$/.test(saved) && typeof sent==='string'
&& /^[0-9a-f]{64}$/.test(sent)
&& timingSafeEqual(Buffer.from(saved),Buffer.from(sent));
}
const old='a'.repeat(64),next='b'.repeat(64);
for(const sent of [old,next,1,'b'])console.log(allowed('http://127.0.0.1:8765',next,sent));실행 결과
false true false false
로컬 과제에서 수정 전후를 검사합니다
이 레슨의 starter ZIP을 풀어 ZIP 루트에서 bash check.sh를 실행합니다. starter의 실패를 읽은 뒤 본문에서 지정한 함수나 질의를 수정합니다. solution ZIP에서도 같은 명령을 실행하여 정상 대조를 확인합니다. 출력 시간 등은 실행마다 달라질 수 있으며 마지막 PASS 행과 종료 코드 0을 확인합니다.
누적 미션을 완성하고 E06을 기록합니다
security-input-storage-mission starter ZIP을 풀고 bash check.sh를 실행합니다. store.py의 search 값 바인딩, security.cjs의 제목 80 코드 포인트 상한·escapeText·csrfAllowed를 완성합니다. 앞 레슨의 단위 풀이를 그대로 연결하고 테스트는 유지합니다. 보고서 E06에는 각 결함의 조건·영향·수정 전후 결과·정상 대조·잔여 위험을 자신의 실행 근거로 보완합니다. bash legacy-check.sh는 Docker 권한·네트워크 경계를 포함하는 external 검사이며 이번 샌드박스에서 실행하지 않습니다.
확인 문제
실습
security.cjs의 csrfAllowed를 완성합니다. 정확한 Origin, 세션별 기대 토큰, 누락·유형·길이·형식 검사를 수행하고 timingSafeEqual로 비교합니다. 아홉 거절 fixture와 정상 대조가 통과해야 합니다. 테스트의 반복 토큰은 비교 fixture이며 실제 발급은 newToken을 사용합니다.
실행 명령
bash check.sh
기대 결과
PASS: nine CSRF rejection fixtures and valid token
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 취약점 수정 전후의 테스트 내용을 설명합니다.