diff --git a/CLAUDE.md b/CLAUDE.md index db27f21d..280e49ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -347,6 +347,27 @@ ApiResponse(responseCode = "400", description = "잘못된 요청 (URL 이 비 응답 detail 의 보안·노이즈, 디버깅 컨텍스트 보존 트레이드오프는 예외 message 정의(`## 도메인 예외 정책` 의 "메시지 톤: 응답 detail 은 전부 사용자 대면, 개발자 구분은 로그로")에서 이미 책임진다. example 은 그 message 를 그대로 끌어다 쓸 뿐이므로 별도 노출 위험을 만들지 않는다. +## 웹 요청 경계에서 반복해 틀리는 것 + +전부 실제로 이 repo 에서 한 번씩 났던 결함이다(#986·#988). 문법이 멀쩡하고 테스트도 초록불이라 **코드만 봐서는 티가 안 나는 종류**라 여기 못박는다. + +### 뚫리면 인증·권한이 무너진다 + +- **경로로 접근을 판정할 땐 raw `request.requestURI` 가 아니라 `UrlPathHelper` 정규화 경로를 쓴다.** dispatcher 는 디코딩 경로로 라우팅하므로 `/%61dmin/...` 이 필터만 건너뛰고 컨트롤러엔 닿는다. +- **상태를 바꾸는 요청은 fetch 여도 CSRF 를 면제하지 않는다.** "JSON API 라 토큰을 못 싣는다" 는 틀렸다 — 헤더로 실으면 된다. +- **신원이 올라가는 시점(로그인·grant)에 기존 세션을 버리고 새로 발급한다.** `getSession(true)` 만 부르면 공격자가 미리 심어둔 세션 id 에 권한이 얹힌다. +- **클라이언트 IP 는 `ClientIp.of` 로만 읽는다.** nginx 가 append 하는 `X-Forwarded-For` 의 첫 hop 은 요청자가 위조할 수 있어 감사 추적이 무의미해진다. +- **Thymeleaf 출력은 `th:text` 로 한다.** `th:utext` + 문자열 결합은 값의 출처가 코드에서 DB 로 바뀌는 순간 저장형 XSS 가 된다. +- **`permitAll` 매처·필터 예외는 실제로 서빙하는 대상이 있을 때만 둔다.** 빈 채로 두면 나중에 그 경로에 놓이는 것이 무인증 공개가 되고, 보안 설정에 이미 있어 의도한 것처럼 보인다. + +### 조용히 깨져서 아무도 모른다 + +- **SSR 컨트롤러(Thymeleaf 반환)는 계약 예외를 잡아 리다이렉트한다.** 안 잡으면 `@RestControllerAdvice` 가 화면을 raw JSON 으로 갈아치워 운영자가 페이지를 잃는다. +- **Thymeleaf 의 `param.x` 는 `String[]` 이다.** `${param.x == 'v'}` 는 항상 false — `${param.x != null and param.x[0] == 'v'}` 로 쓴다. 안내 배너가 뜬 적 없는 채로 수년 간다. +- **서블릿 필터의 `@Order` 는 유일값으로 둔다.** 값이 겹치면 순서가 비명세 규칙으로 갈려, 차단 필터가 로깅 필터 바깥으로 밀리면 차단 기록 자체가 안 남는다. +- **브라우저가 스스로 반복하는 요청엔 종료 조건과 상한을 둔다.** 대상이 사라져도 멈추지 않으면 탭 하나가 시간당 수천 건을 보낸다. +- **외부 CDN 자원은 버전을 고정하고 SRI 를 건다. 단 SRI 를 걸었으면 실제 로드를 눈으로 확인한다.** CORS 헤더를 안 주는 CDN 은 `crossorigin` 이 붙는 순간 리소스를 통째로 차단해, 검사 없이 두는 것보다 나쁜 결과가 된다. + ## PR 생성·갱신 **PR 생성·갱신은 항상 `/pr` 스킬로 한다.** 스킬을 쓸 수 없는 상황이면 수동 `gh` 로 우회하지 말고 사용자에게 먼저 묻는다. 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 8c952b8e..38a4df38 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 a6b3e8eb..1258396c 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 6cafaaae..014fc631 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/access/DocsCommandHandler.kt b/src/main/kotlin/com/depromeet/piki/admin/access/DocsCommandHandler.kt index cbb8458a..7cac5960 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/DocsCommandHandler.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/DocsCommandHandler.kt @@ -6,10 +6,14 @@ import org.springframework.stereotype.Component // `/docs` — dev API 레퍼런스 문서 접근 grant(#733). Link 버튼 2개([API 문서]·[api.json])를 발급한다. // 문서는 dev 만 노출하므로 env 는 dev 고정(prod 는 springdoc off 로 문서 자체가 404). 버튼을 사용자 브라우저에서 -// 열면 그 기기 IP 가 allowlist 에 등록되고(1h sliding), 이후 그 IP 에서 /docs·/v3/api-docs 둘 다 열린다(세션 없이 IP 만). +// 열면 그 기기 IP 가 allowlist 에 등록되고, 이후 그 IP 에서 /docs·/v3/api-docs 둘 다 열린다(세션 없이 IP 만). +// +// 등록 수명은 admin.allowlist-ttl(기본 24h) 이고 EnvironmentAccessFilter 가 접근마다 sliding 으로 밀어준다 — +// 주석·안내 문구가 오래 1시간이라 적어 실제보다 짧은 창을 알렸다(#988). 짧은 창을 원하면 문구가 아니라 +// DOCS/SPEC 전용 TTL 을 새로 두어야 한다(지금은 admin 세션과 같은 값을 공유한다). // // 두 버튼은 각각 grant 토큰(DOCS·SPEC)이라 목적지 페이지로 바로 리다이렉트한다. 하나만 눌러 IP 가 등록되면 나머지 -// 페이지는 3분(토큰 수명)과 무관하게 1시간 동안 주소창으로 접근 가능하다 — 버튼은 목적지 편의일 뿐이다. +// 페이지는 3분(토큰 수명)과 무관하게 그 TTL 동안 주소창으로 접근 가능하다 — 버튼은 목적지 편의일 뿐이다. @Component @ConditionalOnAdminEnabled class DocsCommandHandler( @@ -31,7 +35,7 @@ class DocsCommandHandler( return DiscordInteractions.embedWithLinkButtons( DiscordInteractions.COLOR_GREEN, "📄 dev API 문서 접근 — ${interaction.userName}", - "아래 버튼을 이 기기 브라우저에서 여세요 (3분 내). 한 번 열면 그 IP 가 1시간 등록돼 두 페이지 다 접근됩니다.", + "아래 버튼을 이 기기 브라우저에서 여세요 (3분 내). 한 번 열면 그 IP 가 ${adminProperties.allowlistTtl.toHours()}시간 등록돼 두 페이지 다 접근됩니다.", listOf( "API 문서" to "$host/admin-access/grant?token=$docsToken", "api.json" to "$host/admin-access/grant?token=$specToken", diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/EnvironmentAccessFilter.kt b/src/main/kotlin/com/depromeet/piki/admin/access/EnvironmentAccessFilter.kt index 46ace8d3..62c1143c 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/EnvironmentAccessFilter.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/EnvironmentAccessFilter.kt @@ -21,7 +21,10 @@ import org.springframework.web.util.UrlPathHelper // 게이트를 통과한다 — "내부 직결" 판정은 ClientIp.isInBoxDirect 가 소유한다(#872). @Component @ConditionalOnAdminEnabled -@Order(Ordered.HIGHEST_PRECEDENCE + 3) +// AccessLogFilter(HIGHEST+3) 바로 안쪽에 둔다(#988) — 같은 값이면 두 필터의 상대 순서가 빈 이름·등록 순서 같은 +// 비명세 규칙으로 갈려, 이 게이트가 바깥으로 정렬되는 순간 게이트가 낸 404 가 access log 도 traceId 도 없이 사라진다. +// dev 문서 표면을 훑는 시도가 관측에서 통째로 실명하므로 값을 한 칸 안으로 내려 순서를 확정한다. +@Order(Ordered.HIGHEST_PRECEDENCE + 4) class EnvironmentAccessFilter( private val allowlistService: AdminAllowlistService, private val adminProperties: AdminProperties, diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt b/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt index 5c86664a..60e89278 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt @@ -92,7 +92,9 @@ object StatsEmbed { ), ) - private const val TYPE_CHANNEL_MESSAGE = 4 - private const val FLAG_EPHEMERAL = 64 + // 인터랙션 응답 타입·ephemeral 플래그는 같은 패키지 DiscordInteractions 가 소유한다(#988) — 여기서 다시 + // 선언하면 Discord 가 값을 바꿀 때 한쪽만 고쳐져, /stats 만 admin 채널에 공개로 올라가는 식으로 조용히 갈린다. + private const val TYPE_CHANNEL_MESSAGE = DiscordInteractions.TYPE_CHANNEL_MESSAGE + private const val FLAG_EPHEMERAL = DiscordInteractions.FLAG_EPHEMERAL private const val COLOR_BLURPLE = 0x5865F2 } diff --git a/src/main/kotlin/com/depromeet/piki/admin/announcement/AdminAnnouncementController.kt b/src/main/kotlin/com/depromeet/piki/admin/announcement/AdminAnnouncementController.kt index 7d70ba32..bc4841de 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/announcement/AdminAnnouncementController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/announcement/AdminAnnouncementController.kt @@ -169,9 +169,15 @@ class AdminAnnouncementController( scheduledAt?.trim()?.ifBlank { null }?.let { runCatching { LocalDateTime.parse(it) }.getOrElse { return "redirect:/admin/announcements/$id/send?error=time" } } - adminAnnouncementService.schedule(id, at, actor = actor(request), clientIp = clientIp(request)) - // 예약이면 목록에서 예약 상태를 보고, 즉시면 바로 결과(진행률) 화면으로 보낸다. - return at?.let { "redirect:/admin/announcements?scheduled" } ?: "redirect:/admin/announcements/$id/result" + return try { + adminAnnouncementService.schedule(id, at, actor = actor(request), clientIp = clientIp(request)) + // 예약이면 목록에서 예약 상태를 보고, 즉시면 바로 결과(진행률) 화면으로 보낸다. + at?.let { "redirect:/admin/announcements?scheduled" } ?: "redirect:/admin/announcements/$id/result" + } catch (e: IllegalArgumentException) { + // 이미 발송·예약된 공지(발송 버튼 더블클릭 등)와 과거 시각 예약 — 흔한 운영자 레이스라 계약 실패다. + // 안 잡으면 @RestControllerAdvice 가 SSR 페이지를 raw JSON 400 으로 갈아치워 운영자가 화면을 잃는다(#988). + "redirect:/admin/announcements?error=send" + } } // 예약 취소 — SCHEDULED → DRAFT. @@ -180,8 +186,13 @@ class AdminAnnouncementController( @PathVariable id: Long, request: HttpServletRequest, ): String { - adminAnnouncementService.cancelSchedule(id, actor = actor(request), clientIp = clientIp(request)) - return "redirect:/admin/announcements?canceled" + return try { + adminAnnouncementService.cancelSchedule(id, actor = actor(request), clientIp = clientIp(request)) + "redirect:/admin/announcements?canceled" + } catch (e: IllegalArgumentException) { + // 스케줄러가 방금 집어간 공지 — 취소할 대상이 이미 없다. 목록으로 돌려 현재 상태를 보게 한다. + "redirect:/admin/announcements?error=cancel" + } } // 발송 결과 화면 — 집계(성공·실패·코드별)와 진행률 %. SENDING 동안 result.json 을 폴링해 갱신한다. @@ -206,8 +217,13 @@ class AdminAnnouncementController( fun delete( @PathVariable id: Long, ): String { - adminAnnouncementService.delete(id) - return "redirect:/admin/announcements?deleted" + return try { + adminAnnouncementService.delete(id) + "redirect:/admin/announcements?deleted" + } catch (e: IllegalArgumentException) { + // 그 사이 발송·예약으로 넘어갔거나 다른 운영자가 이미 지운 공지 — 삭제 대상이 아니다. + "redirect:/admin/announcements?error=delete" + } } private fun actor(request: HttpServletRequest): String = AdminSession.actorName(request) diff --git a/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageFetcher.kt b/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageFetcher.kt index 40f6b9d3..cb47bce3 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageFetcher.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageFetcher.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.admin.announcement +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled import com.depromeet.piki.announcement.domain.AnnouncementImageException import org.slf4j.LoggerFactory import org.springframework.http.client.SimpleClientHttpRequestFactory @@ -26,6 +27,7 @@ interface AnnouncementImageFetcher { } @Component +@ConditionalOnAdminEnabled class DefaultAnnouncementImageFetcher : AnnouncementImageFetcher { private val log = LoggerFactory.getLogger(javaClass) diff --git a/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageRehoster.kt b/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageRehoster.kt index 842f698b..367ecd8c 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageRehoster.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementImageRehoster.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.admin.announcement +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled import com.depromeet.piki.announcement.domain.AnnouncementBodyImages import com.depromeet.piki.announcement.domain.AnnouncementImageFile import com.depromeet.piki.common.storage.ImageStorage @@ -12,6 +13,7 @@ import java.util.UUID // fetch·업로드는 외부 호출이라 @Transactional 을 두지 않는다 — 호출부(AdminAnnouncementService)가 // 트랜잭션 밖에서 이 빈을 부르고, 영속화만 짧은 트랜잭션(AnnouncementWriter)에 위임한다(## 트랜잭션 경계). @Component +@ConditionalOnAdminEnabled class AnnouncementImageRehoster( private val fetcher: AnnouncementImageFetcher, private val imageStorage: ImageStorage, diff --git a/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementProgressWriter.kt b/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementProgressWriter.kt index 6818db4e..e756049e 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementProgressWriter.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/announcement/AnnouncementProgressWriter.kt @@ -1,6 +1,7 @@ package com.depromeet.piki.admin.announcement import com.depromeet.piki.announcement.repository.AnnouncementRepository +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled import com.depromeet.piki.notification.fcm.service.UserDeviceService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -10,6 +11,7 @@ import java.util.UUID // 트랜잭션 밖에서 돌므로, 상태 전환·진행률 갱신·delivery 행 저장만 이 별도 빈의 짧은 트랜잭션에 위임한다 // (CLAUDE.md "외부 호출은 트랜잭션 밖" + self-invocation 회피 — async 빈에서 직접 @Transactional 호출은 proxy 우회). @Service +@ConditionalOnAdminEnabled class AnnouncementProgressWriter( private val announcementRepository: AnnouncementRepository, private val deliveryRepository: AnnouncementDeliveryRepository, diff --git a/src/main/kotlin/com/depromeet/piki/admin/announcement/DefaultPushImage.kt b/src/main/kotlin/com/depromeet/piki/admin/announcement/DefaultPushImage.kt index 9d79fd9c..ba8cd3b0 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/announcement/DefaultPushImage.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/announcement/DefaultPushImage.kt @@ -1,5 +1,6 @@ package com.depromeet.piki.admin.announcement +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled import com.depromeet.piki.common.storage.S3Properties import org.springframework.stereotype.Component @@ -10,6 +11,7 @@ import org.springframework.stereotype.Component // (#473 고도화 — kind 로 라벨·아이콘을 가른다) 알림 발송 경로의 소비자가 사라졌다. 지금 유일한 소비자가 // AdminAnnouncementController 뿐이라 admin 패키지에 둔다 — 알림 발송이 이 이미지를 쓴다는 오독을 막는다. @Component +@ConditionalOnAdminEnabled class DefaultPushImage( s3Properties: S3Properties, ) { 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 21acd7a1..612c2f16 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/extraction/AdminExtractionPolicyController.kt b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt index 9a6b27e8..289af281 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/extraction/AdminExtractionPolicyController.kt @@ -91,14 +91,15 @@ class AdminExtractionPolicyController( ) "redirect:/admin/extraction-policies?updated" } catch (e: IllegalArgumentException) { - model.addAttribute("error", e.message) - model.addAttribute("draftReason", reason) - detailView( - adminExtractionPolicyService.find(domain), - model, - selectedAccess = access.name, - draftPermissionRef = permissionRef, - ) + // find 가 다시 던질 수 있다 — 그 사이 다른 운영자가 행을 지웠거나, 애초에 domain 이 정규화를 통과 못 하는 + // 값(경로에 공백 등)이라 save 가 막힌 경우다. 복구 경로에서 새 예외가 새면 GlobalExceptionHandler 가 + // 화면을 raw JSON 400 으로 갈아치워, 에러 메시지도 제출값도 함께 잃는다(#988). 그 땐 목록으로 돌린다. + runCatching { adminExtractionPolicyService.find(domain) } + .map { policy -> + model.addAttribute("error", e.message) + model.addAttribute("draftReason", reason) + detailView(policy, model, selectedAccess = access.name, draftPermissionRef = permissionRef) + }.getOrElse { "redirect:/admin/extraction-policies?missing" } } @PostMapping("/{domain}/delete") diff --git a/src/main/kotlin/com/depromeet/piki/admin/sourceplatform/AdminSourcePlatformController.kt b/src/main/kotlin/com/depromeet/piki/admin/sourceplatform/AdminSourcePlatformController.kt index 8a94599e..107cc3a5 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/sourceplatform/AdminSourcePlatformController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/sourceplatform/AdminSourcePlatformController.kt @@ -70,9 +70,14 @@ class AdminSourcePlatformController( adminSourcePlatformService.save(domain, displayName, actor = AdminSession.actorName(request), clientIp = ClientIp.of(request)) "redirect:/admin/source-platforms?updated" } catch (e: IllegalArgumentException) { - model.addAttribute("error", e.message) - model.addAttribute("draftDisplayName", displayName) - detailView(adminSourcePlatformService.find(domain), model) + // find 가 다시 던질 수 있다 — 정규화를 통과 못 하는 domain(경로에 공백 등)이면 save 와 같은 이유로 막힌다. + // 복구 경로에서 새 예외가 새면 화면이 raw JSON 400 으로 갈린다(#988). 그 땐 목록으로 돌린다. + runCatching { adminSourcePlatformService.find(domain) } + .map { platform -> + model.addAttribute("error", e.message) + model.addAttribute("draftDisplayName", displayName) + detailView(platform, model) + }.getOrElse { "redirect:/admin/source-platforms?missing" } } @PostMapping("/{domain}/delete") 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 99d11d94..b0249c1a 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/announcement-edit.html b/src/main/resources/templates/admin/announcement-edit.html index f3fc432d..aaf6a46a 100644 --- a/src/main/resources/templates/admin/announcement-edit.html +++ b/src/main/resources/templates/admin/announcement-edit.html @@ -5,7 +5,7 @@ 공지 수정 · PiKi 백오피스 - +