From 4645da2c63bf204ea563328cf7e4f17a52de1555 Mon Sep 17 00:00:00 2001 From: z3rotig4r Date: Sun, 5 Jul 2026 14:07:04 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20EgovReloadableFilterInvocationSecurityMe?= =?UTF-8?q?tadataSource.reload()=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0(=EC=9B=90=EC=9E=90=EC=A0=81=20=EB=A7=B5=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reload()가 기존 requestMap을 clear() 후 재적재하는데, 이 맵은 보안 필터 요청 처리 핫패스인 getAttributes()·getAllConfigAttributes()에서 순회된다. 관리 스레드가 재적재하는 동안 요청 스레드가 같은 맵을 순회하면 ConcurrentModificationException이 발생하거나, 재적재가 끝나기 전의 빈/부분 맵을 관측해 권한이 일시적으로 비어 인가 판단이 어긋날 수 있다. 새 LinkedHashMap을 완성한 뒤 requestMap 참조를 한 번에 교체하도록 변경하고, 필드를 volatile로 선언해 완성된 맵의 가시성과 원자적 교체를 보장한다. 순회 스레드는 항상 완전한 맵만 관측한다. 동일 패턴은 AuthorityResourceMetadata.reload()(#242)에서 개선된 바 있다. 테스트: 읽기 순회와 reload 2000회 반복 동시 실행 시 예외 0, 관측 권한 수 최솟값이 항상 20. --- ...ilterInvocationSecurityMetadataSource.java | 11 +- ...ationSecurityMetadataSourceReloadTest.java | 112 ++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 Foundation/org.egovframe.rte.fdl.security/src/test/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSourceReloadTest.java diff --git a/Foundation/org.egovframe.rte.fdl.security/src/main/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSource.java b/Foundation/org.egovframe.rte.fdl.security/src/main/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSource.java index b340a66f..df566ad2 100644 --- a/Foundation/org.egovframe.rte.fdl.security/src/main/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSource.java +++ b/Foundation/org.egovframe.rte.fdl.security/src/main/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSource.java @@ -30,7 +30,7 @@ public class EgovReloadableFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { private static final Logger LOGGER = LoggerFactory.getLogger(EgovReloadableFilterInvocationSecurityMetadataSource.class); - private final Map> requestMap; + private volatile Map> requestMap; private EgovSecuredObjectService securedObjectService; public EgovReloadableFilterInvocationSecurityMetadataSource(LinkedHashMap> requestMap) { @@ -69,12 +69,15 @@ public boolean supports(Class clazz) { public void reload() { LinkedHashMap> reloadedMap = securedObjectService.getRolesAndUrl(); Iterator>> iterator = reloadedMap.entrySet().iterator(); - // 이전 데이터 삭제 - requestMap.clear(); + // 새 맵을 완성한 뒤 참조를 원자적으로 교체한다. + // 기존 requestMap을 clear() 후 다시 채우면, 재적재 중인 빈/부분 맵을 요청 스레드가 + // 순회하여 ConcurrentModificationException 또는 일시적 권한 소실(인가 오판)이 발생할 수 있다. + Map> reloaded = new LinkedHashMap<>(); while (iterator.hasNext()) { Entry> entry = iterator.next(); - requestMap.put(entry.getKey(), entry.getValue()); + reloaded.put(entry.getKey(), entry.getValue()); } + this.requestMap = reloaded; LOGGER.debug("Secured Url Resources - Role Mappings reloaded at Runtime!"); } diff --git a/Foundation/org.egovframe.rte.fdl.security/src/test/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSourceReloadTest.java b/Foundation/org.egovframe.rte.fdl.security/src/test/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSourceReloadTest.java new file mode 100644 index 00000000..203dd623 --- /dev/null +++ b/Foundation/org.egovframe.rte.fdl.security/src/test/java/org/egovframe/rte/fdl/security/bean/EgovReloadableFilterInvocationSecurityMetadataSourceReloadTest.java @@ -0,0 +1,112 @@ +package org.egovframe.rte.fdl.security.bean; + +import org.egovframe.rte.fdl.security.secureobject.EgovSecuredObjectService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.ConfigAttribute; +import org.springframework.security.access.SecurityConfig; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@link EgovReloadableFilterInvocationSecurityMetadataSource#reload()}의 동시성 안전성을 검증한다. + * + *

reload()가 기존 requestMap을 clear() 후 다시 채우면, 요청 스레드가 재적재 중인 맵을 + * 순회하여 ConcurrentModificationException 또는 일시적 권한 소실을 겪을 수 있다. 수정본은 + * 새 맵을 완성한 뒤 참조를 원자적으로 교체하므로, 순회 스레드는 항상 완전한 맵만 관측한다.

+ */ +class EgovReloadableFilterInvocationSecurityMetadataSourceReloadTest { + + /** 요청마다 새 매처·권한을 만들어 반환하는 페이크 서비스(reload가 매번 다른 맵을 받도록). */ + private static class FakeSecuredObjectService implements EgovSecuredObjectService { + @Override + public LinkedHashMap> getRolesAndUrl() { + LinkedHashMap> map = new LinkedHashMap<>(); + for (int i = 0; i < 20; i++) { + List roles = new ArrayList<>(); + roles.add(new SecurityConfig("ROLE_" + i)); + // 매 엔트리마다 서로 다른 RequestMatcher 인스턴스(맵 키 identity 확보). + // 비캡처 람다는 JVM이 싱글턴으로 재사용하므로 i를 캡처해 인스턴스를 분리한다. + final int idx = i; + RequestMatcher matcher = request -> idx < 0; + map.put(matcher, roles); + } + return map; + } + + @Override + public List getMatchedRequestMapping(String url) { + return new ArrayList<>(); + } + + @Override + public String getHierarchicalRoles() { + return ""; + } + } + + @Test + @DisplayName("reload()와 getAllConfigAttributes() 동시 실행 시 예외 없이 완전한 권한 맵만 관측한다") + void reloadIsConcurrencySafe() throws Exception { + EgovReloadableFilterInvocationSecurityMetadataSource source = + new EgovReloadableFilterInvocationSecurityMetadataSource(new LinkedHashMap<>()); + source.setSecuredObjectService(new FakeSecuredObjectService()); + source.reload(); // 초기 적재 + + final int iterations = 2000; + final AtomicBoolean stop = new AtomicBoolean(false); + final List errors = new CopyOnWriteArrayList<>(); + final CountDownLatch done = new CountDownLatch(2); + // 읽기 스레드가 관측한 최소 권한 수. 원자 교체가 아니면 재적재 중 빈/부분 맵(<20)을 볼 수 있다. + final java.util.concurrent.atomic.AtomicInteger minObserved = + new java.util.concurrent.atomic.AtomicInteger(Integer.MAX_VALUE); + + // 읽기 스레드: requestMap.entrySet()를 반복 순회(보안 필터 핫패스와 동일 경로) + Thread reader = new Thread(() -> { + try { + while (!stop.get()) { + int size = source.getAllConfigAttributes().size(); + minObserved.accumulateAndGet(size, Math::min); + } + } catch (Throwable t) { + errors.add(t); + } finally { + done.countDown(); + } + }); + + // 재적재 스레드: reload() 반복 + Thread reloader = new Thread(() -> { + try { + for (int i = 0; i < iterations; i++) { + source.reload(); + } + } catch (Throwable t) { + errors.add(t); + } finally { + stop.set(true); + done.countDown(); + } + }); + + reader.start(); + reloader.start(); + done.await(); + + assertTrue(errors.isEmpty(), "동시 reload/순회 중 예외 발생: " + errors); + // 동시 재적재 내내 읽기 스레드는 항상 완전한 맵(20개)만 관측해야 한다(일시적 권한 소실 없음). + // 기존 clear()+put() 방식이면 부분 맵(<20)이 관측될 수 있어 이 단정이 깨진다. + assertEquals(20, minObserved.get(), + "재적재 중 읽기 스레드가 불완전한 권한 맵을 관측했다: min=" + minObserved.get()); + assertEquals(20, source.getAllConfigAttributes().size()); + } +}