Web Security 실전 훈련북

이 문서의 사용법

이 문서는 Web Security 문서를 다시 설명하는 요약본이 아니다.

각 훈련은 먼저 상황과 evidence를 보고, 빈 답안에 자신의 판단을 쓴 뒤, 해설과 연결 문서를 확인하는 순서로 사용한다. 해설을 먼저 읽으면 훈련 효과가 약해진다.

한 번에 전부 끝내려 하지 말고 같은 훈련을 다른 프로젝트 상황으로 바꾸어 반복한다.

훈련 방식

  1. 상황을 읽고 보호 자산, actor, entry point를 먼저 적는다.
  2. evidence에서 조작 가능한 값과 서버가 만든 값을 구분한다.
  3. 바로 결론을 내리지 말고 확인해야 할 로그, 요청, 응답, 설정, 테스트를 쓴다.
  4. 내 답안에 직접 판단 순서와 조치를 작성한다.
  5. 해설을 보며 빠진 경계, 로그, 테스트, 운영 조치를 표시한다.
  6. 연결 문서를 열어 원래 판단 기준으로 돌아간다.

반복 학습 루프

상황 읽기
  -> evidence 표시
  -> 먼저 판단 쓰기
  -> 해설과 비교
  -> 연결 문서 다시 열기
  -> 다음 PR, 테스트, runbook에 반영

반복할 때는 정답 문장을 외우지 말고 “어느 값이 어느 경계에서 신뢰되는가”를 다시 묻는다.

훈련 목록

구분훈련핵심 판단
Lab 1팀 초대 API threat model 작성자산, actor, entry point, trust boundary를 요구사항으로 바꾼다
Lab 2로그인 실패 응답과 session 보호 정책 작성계정 열거, credential stuffing, session 발급을 함께 본다
Lab 3Resource ownership 권한 테스트 matrix 작성인증 성공과 resource 접근 허용을 분리한다
Lab 4DTO, validation, error, rate limit 경계 점검사용자가 보낼 수 있는 필드와 반복 호출을 제한한다
Incident 1로그인 실패 급증과 계정 탈취 의심장애와 공격을 로그 패턴으로 분리한다
Incident 2token/session 탈취 의심token 종류별 폐기 범위와 증거를 정한다
Incident 3권한 우회 신고 대응영향 범위 산정과 containment를 먼저 잡는다
Incident 4CORS, CSRF, XSS 사고 대응브라우저 정책 실패와 권한 실패를 분리한다
Review 1session, cookie, JWT, refresh token 설계 리뷰저장 위치, 검증, 회전, 폐기를 함께 본다
Review 2OAuth 계정 연결과 MFA step-up 설계 리뷰provider identity와 내부 subject를 섞지 않는다
Review 3Gateway, WAF, Spring Security 책임 경계 리뷰앞단 방어와 애플리케이션 권한 책임을 나눈다
Final팀 초대 배포 후 복합 보안 사고 triage여러 문서를 한 사고 흐름으로 연결한다

Part 1. 실습 랩

Lab 1. 팀 초대 API threat model 작성

상황

팀 협업 서비스에 POST /teams/{teamId}/invites API를 추가하려고 한다.

제품 요구사항은 “팀 관리자가 이메일로 팀원을 초대하고 role을 지정할 수 있다” 정도로만 적혀 있다. 이 상태로 구현에 들어가면 role 상승, 만료되지 않는 초대 token, 초대 남용, 감사 로그 누락이 섞일 수 있다.

제공된 Evidence

POST /teams/42/invites HTTP/1.1
Host: api.example.com
Cookie: SESSION=member-session
Content-Type: application/json
 
{
  "email": "new-user@example.com",
  "role": "OWNER",
  "expiresInDays": 365,
  "invitedByRole": "OWNER"
}
현재 요구사항:
- 팀 관리자는 팀원을 초대할 수 있다.
- 초대 role은 MEMBER, MAINTAINER, OWNER 중 선택한다.
- 초대 링크는 이메일로 전송한다.

해야 할 일

  • 보호 자산, actor, entry point를 표로 작성한다.
  • 허용 role과 금지 role을 요구사항 문장으로 바꾼다.
  • 거부 응답과 감사 로그 필드를 정한다.
  • 이 API에 필요한 실패 테스트 이름을 3개 이상 만든다.

먼저 써보기

힌트

role은 enum validation만으로 충분하지 않다. 누가 어떤 role을 누구에게 부여할 수 있는지, 자기보다 높은 권한을 만들 수 있는지, 초대 token 원문이 어디에 남는지를 봐야 한다.

해설

좋은 답안은 기능 문장을 보안 계약으로 바꾼다.

예를 들어 team_ownerorganization_admin만 초대 API를 호출할 수 있고, OWNER role은 초대 API에서 금지한다는 식으로 허용 조건을 좁혀야 한다. invitedByRole처럼 클라이언트가 보낸 권한 근거는 무시하거나 거부한다.

초대 token은 bearer credential이므로 원문을 로그, trace, metric label에 남기지 않는다. 거부 로그에는 actor, team, requested_role, reason, traceId가 있어야 한다.

연결 문서

가져갈 판단 기준

새 API 요구사항은 “누가 할 수 있다”가 아니라 “누가, 어떤 자산에, 어떤 action을, 어떤 조건에서 할 수 있고, 거부는 어떻게 증명되는가”로 써야 한다.

Lab 2. 로그인 실패 응답과 session 보호 정책 작성

상황

로그인 API가 사용자에게 너무 자세한 실패 이유를 보여 준다는 리뷰가 나왔다.

제품팀은 사용자가 빨리 문제를 고칠 수 있도록 메시지를 자세히 보여 주고 싶어 한다. 보안 관점에서는 계정 열거와 credential stuffing의 단서가 될 수 있다.

제공된 Evidence

POST /login HTTP/1.1
Host: api.example.com
Content-Type: application/json
 
{
  "email": "admin@example.com",
  "password": "Summer2026!"
}
HTTP/1.1 401 Unauthorized
Content-Type: application/json
 
{
  "code": "PASSWORD_MISMATCH",
  "message": "admin@example.com 계정은 존재하지만 비밀번호가 다릅니다",
  "failedCount": 4
}
현재 로그:
INFO login failed email=admin@example.com reason=PASSWORD_MISMATCH password=Summer2026!

해야 할 일

  • 외부 응답 메시지를 다시 설계한다.
  • 내부 로그 필드를 다시 설계한다.
  • 성공 직후 session 발급과 MFA 필요 여부를 어떤 순서로 판단할지 적는다.
  • credential stuffing 의심 시 계정 잠금 대신 어떤 보호 조치를 검토할지 쓴다.

먼저 써보기

힌트

사용자 응답은 일반화하고, 서버 로그는 원문 credential 없이 원인과 risk signal을 분리한다. session ID는 발급 직후부터 bearer credential로 다룬다.

해설

응답은 계정 존재 여부, 잠금 여부, 실패 횟수를 직접 알려 주지 않아야 한다. 내부 로그는 identifier_hash, account_id, ip, asn, user_agent_hash, result, reason, risk, traceId처럼 분석 가능한 필드로 남긴다.

로그인 성공 뒤에는 MFA 필요 여부, session fixation 방지, session cookie 속성, 인증 이벤트 기록이 이어져야 한다. 공격 의심 시 전체 계정 잠금보다 step-up, 지연, 알림, 고위험 계정 보호처럼 사용자 피해를 줄이는 조치를 먼저 검토한다.

연결 문서

가져갈 판단 기준

로그인 실패 응답은 사용자에게는 단순해야 하고, 운영자에게는 원문 credential 없이 충분히 구조적이어야 한다.

Lab 3. Resource ownership 권한 테스트 matrix 작성

상황

주문 상세 API에서 다른 사용자의 주문 ID를 넣어도 200 응답이 나온다는 테스트 실패가 발생했다.

코드에는 @PreAuthorize("isAuthenticated()")가 있고, 팀은 “인증은 적용되어 있다”고 설명한다. 하지만 이 문제는 인증이 아니라 resource ownership 판단이다.

제공된 Evidence

GET /api/orders/2002 HTTP/1.1
Host: api.example.com
Authorization: Bearer access-token-for-member-1004
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "orderId": 2002,
  "memberId": 2002,
  "items": ["monitor"],
  "totalPrice": 320000
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/api/orders/{orderId}")
public OrderResponse get(@PathVariable Long orderId) {
    return orderService.get(orderId);
}

해야 할 일

  • 인증, role, ownership, action을 나눈 테스트 matrix를 작성한다.
  • 403과 404 중 어떤 응답을 택할지 판단 기준을 쓴다.
  • Service와 Repository에서 각각 어떤 조건이 필요할지 쓴다.
  • audit log에 남길 필드를 정한다.

먼저 써보기

힌트

isAuthenticated()는 principal이 있다는 뜻이지, 이 주문을 읽어도 된다는 뜻이 아니다. 같은 role의 사용자끼리도 resource ownership은 다를 수 있다.

해설

matrix에는 최소한 인증 없음, 만료 token, 같은 사용자 resource, 다른 사용자 resource, 관리자 role, 권한 없는 role, 삭제된 resource가 있어야 한다.

Service는 principal과 resource의 관계를 정책으로 판단하고, Repository는 findByIdAndMemberId처럼 데이터 접근 조건으로 마지막 방어선을 만든다. audit log에는 actor, resource, action, result, reason, traceId가 있어야 한다.

연결 문서

가져갈 판단 기준

인가 테스트는 role 이름보다 actor, resource, action, owner 관계를 증명해야 한다.

Lab 4. DTO, validation, error, rate limit 경계 점검

상황

프로필 수정 API가 entity를 request body로 직접 받고 있다.

프론트 화면에는 닉네임과 소개만 있지만, API에 다른 필드를 넣어 보내면 서버가 어떤 필드를 받아들이는지 명확하지 않다. 동시에 같은 API를 반복 호출해 profile update 이벤트가 과도하게 쌓이는 현상도 있다.

제공된 Evidence

PATCH /api/me/profile HTTP/1.1
Host: api.example.com
Cookie: SESSION=valid
Content-Type: application/json
 
{
  "displayName": "new-name",
  "bio": "hello",
  "role": "ADMIN",
  "emailVerified": true,
  "point": 999999
}
@PatchMapping("/api/me/profile")
public Profile update(@RequestBody Profile profile) {
    return profileRepository.save(profile);
}

해야 할 일

  • 요청 DTO allowlist를 설계한다.
  • validation과 output encoding의 책임을 나눈다.
  • 예상 밖 필드 정책을 정한다.
  • 오류 응답과 운영 로그를 분리한다.
  • 반복 호출 abuse를 제한할 key를 정한다.

먼저 써보기

힌트

DTO를 쓰는 목적은 계층 분리가 아니라 사용자가 바꿀 수 있는 필드를 계약으로 제한하는 것이다. 정상 형식의 요청도 반복되면 abuse가 될 수 있다.

해설

사용자용 DTO에는 displayName, bio처럼 사용자가 바꿀 수 있는 필드만 둔다. role, emailVerified, point, accountLocked는 별도 관리자 기능과 정책으로 분리한다.

validation은 입력의 타입, 길이, 형식, 업무 의미를 줄인다. output encoding은 저장된 값이 HTML이나 JavaScript 문맥에서 실행되지 않도록 출력 시점에 적용한다. 오류 응답은 안정된 code와 traceId만 주고, 내부 reason은 로그에 남긴다.

rate limit는 IP 하나로 끝내지 말고 account, device, endpoint, 실패 횟수, 비용 단위를 조합한다.

연결 문서

가져갈 판단 기준

요청 DTO는 데이터 운반 객체가 아니라 사용자가 바꿀 수 있는 필드의 보안 계약이다.

Part 2. 사고 대응 훈련

Incident 1. 로그인 실패 급증과 계정 탈취 의심

Incident 상황

월요일 오전 로그인 실패가 평소보다 8배 증가했다.

일부 사용자는 로그인 장애를 신고했고, 보안 알림에는 특정 계정들의 실패 후 성공 이벤트가 보인다. 장애인지 공격인지 아직 확정할 수 없다.

현재 보이는 증상

  • /login 401 응답 증가
  • 일부 계정에서 실패 후 성공 이벤트 발생
  • 관리자 계정 3개에서 MFA challenge 반복 생성
  • 인증 서버의 CPU와 DB 지연은 정상 범위

요청/응답/로그 단서

level=WARN event=login_failed identifier_hash=91ad account_id=unknown ip=203.0.113.10 asn=64500 user_agent_hash=8c21 reason=bad_credentials trace_id=a1
level=WARN event=login_failed identifier_hash=91ad account_id=unknown ip=198.51.100.77 asn=64501 user_agent_hash=1a04 reason=bad_credentials trace_id=a2
level=INFO event=login_succeeded account_id=1004 prior_failures=9 mfa_required=true ip=198.51.100.77 trace_id=a3
level=WARN event=mfa_verify_failed account_id=1004 method=push reason=too_many_push_requests attempt_count=5 trace_id=a4

5분 안에 확인할 것

  • 내부 인증 장애 지표가 함께 튀는지 확인한다.
  • 실패가 특정 계정 집중인지, 여러 계정 분산인지 본다.
  • 실패 후 성공 계정과 MFA 실패 계정을 우선 표시한다.
  • 원문 password, OTP, recovery code가 로그에 남았는지 확인한다.
  • 관리자 계정과 고위험 계정을 별도 목록으로 묶는다.

원인 후보 분류

  • 인증 서버 장애
  • credential stuffing
  • password spraying
  • MFA fatigue
  • 계정 열거
  • 정상 사용자의 동시 로그인 실패

하면 안 되는 조치

  • 원인 확인 전 전체 계정을 잠그지 않는다.
  • 모든 로그인 실패를 사용자 실수로 처리하지 않는다.
  • 원문 credential이 남는 로그를 더 오래 보존하지 않는다.
  • MFA가 있으니 성공 계정은 안전하다고 단정하지 않는다.

대응 절차

  1. incident id를 만들고 관련 로그 snapshot을 보존한다.
  2. 실패 이벤트를 identifier, account, IP, ASN, user agent, 시간대로 집계한다.
  3. 실패 후 성공 계정에 step-up과 알림을 적용한다.
  4. 관리자와 고위험 계정은 session과 MFA 상태를 별도 확인한다.
  5. 공격 패턴이 명확하면 계정, IP, ASN, device 기준으로 단계적 제한을 적용한다.
  6. 사용자 공지는 확인된 사실과 아직 확인 중인 사실을 분리한다.

완료 검증

  • 로그인 실패율과 실패 후 성공 패턴이 정상 범위로 돌아왔는지 확인한다.
  • 보호 조치가 적용된 계정 목록과 이유가 남아 있는지 확인한다.
  • MFA fatigue 의심 계정의 challenge 반복이 멈췄는지 확인한다.
  • 원문 credential 노출 로그가 없거나 격리되었는지 확인한다.

회고 질문

  • credential stuffing과 password spraying을 구분하는 rule이 있었는가?
  • 실패 후 성공 이벤트가 alert로 이어졌는가?
  • 관리자 계정 threshold가 일반 계정과 달랐는가?
  • 사용자 잠금 DoS를 피하는 보호 정책이 있었는가?

연결 문서

가져갈 판단 기준

로그인 사고의 첫 판단은 실패 횟수가 아니라 실패의 분포와 실패 후 성공의 연결이다.

Incident 2. Token/session 탈취 의심

Incident 상황

한 사용자가 “로그아웃했는데도 다른 기기에서 활동이 보인다”고 신고했다.

동시에 refresh token reuse alert가 같은 계정에서 발생했다. access token, refresh token, server session 중 무엇이 노출되었는지 아직 모른다.

현재 보이는 증상

  • 같은 account에서 서로 다른 ASN의 refresh 요청 발생
  • 이미 사용된 refresh token 재제출
  • logout 이벤트 이후 API 호출 지속
  • 민감 조회 endpoint 호출 증가

요청/응답/로그 단서

level=INFO event=logout_completed account_id=1004 scope=current_device session_revoked=true trace_id=b1
level=WARN event=refresh_token_reuse_detected account_id=1004 family_id=fam_8a reused_token_id=rt_17 action=family_revoked ip=198.51.100.7 trace_id=b2
level=INFO event=api_access account_id=1004 endpoint=/api/me/security method=GET token_jti=at_992 ip=198.51.100.7 trace_id=b3

5분 안에 확인할 것

  • 노출 의심 credential이 access token인지 refresh token인지 session ID인지 구분한다.
  • refresh token family와 device scope를 확인한다.
  • logout이 current device만 대상으로 했는지 전체 기기 대상인지 확인한다.
  • token 원문이 로그, APM, proxy log에 남았는지 확인한다.
  • 민감 endpoint 호출 범위와 시간대를 산정한다.

원인 후보 분류

  • refresh token 탈취
  • access token 탈취
  • session cookie 탈취
  • XSS로 JavaScript 접근 가능 token 노출
  • 로그나 debug endpoint의 token 노출
  • 정상 동시 refresh race

하면 안 되는 조치

  • 브라우저 저장소 삭제만 안내하고 서버 token 상태를 방치하지 않는다.
  • refresh token reuse를 단순 재시도 오류로 처리하지 않는다.
  • token 원문을 분석 편의로 복사하지 않는다.
  • 전체 계정 세션 무효화를 영향 분석 없이 먼저 적용하지 않는다.

대응 절차

  1. 관련 account, device, token family, session id hash를 묶는다.
  2. reuse가 확인된 token family를 폐기한다.
  3. 필요하면 해당 account의 모든 session과 refresh token을 폐기한다.
  4. access token 잔여 TTL 동안 호출된 민감 endpoint를 산정한다.
  5. XSS, 로그 노출, 저장 위치 정책 위반을 원인 후보로 확인한다.
  6. 사용자에게 재로그인, 비밀번호 변경, MFA 확인을 안내한다.

완료 검증

  • 같은 refresh token 재사용이 더 이상 access token 발급으로 이어지지 않는다.
  • 폐기된 token family와 session scope가 감사 로그로 남아 있다.
  • 민감 API 호출 범위가 timeline에 정리되어 있다.
  • token 원문 노출 지점이 제거되거나 격리되어 있다.

회고 질문

  • refresh token rotation과 reuse detection이 실제로 동작했는가?
  • logout 범위가 사용자에게 명확했는가?
  • access token TTL이 사고 대응 가능한 수준이었는가?
  • token 저장 위치 선택이 문서화되어 있었는가?

연결 문서

가져갈 판단 기준

token 사고 대응은 “로그아웃시킨다”가 아니라 어떤 credential 묶음을 어떤 이유로 폐기했는지 증명하는 일이다.

Incident 3. 권한 우회 취약점 신고 대응

Incident 상황

외부 제보자가 다른 팀의 project id를 URL에 넣으면 일부 설정을 읽을 수 있다고 신고했다.

문제 endpoint는 관리자 화면에서만 호출되지만, 서버는 isAuthenticated()만 확인하고 있었다.

현재 보이는 증상

  • /api/projects/{projectId}/settings에서 다른 팀 project 조회 가능
  • 같은 role의 사용자 간 resource id 변경으로 재현됨
  • export endpoint에도 같은 Service가 사용됨
  • audit log에는 200 access log만 있고 authorization decision이 없음

요청/응답/로그 단서

GET /api/projects/9001/settings HTTP/1.1
Host: api.example.com
Authorization: Bearer member-token-for-team-42
HTTP/1.1 200 OK
Content-Type: application/json
 
{
  "projectId": 9001,
  "teamId": 77,
  "webhookUrl": "https://hooks.example/internal",
  "billingPlan": "enterprise"
}
level=INFO event=request_completed path=/api/projects/{projectId}/settings status=200 account_id=1004 trace_id=c1

5분 안에 확인할 것

  • 읽기, 수정, export endpoint 전체 영향 범위를 확인한다.
  • 같은 Service와 Repository를 공유하는 API를 찾는다.
  • 제보 요청의 actor, resource, action, team 관계를 재현한다.
  • 민감 필드가 응답에 포함되었는지 분류한다.
  • 거부 로그가 없더라도 access log로 가능한 timeline을 만든다.

원인 후보 분류

  • resource ownership 검증 누락
  • role check와 object permission 혼동
  • Repository 조회 조건 누락
  • 관리자 화면 UI 숨김에 의존
  • export 경로의 별도 권한 누락
  • audit log 설계 누락

하면 안 되는 조치

  • 화면에서 버튼만 숨기고 서버 수정을 미루지 않는다.
  • 신고 endpoint 하나만 막고 같은 Service 호출 경로를 놓치지 않는다.
  • 영향 범위 산정 없이 “수정 완료”로 종료하지 않는다.
  • 404/403 응답 정책을 로그 없이 바꾸지 않는다.

대응 절차

  1. 해당 endpoint를 임시 제한하거나 민감 필드를 축소한다.
  2. Service에서 actor, resource, action 기준 정책을 추가한다.
  3. Repository 조회에 team/account 조건을 포함한다.
  4. 목록, 상세, 수정, export, admin 경로를 같은 matrix로 테스트한다.
  5. authorization_denied audit event를 추가한다.
  6. 영향 받은 resource와 account 범위를 보고서에 남긴다.

완료 검증

  • 비소유자, 낮은 role, 만료 권한, 다른 team 요청이 실패한다.
  • 같은 Service를 쓰는 모든 endpoint가 owner 조건을 가진다.
  • 거부 이벤트가 actor, resource, action, reason, traceId로 남는다.
  • 노출된 필드와 시간 범위가 정리되어 있다.

회고 질문

  • 설계 단계에서 owner check가 요구사항에 있었는가?
  • 테스트가 happy path에 치우치지 않았는가?
  • audit log가 권한 결정을 복원할 수 있었는가?
  • 리뷰 질문이 Service와 Repository까지 따라갔는가?

연결 문서

가져갈 판단 기준

권한 우회 사고에서 가장 먼저 증명해야 하는 것은 수정 여부가 아니라 무엇이 누구에게 노출되었는지다.

Incident 4. CORS, CSRF, XSS 사고 대응

Incident 상황

배포 후 프론트엔드에서 CORS 오류가 증가했다.

운영자가 급하게 Origin allowlist를 넓힌 뒤, 일부 사용자의 이메일 변경 이벤트가 본인 의도 없이 발생했다. 동시에 CSP violation report가 늘었다.

현재 보이는 증상

  • credential 포함 요청에서 Origin 반사 설정 발견
  • 이메일 변경 POST에 CSRF token이 없는 요청 성공
  • 댓글 영역에서 CSP violation report 발생
  • 일부 사용자의 security event가 같은 외부 referer와 연결됨

요청/응답/로그 단서

POST /api/account/email HTTP/1.1
Host: app.example.com
Cookie: __Host-SESSION=victim-session
Origin: https://evil.example
Content-Type: application/x-www-form-urlencoded
 
email=attacker@example.net
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true
level=WARN event=csp_violation_reported document_uri=/posts/77 directive=script-src blocked_uri=https://evil.example trace_id=d1

5분 안에 확인할 것

  • CORS 문제인지, CSRF 상태 변경 문제인지, XSS 실행 문제인지 분리한다.
  • 외부 Origin이 응답을 읽었는지와 상태 변경이 실행되었는지를 따로 본다.
  • CSRF token, Origin/Referer 검증, SameSite cookie 상태를 확인한다.
  • XSS payload 저장 여부와 피해 사용자 범위를 확인한다.
  • 급한 설정 변경이 어떤 경로를 열었는지 기록한다.

원인 후보 분류

  • CORS Origin 반사
  • credential 포함 wildcard성 설정
  • CSRF token 누락
  • SameSite 예외 처리 실수
  • 저장 XSS 또는 DOM XSS
  • CSP report-only 상태 장기 방치

하면 안 되는 조치

  • CORS 오류를 없애려고 모든 Origin을 허용하지 않는다.
  • CORS 차단이 CSRF 방어라고 가정하지 않는다.
  • XSS payload 원문을 관리자 화면이나 로그에 그대로 출력하지 않는다.
  • CSP report가 많다는 이유로 CSP를 제거하지 않는다.

대응 절차

  1. CORS allowlist를 production Origin 기준으로 되돌린다.
  2. 상태 변경 endpoint에 CSRF token 또는 Origin 검증을 강제한다.
  3. 이메일 변경, 비밀번호 변경, MFA 변경 이벤트를 시간순으로 확인한다.
  4. XSS 저장 지점이 있으면 payload 제거, cache purge, 영향 사용자 session 보호를 수행한다.
  5. CSP report를 실제 공격 후보와 확장 프로그램 잡음으로 분류한다.
  6. 배포 전 보안 header와 CORS smoke test를 추가한다.

완료 검증

  • 허용되지 않은 Origin의 credential 포함 요청이 응답을 읽을 수 없다.
  • CSRF token 없는 상태 변경 요청이 실패한다.
  • 저장된 XSS payload가 실행되지 않는다.
  • CSP enforce 또는 전환 계획이 문서화되어 있다.
  • 관련 사용자 보호 조치와 공지가 완료되어 있다.

회고 질문

  • CORS와 CSRF의 차이를 운영 runbook이 설명하고 있었는가?
  • SameSite 예외가 문서화되어 있었는가?
  • CSP report를 보는 owner가 있었는가?
  • 배포 전 실제 response header 검증이 있었는가?

연결 문서

가져갈 판단 기준

브라우저 보안 사고는 CORS, CSRF, XSS를 한 단어로 뭉치지 말고 응답 읽기, 상태 변경, script 실행을 분리해 본다.

Part 3. 설계 리뷰 훈련

제안된 설계안

SPA에서 access token과 refresh token을 모두 localStorage에 저장한다.

access token TTL은 24시간, refresh token TTL은 30일이다. logout은 프론트에서 localStorage를 지우는 방식으로 처리한다. 서버는 JWT 서명만 검증하고 audience는 보지 않는다.

겉보기 장점

  • 구현이 단순하다.
  • 서버 session 저장소가 필요 없다.
  • 프론트에서 새로고침 후에도 로그인 상태가 유지된다.
  • API 서버는 stateless하게 확장할 수 있다.

숨은 리스크

  • XSS 한 번으로 refresh token까지 탈취될 수 있다.
  • logout 후에도 이미 탈취된 refresh token은 살아 있다.
  • audience 검증이 없으면 다른 API용 token을 받아들일 수 있다.
  • 권한 변경과 계정 잠금이 긴 access token에 즉시 반영되지 않는다.
  • refresh token reuse detection이 없으면 장기 탈취를 발견하기 어렵다.

리뷰 질문

  • refresh token은 왜 JavaScript 접근 가능 저장소에 있어야 하는가?
  • access token과 refresh token TTL은 어떤 피해 시간을 기준으로 정했는가?
  • JWT 검증에서 issuer, audience, expiry, alg, kid를 모두 확인하는가?
  • logout, password change, MFA 해제, 계정 잠금 시 무엇을 폐기하는가?
  • refresh token reuse가 감지되면 어떤 family를 폐기하는가?

빠진 보안 계약

  • token 저장 위치
  • access token TTL과 refresh token TTL
  • refresh token rotation과 reuse detection
  • logout scope
  • JWT issuer와 audience
  • token 원문 로그 금지
  • token family 폐기 조건

개선된 설계 방향

refresh token은 HttpOnly, Secure, SameSite cookie나 BFF server session 같은 더 좁은 경계로 옮기는 방안을 검토한다. access token은 짧게 유지하고 refresh token rotation과 reuse detection을 추가한다.

JWT 검증은 서명뿐 아니라 issuer, audience, expiry, not-before, algorithm allowlist, key id를 본다. logout은 클라이언트 삭제와 서버 폐기를 함께 수행한다.

승인 조건

  • refresh token 저장 위치와 CSRF/Origin 방어가 문서화되어 있다.
  • token rotation과 reuse detection 테스트가 있다.
  • logout 후 같은 refresh token으로 재발급이 실패한다.
  • JWT audience가 다른 token이 거부된다.
  • token 원문이 로그, trace, APM에 남지 않는다.

연결 문서

가져갈 판단 기준

token 설계는 발급보다 탈취, 재사용, 폐기, 로그 노출을 먼저 견뎌야 한다.

Review 2. OAuth 계정 연결과 MFA step-up 설계 리뷰

제안된 설계안

사용자가 로그인한 상태에서 Google 계정을 연결할 수 있게 한다.

callback에서 받은 email이 기존 계정 이메일과 같으면 자동으로 연결한다. state는 사용하지 않고, redirect URI는 요청 파라미터의 값을 그대로 사용한다. MFA 설정 변경도 로그인 상태면 바로 허용한다.

겉보기 장점

  • 사용자 경험이 간단하다.
  • 이메일이 같으면 같은 사람이라고 보기 쉽다.
  • OAuth provider가 인증을 처리하므로 서버 구현이 줄어든다.
  • MFA 변경 절차가 짧아진다.

숨은 리스크

  • provider의 email과 내부 subject id를 혼동할 수 있다.
  • state 검증이 없으면 로그인 CSRF와 계정 연결 공격이 가능하다.
  • redirect URI 검증이 없으면 authorization code가 잘못된 곳으로 흐를 수 있다.
  • MFA 해제와 recovery code 재발급이 계정 탈취 후 첫 공격면이 된다.
  • provider subject, issuer, audience, email_verified를 구분하지 않으면 identity가 흔들린다.

리뷰 질문

  • OAuth account linking의 기준은 email인가 provider subject인가?
  • state, PKCE, redirect URI 검증은 어디서 수행되는가?
  • ID token과 access token을 구분해 검증하는가?
  • MFA 변경에는 최근 step-up이 필요한가?
  • account linking, unlinking, MFA 변경이 audit log로 남는가?

빠진 보안 계약

  • provider issuer와 subject
  • state 저장소와 만료
  • PKCE verifier 검증
  • redirect URI allowlist
  • account linking 승인 조건
  • MFA step-up 요구 조건
  • recovery code 저장과 사용 로그

개선된 설계 방향

계정 연결은 provider email이 아니라 issuer와 provider subject를 기준으로 내부 subject와 연결한다. email은 보조 신호로만 본다. OAuth callback은 state, PKCE, redirect URI allowlist를 검증해야 한다.

MFA 해제, recovery code 재발급, 소셜 계정 연결 해제 같은 민감 action은 최근 step-up과 감사 로그를 요구한다.

승인 조건

  • state가 없거나 불일치하면 callback이 실패한다.
  • redirect URI가 allowlist 밖이면 실패한다.
  • provider subject 충돌 시 자동 연결하지 않는다.
  • MFA 해제에는 최근 step-up이 필요하다.
  • account linking과 MFA 변경 이벤트에 actor, subject, provider, result, reason, traceId가 남는다.

연결 문서

가져갈 판단 기준

OAuth 계정 연결은 “이메일이 같다”가 아니라 “검증된 provider identity를 어떤 내부 subject에 어떤 근거로 묶는가”의 문제다.

Review 3. Gateway, WAF, Spring Security 책임 경계 리뷰

제안된 설계안

운영 환경에는 WAF와 API Gateway가 있으므로 애플리케이션에서는 일부 권한 검사와 input validation을 줄이기로 했다.

Gateway가 X-User-Id, X-Admin-Role header를 붙이고, 애플리케이션은 이 값을 읽어 관리자 기능을 허용한다. production CORS는 Gateway에서 처리하고, Spring Security에서는 /api/**를 넓게 permit한다.

겉보기 장점

  • 공통 보안 처리를 앞단에 모을 수 있다.
  • 애플리케이션 코드가 단순해진다.
  • WAF rule로 알려진 공격 패턴을 빠르게 차단할 수 있다.
  • Gateway에서 CORS와 rate limit를 일괄 운영할 수 있다.

숨은 리스크

  • WAF는 business authorization을 알지 못한다.
  • 외부 요청 header가 내부 검증 header처럼 사용될 수 있다.
  • Gateway route와 Spring matcher가 불일치하면 보호되지 않는 경로가 생긴다.
  • CORS 허용은 인가가 아니다.
  • rollback manifest가 오래된 insecure 설정을 되살릴 수 있다.
  • secret, TLS, HSTS, log retention 같은 운영 경계가 애플리케이션 밖에서 깨질 수 있다.

리뷰 질문

  • 애플리케이션 포트에 Gateway를 우회해 직접 접근할 수 없는가?
  • 외부에서 온 X-User-Id, X-Admin-Role header를 제거하는가?
  • Gateway가 만든 header임을 mTLS, signature, network policy 등으로 검증하는가?
  • Spring Security matcher와 Gateway route가 같은 보호 정책을 갖는가?
  • WAF가 모르는 resource ownership은 어디서 판단하는가?
  • production CORS, TLS, secret, rollback 설정을 실제 응답으로 검증하는가?

빠진 보안 계약

  • 신뢰 가능한 proxy 목록
  • forwarded header 처리 기준
  • 내부 인증 header 생성과 검증 방식
  • Gateway route와 application matcher mapping
  • WAF 우회 시 애플리케이션 방어선
  • production CORS allowlist
  • secret runtime 주입과 rotation
  • TLS 인증서 갱신과 HSTS 적용 범위

개선된 설계 방향

Gateway와 WAF는 공통 차단과 edge 통제를 담당하되, 애플리케이션은 인증 결과 검증, resource ownership, 업무 권한, DTO 검증, audit log를 유지해야 한다.

외부가 보낸 내부 보안 header는 Gateway에서 제거하고, 애플리케이션은 신뢰할 수 있는 proxy와 서명된 header만 받아들인다. Spring Security matcher는 최소 허용으로 구성하고, production 응답에서 CORS, cookie, HSTS, CSP를 직접 확인한다.

승인 조건

  • 외부 요청에 X-Admin-Role을 직접 넣어도 관리자 권한이 생기지 않는다.
  • Gateway 우회 요청이 network 또는 application layer에서 거부된다.
  • /api/**.permitAll() 같은 넓은 matcher가 없다.
  • resource ownership은 Service나 Repository에서 검증된다.
  • production CORS, HSTS, Secure cookie, secret masking, rollback 설정 검증 결과가 있다.

연결 문서

가져갈 판단 기준

Gateway와 WAF는 애플리케이션 보안을 대신하지 않고, 애플리케이션이 믿어도 되는 입력의 조건을 더 엄격하게 만들어야 한다.

최종 종합 훈련

종합 상황

팀 초대 기능 배포 후 네 가지 신호가 동시에 들어왔다.

  • 로그인 실패가 증가했다.
  • 일부 사용자가 다른 팀의 project 설정을 볼 수 있다고 신고했다.
  • 브라우저 콘솔에는 CORS 오류가 보인다.
  • 보안 로그에는 같은 계정의 refresh token reuse와 반복 초대 생성 실패가 남아 있다.

제공된 Evidence

release: team-invite-v3
changed:
  - POST /teams/{teamId}/invites
  - PATCH /teams/{teamId}/members/{memberId}/role
  - GET /api/projects/{projectId}/settings
  - CORS allowlist moved to gateway
level=WARN event=login_failed identifier_hash=91ad ip=203.0.113.10 reason=bad_credentials trace_id=f1
level=INFO event=login_succeeded account_id=1004 prior_failures=8 mfa_required=true trace_id=f2
level=WARN event=team_invite_denied actor_id=1004 team_id=42 requested_role=OWNER reason=owner_role_not_invitable trace_id=f3
level=INFO event=request_completed path=/api/projects/{projectId}/settings status=200 account_id=1004 resource=project:9001 trace_id=f4
level=WARN event=refresh_token_reuse_detected account_id=1004 family_id=fam_8a action=family_revoked trace_id=f5
level=WARN event=cors_policy_changed origin_mode=reflect credentials=true deploy=team-invite-v3 trace_id=f6

작성할 답안

  • 5분 triage 순서
  • 원인 후보 분류
  • 확인할 요청, 응답, header, log
  • containment 절차
  • 수정 방향
  • 완료 검증
  • 회고 action
  • 연결 문서

먼저 써보기

해설 기준

좋은 답안은 네 신호를 하나의 원인으로 성급히 묶지 않는다.

먼저 incident id와 release id를 기준으로 로그를 고정한다. 로그인 실패와 refresh token reuse는 계정 탈취 가능성을 뜻한다. 권한 신고는 resource ownership 누락을 의심해야 한다. CORS 오류와 origin_mode=reflect credentials=true는 브라우저 정책 완화가 새 위험을 만들었을 수 있다.

containment는 좁게 시작한다. token family 폐기, 영향 계정 step-up, team invite role 제한, project settings endpoint 임시 민감 필드 축소, CORS allowlist rollback을 검토한다. 이후 Service owner check, Repository 조건, CSRF/CORS 설정, audit event를 수정하고 실패 테스트를 추가한다.

완료 검증은 “배포됨”이 아니다. 비소유자 project 접근 실패, OWNER 초대 실패와 로그, refresh reuse 차단, 허용되지 않은 Origin의 credential 요청 실패, 관련 사용자 영향 범위 보고가 모두 필요하다.

연결 문서

가져갈 판단 기준

복합 보안 사고는 가장 시끄러운 증상부터 고치는 일이 아니라, 계정, token, resource, Origin, 로그를 같은 timeline에 놓고 피해를 제한하는 일이다.

반복 기록표

날짜훈련처음 판단놓친 관점다시 볼 문서다음 액션
Lab 1
Lab 2
Lab 3
Lab 4
Incident 1
Incident 2
Incident 3
Incident 4
Review 1
Review 2
Review 3
Final