From cc4466cab6c8131c1efd6ac742eacafe10cfe965 Mon Sep 17 00:00:00 2001
From: sevineleven <117634128+sevineleven@users.noreply.github.com>
Date: Mon, 6 Jul 2026 01:55:56 +0900
Subject: [PATCH] =?UTF-8?q?fix:=20admin=20=EC=84=B8=EC=85=98=20=EC=97=B0?=
 =?UTF-8?q?=EC=9E=A5=20CSRF=20=EC=B0=A8=EB=8B=A8=20=ED=95=B4=EC=A0=9C=20+?=
 =?UTF-8?q?=20TTL=201=EC=8B=9C=EA=B0=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

연장 버튼(POST /admin/session/extend)이 CSRF 에 막혀 403 나고, 프론트가 그
실패 응답(remainingSeconds 없음)을 '만료됨'으로 오표시했다. GET /ttl 은 safe
method 라 통과해 남은시간은 보이는데 연장만 조용히 실패하던 문제.

- /admin/session/** 을 CSRF 제외에 추가. 폼이 아니라 fetch(JSON) API 라
  admin-access 처럼 토큰을 실을 수 없다(자기 세션 연장이라 CSRF 위험도 없음).
- allowlistTtl 24h → 1h. 무활동 만료를 짧게, 연장 버튼으로 갱신.
- 연장 fetch 가 비 2xx·네트워크 실패 시 기존 카운트다운 유지(만료 오표시 방지).
---
 .../com/depromeet/piki/admin/config/AdminProperties.kt |  2 +-
 .../depromeet/piki/admin/config/AdminSecurityConfig.kt | 10 +++++++++-
 src/main/resources/application.yml                     |  2 +-
 src/main/resources/templates/admin/fragments.html      |  5 ++++-
 4 files changed, 15 insertions(+), 4 deletions(-)

diff --git a/src/main/kotlin/com/depromeet/piki/admin/config/AdminProperties.kt b/src/main/kotlin/com/depromeet/piki/admin/config/AdminProperties.kt
index ed693030..1ec21fc2 100644
--- a/src/main/kotlin/com/depromeet/piki/admin/config/AdminProperties.kt
+++ b/src/main/kotlin/com/depromeet/piki/admin/config/AdminProperties.kt
@@ -33,7 +33,7 @@ data class AdminProperties(
     val environment: String = "",
     val grantSigningKey: String = "",
     val grantHosts: Map<String, String> = emptyMap(),
-    val allowlistTtl: Duration = Duration.ofHours(24),
+    val allowlistTtl: Duration = Duration.ofHours(1),
     val grantTokenTtl: Duration = Duration.ofMinutes(3),
     val localBypass: Boolean = false,
     val scheduleGraceWindow: Duration = Duration.ofHours(1),
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 39afe64d..21acd7a1 100644
--- a/src/main/kotlin/com/depromeet/piki/admin/config/AdminSecurityConfig.kt
+++ b/src/main/kotlin/com/depromeet/piki/admin/config/AdminSecurityConfig.kt
@@ -30,7 +30,15 @@ class AdminSecurityConfig {
             // 결정적으로 이 체인에 묶인다(String 다중 패턴 조합에서 GET /admin-access/grant 가 메인 체인으로 새 401 나던 문제 차단).
             .securityMatcher(adminPathMatcher())
             .authorizeHttpRequests { it.anyRequest().permitAll() }
-            .csrf { it.ignoringRequestMatchers(ADMIN_PATHS.matcher("/admin-access/**")) }
+            .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/**"),
+                )
+            }
             .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) }
             .build()
 
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 0a8b2603..92e5a960 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -28,7 +28,7 @@ admin:
     staging: https://staging.api.piki.day
     prod: https://api.piki.day
   # 허용 IP sliding TTL(무활동 만료, IP 변동 흡수). grant-token 은 원타임 링크 수명.
-  allowlist-ttl: ${ADMIN_ALLOWLIST_TTL:24h}
+  allowlist-ttl: ${ADMIN_ALLOWLIST_TTL:1h}
   grant-token-ttl: 3m
   # 로컬에서만 /admin 게이트(AdminAccessFilter) 우회. 배포 환경은 항상 false.
   local-bypass: ${ADMIN_LOCAL_BYPASS:false}
diff --git a/src/main/resources/templates/admin/fragments.html b/src/main/resources/templates/admin/fragments.html
index 6545a3ca..4766f333 100644
--- a/src/main/resources/templates/admin/fragments.html
+++ b/src/main/resources/templates/admin/fragments.html
@@ -89,8 +89,11 @@
       }
       btn.addEventListener('click', function () {
         btn.disabled = true;
-        fetch('/admin/session/extend', { method: 'POST' }).then(function (r) { return r.json(); })
+        // 실패(비 2xx·네트워크)면 기존 카운트다운을 유지한다 — 응답 실패를 "만료됨"으로 오표시하지 않는다.
+        fetch('/admin/session/extend', { method: 'POST' })
+          .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
           .then(function (d) { start(d.remainingSeconds); })
+          .catch(function () {})
           .finally(function () { btn.disabled = false; });
       });
       load();
