From 45395b8f5b33af4a400fc01dda9ec24bb962df6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:00:46 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=B0=B1=EC=98=A4=ED=94=BC=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EA=B2=BD=EB=A1=9C=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20dispatcher=20=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EA=B2=8C=20=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shouldNotFilter 가 raw requestURI 로 게이트 적용 여부를 판정하는데 Spring 은 디코딩 경로로 라우팅해, /%61dmin/announcements 가 세션·세션-IP 바인딩·allowlist 검사를 통째로 건너뛴 채 백오피스에 도달했다 - 로컬에서 게이트를 켜고(ADMIN_LOCAL_BYPASS=false) 실측: 수정 전 이 경로가 200 과 함께 공지 발송 페이지 HTML 을 그대로 반환했고, 수정 후 404 로 막힌다. 정상 /admin 은 세션 없으면 그대로 404, grant 진입점(/admin-access/**)은 400 으로 게이트를 통과해 흐름이 유지되는 것도 함께 확인했다 - matrix param(/admin;x=1/...)과 중복 슬래시(//admin/...)는 게이트는 뚫려도 메인 Security 체인이 401 로 막고 있었다. 실제 노출은 percent-encoding 경로 하나였지만, 판정 층이 어긋나 있다는 사실은 같으므로 함께 막는다 - 형제 필터 EnvironmentAccessFilter 가 같은 우회 유형을 이미 UrlPathHelper 로 막고 있었다. 그 구조(isGatedRequest/isGatedPath 를 companion 으로 노출)를 그대로 따라 두 게이트의 모양을 맞추고 경로 판정을 단위 테스트로 고정했다 --- .../piki/admin/access/AdminAccessFilter.kt | 22 ++++++++--- .../admin/access/AdminAccessFilterTest.kt | 38 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/AdminAccessFilter.kt b/src/main/kotlin/com/depromeet/piki/admin/access/AdminAccessFilter.kt index d9b5b29a..8c952b8e 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/AdminAccessFilter.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/AdminAccessFilter.kt @@ -12,6 +12,7 @@ import org.springframework.core.annotation.Order import org.springframework.session.web.http.SessionRepositoryFilter import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter +import org.springframework.web.util.UrlPathHelper // prod·전 환경의 /admin 게이트 — 슬랙으로 검증된 세션 + allowlist IP + 세션-IP 바인딩 셋 다 맞아야 통과, 아니면 404. // password 가 아니라 "슬랙 링크 클릭으로 발급된 세션"이 신원이다. 미허용은 401/302 가 아니라 404(존재 숨김). @@ -62,15 +63,26 @@ class AdminAccessFilter( } } - // /admin 과 /admin/** 만 게이트. /admin-assets·/admin-access 는 다른 prefix 라 제외(공개 진입·정적). - override fun shouldNotFilter(request: HttpServletRequest): Boolean { - val uri = request.requestURI - return !(uri == "/admin" || uri.startsWith("/admin/")) - } + override fun shouldNotFilter(request: HttpServletRequest): Boolean = !isGatedRequest(request) // setStatus 로 막는다(sendError 금지) — sendError 는 /error 로 ERROR 디스패치를 일으켜 메인 체인이 401 로 // 가로채면 "존재 숨김(404)" 의도가 깨진다. 체인을 더 진행하지 않으니 빈 404 로 응답이 닫힌다. private fun deny(response: HttpServletResponse) { response.status = HttpServletResponse.SC_NOT_FOUND } + + companion object { + // 게이트 판정 경로를 Spring 라우팅과 동일하게 정규화한 뒤 매칭한다 — raw requestURI 로 판정하면 + // Spring 은 정규화 경로로 라우팅하는데 판정은 원문이라 불일치가 생겨, `/%61dmin/announcements`(percent-encoding)· + // `/admin;x=1`(matrix param) 처럼 필터는 안 걸고 dispatcher 는 서빙하는 우회가 뚫린다. 그 경로로 들어오면 + // 세션·세션-IP 바인딩·allowlist 검사가 통째로 생략된 채 백오피스에 닿는다(#986). + // EnvironmentAccessFilter 가 같은 이유로 쓰는 UrlPathHelper(removeSemicolonContent·urlDecode 기본 on)를 그대로 쓴다. + private val PATH_HELPER = UrlPathHelper.defaultInstance + + fun isGatedRequest(request: HttpServletRequest): Boolean = isGatedPath(PATH_HELPER.getPathWithinApplication(request)) + + // /admin 과 /admin/** 만 게이트. /admin-assets·/admin-access 는 다른 prefix 라 제외(공개 진입·정적). + // 세그먼트 경계로 매칭해 /admin-access 류 과매칭을 막는다. + fun isGatedPath(uri: String): Boolean = uri == "/admin" || uri.startsWith("/admin/") + } } diff --git a/src/test/kotlin/com/depromeet/piki/admin/access/AdminAccessFilterTest.kt b/src/test/kotlin/com/depromeet/piki/admin/access/AdminAccessFilterTest.kt index 9df30018..b1cdc339 100644 --- a/src/test/kotlin/com/depromeet/piki/admin/access/AdminAccessFilterTest.kt +++ b/src/test/kotlin/com/depromeet/piki/admin/access/AdminAccessFilterTest.kt @@ -3,7 +3,9 @@ package com.depromeet.piki.admin.access import org.junit.jupiter.api.Test import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties import org.springframework.core.annotation.OrderUtils +import org.springframework.mock.web.MockHttpServletRequest import org.springframework.session.web.http.SessionRepositoryFilter +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -40,4 +42,40 @@ class AdminAccessFilterTest { "미허용 요청이 404 가 아니라 메인 체인의 401 로 새어 존재 숨김이 깨진다.", ) } + + @Test + fun `admin 과 admin 하위 경로만 게이트한다`() { + assertTrue(AdminAccessFilter.isGatedPath("/admin")) + assertTrue(AdminAccessFilter.isGatedPath("/admin/announcements")) + assertTrue(AdminAccessFilter.isGatedPath("/admin/item-quota")) + } + + @Test + fun `공개 진입과 정적 prefix 는 게이트하지 않는다`() { + // /admin-access 는 grant 진입점이라 세션 이전에 닿아야 하고, /admin-assets 는 정적 prefix 다. + // startsWith("/admin") 로 판정하면 둘 다 과매칭돼 grant 흐름 자체가 막힌다. + assertFalse(AdminAccessFilter.isGatedPath("/admin-access/grant")) + assertFalse(AdminAccessFilter.isGatedPath("/admin-assets/app.css")) + assertFalse(AdminAccessFilter.isGatedPath("/api/v1/wishlists")) + } + + @Test + fun `percent-encoding·matrix parameter 로 위장한 admin 경로도 정규화 후 게이트한다`() { + // raw requestURI 로 판정하면 필터는 안 걸고 dispatcher 는 정규화 경로로 서빙해, 세션·IP 바인딩·allowlist + // 검사를 통째로 건너뛴 채 백오피스에 닿는 우회가 뚫린다(#986). dispatcher 와 같은 경로를 봐야 한다. + assertTrue(AdminAccessFilter.isGatedRequest(req("/%61dmin/announcements"))) // %61 = 'a' + assertTrue(AdminAccessFilter.isGatedRequest(req("/admin/%69tem-quota"))) // %69 = 'i' + assertTrue(AdminAccessFilter.isGatedRequest(req("/admin;x=1"))) + assertTrue(AdminAccessFilter.isGatedRequest(req("/admin;x=1/announcements"))) + } + + @Test + fun `정규화 후에도 공개 진입 prefix 는 통과한다`() { + // 위 우회 차단이 grant 진입까지 함께 막아버리면 백오피스에 아무도 못 들어간다. + assertTrue(AdminAccessFilter.isGatedRequest(req("/admin"))) + assertFalse(AdminAccessFilter.isGatedRequest(req("/admin-access/grant"))) + assertFalse(AdminAccessFilter.isGatedRequest(req("/%61dmin-access/grant"))) + } + + private fun req(uri: String) = MockHttpServletRequest().apply { requestURI = uri } }