From f6cd1e3dacfd69ee541777633dca212072f721ff 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 16:26:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=B0=B1=EC=98=A4=ED=94=BC=EC=8A=A4?= =?UTF-8?q?=20=EB=B3=B4=EC=95=88=20=EA=B2=B0=ED=95=A8=205=EA=B1=B4=20-=20C?= =?UTF-8?q?SRF=20=EB=A9=B4=EC=A0=9C=C2=B7=EC=84=B8=EC=85=98=20=ED=94=BD?= =?UTF-8?q?=EC=84=B8=EC=9D=B4=EC=85=98=C2=B7=EA=B0=90=EC=82=AC=20IP=20?= =?UTF-8?q?=EC=9C=84=EC=A1=B0=C2=B7=EC=A3=BD=EC=9D=80=20permitAll=C2=B7?= =?UTF-8?q?=EB=AF=B8=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session/extend 의 CSRF 면제를 걷었다. "fetch 라 토큰을 실을 수 없다"는 면제 근거가 틀렸다 - 같은 화면의 다른 fetch 는 이미 헤더로 싣고 있었다. 면제 상태에선 제3자 페이지가 cross-origin 폼 자동 제출로 allowlist TTL 을 무기한 늘려, 연장은 명시적 버튼으로만 한다는 #669 의 보장이 무력화된다. 로컬 실측으로 토큰 없는 POST 가 401 로 막히고 토큰을 실으면 200 인 것을 확인했다 - grant 흐름이 기존 세션에 admin 신원을 심어 세션 픽세이션 창이 열려 있었다. establish 전에 invalidate 후 새 세션을 발급한다. permitAll 체인이라 Spring Security 의 픽세이션 보호가 돌지 않아 IP 바인딩 한 겹에만 기대고 있었다 - 감사 로그 IP 를 XFF 첫 hop 에서 읽던 마지막 한 곳(AdminTemplateController)을 ClientIp.of 로 옮겼다. nginx 가 XFF 를 append 하므로 클라 위조분이 첫 hop 이라 감사 추적이 공격자 통제 아래 있었다 - /admin-assets/** 는 서빙하는 것이 없는데 보안 체인에 permitAll 로 뚫려 있고 게이트 필터도 제외하고 있었다. 정적 핸들러를 붙이는 순간 무인증 공개가 되도록 미리 파인 구멍이라 matcher 를 지웠다 - 변수 칩만 th:utext 로 마크업을 문자열 결합해 조립하고 있었다. 이스케이프되는 th:text 두 개로 나눴다 - 출력은 같고 unescaped sink 만 사라진다. 변수 카탈로그가 DB 로 옮겨가면 그대로 저장형 XSS 경로가 된다 - fragments 의 CSRF 값은 safe navigation 으로 읽는다. 직접 접근하면 CsrfFilter 가 없는 구성에서 상단바 렌더가 깨져 admin 페이지 전체가 500 이 된다(테스트에서 실제로 터졌다) --- .../piki/admin/access/AdminAccessFilter.kt | 4 ++-- .../admin/access/AdminSessionController.kt | 14 +++++++++++--- .../admin/access/DiscordAccessController.kt | 6 ++++++ .../piki/admin/config/AdminSecurityConfig.kt | 18 +++++++++--------- .../admin/template/AdminTemplateController.kt | 5 +++-- .../resources/templates/admin/fragments.html | 18 ++++++++++++++---- .../templates/admin/template-edit.html | 9 ++++++--- .../piki/admin/access/AdminAccessFilterTest.kt | 5 ++--- 8 files changed, 53 insertions(+), 26 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 8c952b8e8..38a4df381 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/AdminAccessFilter.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/AdminAccessFilter.kt @@ -16,7 +16,7 @@ import org.springframework.web.util.UrlPathHelper // prod·전 환경의 /admin 게이트 — 슬랙으로 검증된 세션 + allowlist IP + 세션-IP 바인딩 셋 다 맞아야 통과, 아니면 404. // password 가 아니라 "슬랙 링크 클릭으로 발급된 세션"이 신원이다. 미허용은 401/302 가 아니라 404(존재 숨김). -// 공개 진입(/admin-access/**)·정적(/admin-assets/**)은 경로가 달라 이 필터 대상이 아니다(shouldNotFilter). +// 공개 진입(/admin-access/**)은 경로가 달라 이 필터 대상이 아니다(shouldNotFilter). // // order: SessionRepositoryFilter 바로 안쪽이어야 한다(#891). 세션이 Redis 로 옮겨간 뒤(#885/#888) // getSession 은 그 필터가 씌우는 요청 래퍼를 통해서만 저장소에 닿는다 — 바깥에서 부르면 래퍼가 없는 원본 요청이라 @@ -81,7 +81,7 @@ class AdminAccessFilter( fun isGatedRequest(request: HttpServletRequest): Boolean = isGatedPath(PATH_HELPER.getPathWithinApplication(request)) - // /admin 과 /admin/** 만 게이트. /admin-assets·/admin-access 는 다른 prefix 라 제외(공개 진입·정적). + // /admin 과 /admin/** 만 게이트. /admin-access 는 다른 prefix 라 제외(공개 진입). // 세그먼트 경계로 매칭해 /admin-access 류 과매칭을 막는다. fun isGatedPath(uri: String): Boolean = uri == "/admin" || uri.startsWith("/admin/") } diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/AdminSessionController.kt b/src/main/kotlin/com/depromeet/piki/admin/access/AdminSessionController.kt index a6b3e8ebc..1258396cf 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/AdminSessionController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/AdminSessionController.kt @@ -2,6 +2,7 @@ package com.depromeet.piki.admin.access import com.depromeet.piki.admin.config.ClientIp import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled +import com.depromeet.piki.common.response.ApiResponseBody import io.swagger.v3.oas.annotations.Hidden import jakarta.servlet.http.HttpServletRequest import org.springframework.web.bind.annotation.GetMapping @@ -19,16 +20,23 @@ class AdminSessionController( private val allowlistService: AdminAllowlistService, ) { // 남은 세션 시간(초). 카운트다운 UI 가 폴링한다. 만료·미등록이면 0. + // 내부 SSR 엔드포인트도 공통 응답 래퍼로 통일한다(AdminTemplateController.preview·resultJson 과 같은 결). @GetMapping("/ttl") - fun ttl(request: HttpServletRequest): Map = mapOf("remainingSeconds" to remainingSeconds(ClientIp.of(request))) + fun ttl(request: HttpServletRequest): ApiResponseBody = + ApiResponseBody.ok(AdminSessionTtl(remainingSeconds(ClientIp.of(request)))) // 명시적 연장 — allowlist TTL 을 다시 채운다. 연장 버튼 클릭으로만 호출된다(배경 요청으로는 갱신되지 않음). @PostMapping("/extend") - fun extend(request: HttpServletRequest): Map { + fun extend(request: HttpServletRequest): ApiResponseBody { val ip = ClientIp.of(request) allowlistService.refresh(ip) - return mapOf("remainingSeconds" to remainingSeconds(ip)) + return ApiResponseBody.ok(AdminSessionTtl(remainingSeconds(ip))) } private fun remainingSeconds(ip: String): Long = allowlistService.ttl(ip)?.seconds ?: 0L } + +// 상단바 카운트다운이 읽는 유일한 값. Map 대신 타입을 두어 응답 모양이 컴파일로 추적된다. +data class AdminSessionTtl( + val remainingSeconds: Long, +) diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt index 6cafaaaef..014fc6317 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt @@ -132,7 +132,13 @@ class DiscordAccessController( val ip = ClientIp.of(request) allowlistService.grant(ip, identity.name) // ADMIN 은 백오피스라 세션(신원)을 발급한다. DOCS/SPEC(#733)는 문서 노출용이라 IP 등록만 하고 세션은 안 준다. + // + // 신원을 심기 전에 기존 세션을 버리고 새 id 를 발급한다(#988) — 세션 픽세이션 방어. 그대로 getSession(true) + // 를 부르면 공격자가 미리 심어둔 JSESSIONID 에 admin 신원이 얹혀, 공격자가 이미 아는 id 가 admin 세션이 된다. + // Spring Security 의 세션 픽세이션 보호는 인증 이벤트에서 도는데 이 체인은 permitAll(인증 없음)이라 안 돈다. + // 지금은 아래 IP 바인딩이 피해를 막고 있으나, 같은 egress NAT 를 쓰는 공격자에겐 그 한 겹이 통하지 않는다. if (identity.dest.issueSession) { + request.getSession(false)?.invalidate() AdminSession.establish(request.getSession(true), identity.name, ip) } auditService.record( diff --git a/src/main/kotlin/com/depromeet/piki/admin/config/AdminSecurityConfig.kt b/src/main/kotlin/com/depromeet/piki/admin/config/AdminSecurityConfig.kt index 21acd7a1b..612c2f167 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/config/AdminSecurityConfig.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/config/AdminSecurityConfig.kt @@ -10,7 +10,7 @@ import org.springframework.security.web.servlet.util.matcher.PathPatternRequestM import org.springframework.security.web.util.matcher.OrRequestMatcher /** - * 백오피스 경로(/admin·/admin-access·/admin-assets)를 메인 JWT(stateless) 체인(@Order(2))에서 떼어내는 체인. + * 백오피스 경로(/admin·/admin-access)를 메인 JWT(stateless) 체인(@Order(2))에서 떼어내는 체인. * * 접근 제어는 이 체인이 아니라 그 앞단 서블릿 필터(EnvironmentAccessFilter·AdminAccessFilter, #526)가 한다 — * 슬랙으로 검증된 세션 + IP allowlist. 따라서 이 체인은 admin 경로를 메인 체인에서 떼어 permitAll 로 통과시키고 @@ -31,13 +31,14 @@ class AdminSecurityConfig { .securityMatcher(adminPathMatcher()) .authorizeHttpRequests { it.anyRequest().permitAll() } .csrf { - // /admin-access/** 는 슬랙·디스코드 진입(서명검증으로 보호), /admin/session/** 는 폼이 아니라 - // fetch(JSON) API(세션 남은시간·연장)라 CSRF 토큰을 실을 수 없다 — 둘 다 CSRF 제외한다. - // 나머지 /admin/** 폼(Thymeleaf)은 CSRF 를 유지한다(_csrf 히든). - it.ignoringRequestMatchers( - ADMIN_PATHS.matcher("/admin-access/**"), - ADMIN_PATHS.matcher("/admin/session/**"), - ) + // /admin-access/** 만 CSRF 제외한다 — 슬랙·디스코드 진입이라 우리 페이지에서 토큰을 실을 수 없고, + // 대신 서명검증으로 보호된다. 나머지 /admin/** 은 폼(Thymeleaf _csrf 히든)이든 fetch 든 CSRF 를 유지한다. + // + // /admin/session/** 를 제외 목록에서 뺐다(#988) — "fetch 라 토큰을 실을 수 없다"는 전제가 틀렸다. + // 같은 admin 화면의 다른 fetch(템플릿 미리보기·공지 이미지 업로드)는 이미 헤더로 토큰을 싣고 있다. + // 제외해 두면 제3자 페이지가 cross-origin 폼 자동 제출로 extend 를 호출해 allowlist TTL 을 무기한 + // 늘릴 수 있어, 연장은 명시적 버튼으로만 한다는 #669 의 보장이 무력화된다(GET /ttl 은 안전 메서드라 무관). + it.ignoringRequestMatchers(ADMIN_PATHS.matcher("/admin-access/**")) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) } .build() @@ -48,7 +49,6 @@ class AdminSecurityConfig { OrRequestMatcher( ADMIN_PATHS.matcher("/admin"), ADMIN_PATHS.matcher("/admin/**"), - ADMIN_PATHS.matcher("/admin-assets/**"), ADMIN_PATHS.matcher("/admin-access/**"), ) diff --git a/src/main/kotlin/com/depromeet/piki/admin/template/AdminTemplateController.kt b/src/main/kotlin/com/depromeet/piki/admin/template/AdminTemplateController.kt index 99d11d94b..b0249c1ad 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/template/AdminTemplateController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/template/AdminTemplateController.kt @@ -2,6 +2,7 @@ package com.depromeet.piki.admin.template import io.swagger.v3.oas.annotations.Hidden import com.depromeet.piki.admin.access.AdminSession +import com.depromeet.piki.admin.config.ClientIp import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled import com.depromeet.piki.common.response.ApiResponseBody import com.depromeet.piki.notification.domain.NotificationType @@ -83,6 +84,6 @@ class AdminTemplateController( private fun actor(request: HttpServletRequest): String = AdminSession.actorName(request) - private fun clientIp(request: HttpServletRequest): String = - request.getHeader("X-Forwarded-For")?.split(",")?.firstOrNull()?.trim()?.ifBlank { null } ?: request.remoteAddr + // XFF 첫 hop 은 스푸핑 가능 — nginx 가 append 로 넣어 클라 위조분이 앞에 섞인다. 감사 IP 는 ClientIp 로 통일한다. + private fun clientIp(request: HttpServletRequest): String = ClientIp.of(request) } diff --git a/src/main/resources/templates/admin/fragments.html b/src/main/resources/templates/admin/fragments.html index 8dc08a3f9..5a9206ba5 100644 --- a/src/main/resources/templates/admin/fragments.html +++ b/src/main/resources/templates/admin/fragments.html @@ -62,8 +62,15 @@ - - - + +