이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- SecurityFilterChain은 요청 처리 흐름에서 정확히 어떤 경계를 맡는가?
- URL matcher, filter 순서, 예외 endpoint가 왜 전체 보안 수준을 결정하는가?
- FilterChain이 해결하지 못해 Controller/Service/Repository가 다시 확인해야 하는 것은 무엇인가?
개요
Spring Security의 Servlet 지원은 Filter 기반으로 동작한다. SecurityFilterChain은 요청이 DispatcherServlet과 Controller에 도달하기 전에 인증, CSRF, CORS, header, exception handling, URL authorization 같은 공통 경계를 적용한다.
이 계층의 장점은 모든 요청에 일관된 공통 정책을 적용할 수 있다는 점이다. 반대로 matcher 순서가 틀리거나 예외 endpoint가 넓으면 Controller 코드가 아무리 좋아도 요청이 보호 경계를 우회할 수 있다.
SecurityFilterChain은 강력하지만 도메인 소유권을 자동으로 알지 못한다. /api/orders/991이 현재 사용자 소유 주문인지 확인하려면 Service/Repository가 서버 데이터로 다시 판단해야 한다.
이 문서의 핵심은 FilterChain을 “첫 번째 방어선”으로 보고, 그 뒤에 남는 애플리케이션 책임을 명확히 하는 것이다.
공격 시나리오
- 공격자는 permitAll matcher가 넓게 잡힌 endpoint를 찾는다.
/api/**보다 앞에 있는/api/public/**matcher가 의도보다 많은 endpoint를 포함하게 만든다.- actuator, internal, batch, webhook endpoint가 별도 chain에서 보호 없이 열려 있는지 확인한다.
- 인증은 통과한 뒤 URL의 resource id를 바꿔 IDOR를 시도한다.
- CSRF가 꺼진 cookie 기반 상태 변경 endpoint에 브라우저 form 요청을 보낸다.
- reverse proxy path rewrite 때문에 실제 보호 path와 Spring matcher가 어긋나는지 노린다.
취약한 요청/응답 예시
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").permitAll()
.anyRequest().authenticated())
.csrf(csrf -> csrf.disable())
.build();
}GET /api/users/2002/orders/991 HTTP/1.1
Host: api.example.com
Authorization: Bearer member-1004-token공통 matcher가 너무 넓고 CSRF가 일괄 비활성화되어 있다. 인증 여부와 별개로 URL의 객체 소유권은 FilterChain에서 자동으로 검증되지 않는다.
주제별 핵심 판단
- SecurityFilterChain은 요청 단위 공통 경계를 담당한다.
- matcher는 좁은 규칙에서 넓은 규칙 순서로 검토한다.
permitAllendpoint는 inventory와 테스트가 필요하다.- CSRF는 cookie 기반 인증의 상태 변경 요청에서 중요하다.
- CORS는 브라우저 읽기 권한 정책이지 서버 인가 정책이 아니다.
- 보안 header는 브라우저 방어를 돕지만 XSS 출력 인코딩을 대체하지 않는다.
- FilterChain 뒤의 Service와 Repository가 resource ownership을 확인해야 한다.
개선된 요청/응답 예시
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/webhooks/**"))
.cors(Customizer.withDefaults())
.build();
}public Order getOwnedOrder(MemberId memberId, long orderId) {
return orderRepository.findByIdAndMemberId(orderId, memberId)
.orElseThrow(OrderNotFoundException::new);
}FilterChain은 인증과 URL 수준 접근을 관리하고, Service/Repository는 resource ownership을 확인한다.
방어 설계
- 모든 endpoint를 public, authenticated, role-restricted, internal, webhook으로 분류한다.
- 여러 SecurityFilterChain을 쓴다면 각 chain의
securityMatcher와 순서를 테스트한다. anyRequest().denyAll()또는 명시적 기본 거부를 설계 원칙으로 둔다.- cookie 인증을 쓰는 상태 변경 endpoint는 CSRF 정책을 분리해서 검토한다.
- CORS allowlist는 환경별로 명시하고 credential 허용 여부를 확인한다.
- reverse proxy 뒤에서는 forwarded header 처리와 scheme 인식을 점검한다.
- actuator와 management port는 별도 네트워크와 인증 정책을 둔다.
- URL authorization으로 끝내지 말고 method/repository 권한 테스트를 연결한다.
Spring/HTTP 예시
@Bean
SecurityFilterChain managementSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().hasRole("OPS"))
.build();
}@Test
void nonOwnerStillCannotReadOrderAfterAuthentication() throws Exception {
mockMvc.perform(get("/api/orders/{id}", otherMembersOrderId)
.with(jwt().jwt(jwt -> jwt.subject("member-1004"))))
.andExpect(status().isNotFound());
}FilterChain 테스트와 ownership 테스트를 모두 둬야 책임 경계가 보인다.
운영 로그와 감사 지점
level=WARN event=filter_chain_access_denied result=blocked path=/api/admin/users matcher=/api/admin/** reason=missing_role principal=member:1004 traceId=2fa091cclevel=INFO event=security_chain_inventory_changed result=allowed chain=apiSecurity addedMatcher=/api/webhooks/** csrf=ignored reviewer=security:kim traceId=2fa091cc- matcher 변경은 배포 변경과 함께 추적한다.
permitAll추가는 보안 리뷰 이벤트로 남긴다.- CSRF ignore 추가는 이유와 endpoint owner를 남긴다.
- access denied 로그는 path, matcher, reason을 분리한다.
- proxy route 변경과 Spring matcher 변경을 같은 release에서 비교한다.
실전 판단 기준
/api/**.permitAll()은 거의 항상 위험 신호다.- CORS 허용은 인가가 아니다.
- CSRF를 “JWT라서 무조건 꺼도 됨”으로 처리하면 cookie 저장 방식에서 깨진다.
- FilterChain이 통과했다고 resource 접근이 허용된 것은 아니다.
- actuator와 webhook은 예외 endpoint라 더 명확한 정책이 필요하다.
- reverse proxy path rewrite는 보안 matcher를 무력화할 수 있다.
테스트 포인트
- public endpoint inventory와 실제 matcher가 일치하는지 확인한다.
- 인증 없는 요청이 보호 endpoint에서 401인지 확인한다.
- 권한 없는 role이 admin endpoint에서 403인지 확인한다.
- 인증된 비소유자가 resource를 읽지 못하는지 확인한다.
- CSRF token 없는 cookie 기반 상태 변경 요청이 거부되는지 확인한다.
- 신뢰하지 않는 Origin이 CORS 응답을 받지 못하는지 확인한다.
위험 신호!
- matcher가 넓은 순서로 먼저 선언되어 있다.
- 모든
/api/**가 permitAll이다. - CSRF가 전체 disable인데 인증 cookie를 쓴다.
- actuator가 application API와 같은 정책으로 노출된다.
- FilterChain 테스트만 있고 Service ownership 테스트가 없다.
- proxy route와 Spring matcher가 따로 관리된다.
확인 질문
확인 질문
- SecurityFilterChain의 주된 책임은 무엇인가?
- 요청이 Controller에 도달하기 전 공통 인증, URL 권한, CSRF/CORS/header 같은 웹 보안 경계를 적용하는 것이다.
- FilterChain이 자동으로 알 수 없는 것은 무엇인가?
- 특정 resource가 현재 사용자 소유인지, 이 업무 상태 전이가 허용되는지 같은 도메인 권한이다.
- matcher 변경 시 반드시 남겨야 하는 증거는 무엇인가?
- public endpoint inventory, 테스트, reviewer, 변경 이유, 실패 로그 event다.