Devin.KR

CSRF와 요청 보호

90분 안팎

학습 목표

세션 기반 변경 요청의 CSRF 방어와 쿠키 정책을 확인합니다.

개념

로그인만으로 요청 의도를 확인할 수 있나요

세션 쿠키는 브라우저가 조건에 맞는 요청에 자동으로 붙입니다. 사용자가 다른 페이지를 열었을 때 그 페이지가 도서 변경 요청을 유도하면 서버는 쿠키만 보고 사용자가 직접 요청했다고 오해할 수 있습니다. CSRF는 이처럼 인증 정보가 자동으로 전달되는 환경에서 요청 의도를 위조하는 문제입니다. 실습 앱과 자기 테스트 환경에서 토큰 없는 요청을 보내 방어를 확인합니다. 외부 사이트에 공격 요청을 보내지 않습니다. 요청자가 로그인했다는 사실과 이번 변경을 정상 화면에서 준비했다는 증거를 구분합니다.

토큰을 쿠키와 다른 전달 경로로 보냅니다

Spring Security의 기본 세션 토큰 저장 방식을 유지합니다. 클라이언트는 GET /csrf 응답의 headerName·parameterName·token을 읽고 변경 요청의 지정 헤더에 token을 넣습니다. 세션 쿠키는 인증을 이어 주고 별도 토큰은 변경 요청 검증에 사용됩니다. 토큰을 URL 쿼리에 넣으면 URL 기록에 남을 수 있으므로 이 예제는 헤더를 선택합니다. 토큰을 정해진 상수로 만들거나 전체 사용자에게 같은 값을 주지 않습니다. GET /csrf는 토큰을 실제로 읽어 지연 생성되는 토큰을 현재 세션과 연결합니다.

로그인 전후의 토큰 수명을 구분합니다

로그인 요청도 CSRF 검사의 대상입니다. 익명 세션에서 받은 토큰으로 POST /login을 보내고 성공하면 인증된 세션에서 GET /csrf를 다시 호출해 변경 요청용 토큰을 얻습니다. 로그인이나 로그아웃 경계에서 토큰이 바뀔 수 있으므로 로그인 전 값을 이후 작업의 영구 토큰으로 보관하지 않습니다. 테스트의 login 메서드는 로그인 세션만 반환하고 token(login(9))이 새 토큰을 조회합니다. 비밀번호가 맞아도 로그인 POST에 토큰이 없으면 403일 수 있습니다. 이 상태를 비밀번호 검증 실패 401과 섞지 않습니다.

변경 메서드와 조회 메서드를 나눕니다

GET /books는 목록을 읽고 DB를 바꾸지 않습니다. POST /books, PATCH /books/{id}, DELETE /books/{id}, PATCH /loans/{id}/note, POST /logout은 변경 또는 세션 종료이므로 토큰을 검사합니다. GET 요청으로 삭제하는 우회 경로를 만들면 이 구분을 깨뜨립니다. 메서드 이름을 바꾸기만 해도 안전해지는 것은 아니며 조회 핸들러가 실제 쓰기를 하지 않는지 검토합니다. CSRF 토큰을 가진 일반 회원도 관리자 도서 수정은 거부됩니다. 토큰 검사 통과는 역할이나 소유권을 부여하지 않습니다.

필터 실행 순서가 상태에 미치는 영향

유효한 토큰이 있는 익명 변경 요청은 인증 검사에서 401이지만, 토큰 없는 변경 요청은 CSRF 필터가 먼저 403으로 거절할 수 있습니다. 따라서 미션의 로그인 401은 로그인 자격 증명 실패와 미인증 조회·유효 토큰 변경 경로로 확인합니다. 모든 익명 POST에 401만 강제하지 않습니다. 상태 하나로 실패 원인을 짐작하지 않고 응답 코드와 요청 준비 조건을 함께 봅니다. 제공된 ACCESS_DENIED 응답은 상세 내부 토큰값을 포함하지 않습니다. 민감한 토큰을 응답 오류나 진단 로그에 그대로 붙이지 않습니다.

진짜 토큰을 사용하는 테스트를 읽습니다

AccessSecurityTest의 token은 MockMvc로 GET /csrf를 실행하고 응답 JSON에서 헤더 이름과 토큰을 읽습니다. 그 요청의 MockHttpSession도 Ticket에 같이 묶어 반환합니다. patch 요청에 session과 header를 모두 붙이면 서버의 실제 CSRF 필터가 검증합니다. 테스트 편의용 csrf()가 없더라도 토큰·세션 결합을 직접 확인할 수 있습니다. 다른 회원 세션에서 얻은 토큰을 관리자 세션에 넣는 anotherSessionTokenIsRejected는 쿠키와 토큰 중 하나만 맞추는 실수를 잡습니다. 이 검사는 브라우저의 사이트 간 쿠키 정책을 실행하는 검사는 아닙니다.

거절 뒤의 DB를 관찰합니다

관리자 세션으로 제목을 바꾸되 토큰을 빼면 403이고 원래 제목은 보존됩니다. 틀린 문자열을 토큰으로 보내는 경우도 같은 보호를 확인합니다. 소유자 세션에서 메모를 바꾸더라도 토큰이 없으면 loan_notes 행은 생기지 않습니다. 상태 검사만 통과하고 SQL 값이 바뀌었다면 방어 경계 앞에서 쓰기가 일어났거나 다른 경로가 데이터를 수정한 것입니다. 성공 요청도 같이 검사해 토큰 검사를 전부 거부하는 구현을 걸러냅니다. CSRF 거절 테스트는 입력 검증 테스트와 달리 인증·역할 조건을 먼저 성공하도록 준비합니다.

SameSite·CORS와 책임이 겹치지 않습니다

SameSite는 사이트 관계에 따른 쿠키 전달 조건입니다. CORS는 브라우저가 다른 출처의 응답을 스크립트에 노출하는 조건을 다룹니다. CORS 오류가 났다는 사실만으로 서버가 변경 요청을 받지 않았다고 결론 내리지 않습니다. 요청 형식에 따라 서버 처리가 이미 일어났을 수 있습니다. SameSite도 배포 환경과 브라우저 동작에 영향을 받으므로 이 레슨은 CSRF 토큰을 제거하는 근거로 사용하지 않습니다. HttpOnly는 세션 쿠키 읽기를 제한하지만 사이트 내 악성 스크립트가 보내는 요청까지 막는 기능은 아닙니다.

쿠키 설정의 확인 범위를 적습니다

실제 배포에서는 HTTPS와 세션 쿠키의 Secure·HttpOnly·SameSite·Path를 개발자 도구의 Set-Cookie 및 요청 Cookie에서 확인합니다. 여기서는 네트워크 서버를 실행하지 않았으므로 해당 브라우저 관찰의 출력은 제공하지 않습니다. 로컬 HTTP 실습에 Secure만 붙이면 브라우저가 쿠키를 전송하지 않아 매 요청이 익명처럼 보일 수 있습니다. HTTPS 프록시 뒤 설정은 신뢰하는 프록시와 전달 헤더 처리까지 따로 검토합니다. 테스트 통과는 제공된 MockMvc 필터 동작의 증거이며 배포 쿠키의 실제 전달 증거와 구별해 기록합니다.

starter의 예외 경로를 좁힙니다

starter의 filterChain에는 /books/**와 /loans/**를 CSRF 검사에서 제외하는 TODO가 있습니다. 이 제외 설정을 제거해 기본 CSRF 검사를 유지합니다. /csrf를 permitAll로 둔 것은 토큰을 얻도록 허용한 것이며 변경 경로의 검사 면제와 다릅니다. PATCH가 갑자기 403이면 보호를 끄기 전에 로그인 후 토큰 재조회·세션 전달·headerName 사용을 점검합니다. POST /logout에 토큰을 넣어 정상 종료도 확인합니다. 요청 경로의 방어와 네트워크 전송 원리는 더 읽기의 계층 설명으로 확장하고 이번 레슨에서는 토큰 검증을 실제 테스트로 고정합니다.

따라하기

실패 경계와 수정 파일을 찾습니다

SecurityConfiguration의 CSRF 제외 TODO를 제거합니다. 로그인 후 /csrf 응답에서 얻은 헤더와 토큰을 같은 세션으로 전달해 정상 변경을 확인합니다. 토큰 누락·오류·다른 세션 토큰의 거절 후 DB 불변을 검사합니다.

starter에서 먼저 실행해 실패 테스트 이름을 확인합니다. README의 권한 표와 src/test/java/lab/AccessSecurityTest.java의 해당 경로를 대조합니다.

./mvnw test > test.log 2>&1

TODO를 주제에 맞게 복원합니다

아래 코드를 해당 메서드와 규칙 위치에 반영합니다. 서로 다른 메서드의 조각을 한 메서드에 붙이지 않습니다. 테스트 기대값을 바꾸는 대신 요청 경계를 수정합니다.

// 다음 starter 줄을 제거합니다. 기본 CSRF 보호를 유지합니다.
http.csrf(c -> c.ignoringRequestMatchers("/books/**","/loans/**"));
// 변경 요청은 로그인 후 조회한 Ticket의 세션과 토큰을 같이 전달합니다.
mvc.perform(patch("/books/7").session(t.session())
 .header(t.header(),t.token()).contentType(MediaType.APPLICATION_JSON)
 .content("{\"title\":\"관리자 수정\"}"));

성공과 거절을 누적 검사합니다

수정한 starter 폴더에서 다시 실행합니다. 실패하면 test.log와 target/surefire-reports의 테스트 이름·기대 상태를 확인합니다. 실제 보안 테스트는 요청·응답 자동 인쇄를 꺼 비밀번호나 토큰이 로그에 남지 않도록 했습니다.

./mvnw test > test.log 2>&1

실행한 보고서를 집계합니다

모범 구현에서 실행한 누적 결과입니다. 95개 기존 검사와 27개 보안 검사가 포함됩니다. 학습자의 결과도 실패·오류·건너뜀 0인지 대조합니다.

python3 report.py

실행 결과

Tests=122
Failures=0
Errors=0
Skipped=0

확인 문제

실습

SecurityConfiguration의 CSRF 제외 TODO를 제거합니다. 로그인 후 /csrf 응답에서 얻은 헤더와 토큰을 같은 세션으로 전달해 정상 변경을 확인합니다. 토큰 누락·오류·다른 세션 토큰의 거절 후 DB 불변을 검사합니다.

테스트를 삭제·비활성화하지 않고 README의 API 계약을 지킵니다. report.py의 집계와 수정 이유를 제출합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

Tests=122, Failures=0, Errors=0, Skipped=0입니다.

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • 인증에 성공했지만 수정 요청을 거부해야 하는 사례를 설명합니다.