Skip to content

백오피스 리뷰 후속 - 보안 5건·동작 3건·정합성 5건 - #989

Open
m-a-king wants to merge 3 commits into
devfrom
fix/988-admin-review-followups
Open

백오피스 리뷰 후속 - 보안 5건·동작 3건·정합성 5건#989
m-a-king wants to merge 3 commits into
devfrom
fix/988-admin-review-followups

Conversation

@m-a-king

@m-a-king m-a-king commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Situation

백오피스 전체(admin 패키지 47개 파일 + 템플릿 16개)를 코드 리뷰해 15건이 나왔다. 가장 심각한 게이트 우회는 #987 에서 먼저 냈고, 이 PR 이 나머지다.

절반 가까이가 코드베이스가 이미 산문으로 정해둔 규칙을 딱 한 곳만 안 지킨 경우였다. ClientIp.of 쓰기(19곳 중 1곳 누락), admin 빈에 @ConditionalOnAdminEnabled 달기(4개 누락), SSR 컨트롤러의 try/catch, 공통 응답 래퍼. 규칙과 참조 구현이 이미 있어 고치는 비용이 낮았다.

Task

14건을 문제별로 닫는다. 각 수정이 의도대로 동작하는지, 그리고 고치려던 문제가 실제로 존재했는지를 로컬 실행으로 확인한다.

Action

보안 5건

문제 그래서 무슨 일이 벌어지나 해결
POST /admin/session/extend 가 CSRF 면제 제3자 페이지가 cross-origin 폼을 자동 제출해 allowlist TTL 을 무기한 연장. "연장은 명시적 버튼으로만" 이라는 #669 의 보장이 무너진다 면제를 걷고 상단바 fetch 에 토큰을 실었다. 면제 근거였던 "fetch 라 토큰을 못 싣는다" 는 틀렸다 (같은 화면의 다른 fetch 는 이미 싣고 있었다)
grant 가 기존 세션에 신원을 심음 공격자가 미리 심어둔 JSESSIONID 가 그대로 admin 세션이 된다. permitAll 체인이라 Spring Security 의 픽세이션 보호가 안 돈다 establish 전에 invalidate() 후 새 세션 발급
감사 로그 IP 를 XFF 첫 hop 에서 읽음 nginx 가 XFF 를 append 하므로 클라이언트 위조분이 첫 hop 이다. 템플릿 수정 이력의 IP 가 공격자 통제 아래 놓인다 ClientIp.of 로 교체 (19곳 중 유일하게 남아 있던 한 곳)
/admin-assets/** 가 permitAll 로 뚫려 있음 서빙하는 것이 없는데 보안 체인에 열려 있고 게이트 필터도 제외한다. 정적 핸들러를 붙이는 순간 무인증 공개가 된다 matcher 삭제
변수 칩이 th:utext 로 마크업 조립 admin 패널의 유일한 unescaped 출력. 변수 카탈로그가 DB 로 옮겨가면 그대로 저장형 XSS 경로가 된다 이스케이프되는 th:text 두 개로 분리 (출력은 동일)

동작 4건

문제 그래서 무슨 일이 벌어지나 해결
공지 send·cancel·delete 가 예외를 안 잡음 발송 버튼 더블클릭 같은 흔한 레이스에서 SSR 페이지가 raw JSON 400 으로 갈린다. 운영자는 화면을 잃고 발송이 시작됐는지도 모른다 형제 컨트롤러들이 쓰는 try/catch 패턴 적용 + 사유별 배너
복구용 catch 안에서 find 가 재throw 에러 페이지를 그리려다 예외가 새어 에러 메시지와 제출값을 함께 잃는다. 정책·출처 몰 두 화면 runCatching 으로 감싸고, 실패하면 목록으로 되돌린다
결과 폴링에 상한·중단 조건 없음 삭제된 공지나 SENDING 에 갇힌 공지에서 탭 하나가 시간당 약 1200 요청을 영구히 보낸다 비 2xx 는 즉시 중단, 진행이 안 끝나도 약 10분에서 멈추고 새로고침 안내
param.error 비교가 항상 false String[] 을 문자열과 직접 비교해, 길이 초과·이미지 실패 배너가 원래부터 한 번도 뜬 적이 없다 param.error[0] 로 수정. 새로 넣은 3건과 기존 2건을 함께 고쳤다

마지막 항목은 리뷰에 없던 건이다. 배너를 붙이다 기존 것도 죽어 있는 걸 발견했다 (metrics.html 만 인덱스 접근으로 올바르게 쓰고 있었다).

정합성 5건

문제 그래서 무슨 일이 벌어지나 해결
flatpickr 이 무버전 URL jsDelivr 의 latest 로 해석된다. breaking 릴리즈가 나면 dateFormat 계약이 조용히 바뀌어 예약 시각이 서버가 못 읽는 모양으로 제출된다 4.6.13 고정. 무버전 URL 이 해석하던 파일과 내용이 같음을 해시로 확인
CDN 자원에 무결성 검증 없음 이 화면들은 CSRF 토큰을 JS 변수로 들고 전체 사용자 푸시를 구동한다. CDN 이 오염되면 토큰 탈취·브로드캐스트 구동으로 이어진다 pretendard·flatpickr 에 integrity·crossorigin 적용 (toast editor 는 아래 참조)
admin 빈 4개에 @ConditionalOnAdminEnabled 누락 admin.enabled=false 에서도 생성된다. DefaultPushImage 는 생성 시점에 S3 프로퍼티를 읽어 부팅을 깬다 네 빈에 어노테이션 추가
EnvironmentAccessFilter@OrderAccessLogFilter 와 동일 체인 내 위치가 비명세 규칙으로 갈린다. 게이트가 바깥으로 정렬되면 dev 문서 탐지 시도가 access log 도 traceId 도 없이 사라진다 한 칸 안으로 내려 순서 확정
Discord 상수 재선언 · 세션 TTL 응답 래퍼 미적용 · /docs TTL 문구 오기 값이 갈리면 /stats 만 공개로 올라가거나, 응답 모양을 통일하려는 다음 사람이 카운트다운을 깬다. TTL 은 실제 24시간인데 1시간이라 안내해 아무도 회수하지 않는다 상수는 참조로, 응답은 ApiResponseBody 로, TTL 문구는 설정값에서 파생하게

Result

로컬에 앱을 띄워 각 수정을 실행으로 확인했다.

확인한 것 결과
CSRF 토큰 없이 POST /admin/session/extend 401 (차단됨)
토큰 실어 같은 요청 200, {"data":{"remainingSeconds":0},...}
SRI 를 건 flatpickr·pretendard 정상 로드, ko locale·altInput 동작
toast editor 정상 마운트
변수 칩 렌더·클릭 삽입 정상, data-var 유지
에러 배너 5종 모두 표시 (수정 전에는 전부 안 뜸)

못 한 것 하나: toast editor 의 SRI

uicdn.toast.comCORS 헤더를 주지 않는다. SRI 는 교차 출처에서 CORS 없이 동작하지 않으므로, crossorigin 을 붙이는 순간 브라우저가 스크립트를 차단한다. 실제로 붙였다가 공지 편집기가 통째로 안 뜨는 것(window.toastui undefined, 에디터 미마운트)을 로컬에서 확인하고 되돌렸다.

선택지 가능한가 비용
현행 유지 (SRI 없음) - 편집 화면 2개가 무결성 검증 없이 남는다
jsDelivr @toast-ui/editor@3.2.2 로 이전 CORS 있음, 4개 파일 모두 존재 같은 버전인데 빌드가 달라 해시 불일치. 동작 확인이 별도로 필요
로컬 정적 파일로 내재화 가능 repo 용량과 업데이트 수작업

이 PR 은 현행 유지로 두고 코드에 TODO 를 남겼다. 어느 쪽으로 갈지는 별도로 정한다.


연관 이슈

- 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 이 된다(테스트에서 실제로 터졌다)
- 공지 send·cancel·delete 가 IllegalArgumentException 을 안 잡아, 발송 버튼 더블클릭 같은 흔한 레이스에서 @RestControllerAdvice 가 SSR 페이지를 raw JSON 400 으로 갈아치웠다. 운영자는 화면을 잃고 발송이 시작됐는지도 알 수 없었다. 형제 컨트롤러들이 이미 쓰는 try/catch 패턴을 적용하고 사유별 배너를 붙였다
- 정책·출처 몰 상세의 catch 블록이 복구용으로 부르는 find 가 다시 던질 수 있었다. 복구 경로에서 예외가 새면 에러 메시지와 제출값을 함께 잃는다. runCatching 으로 감싸 그 경우 목록으로 돌린다
- 발송 결과 폴링에 상한과 중단 조건이 없어, 삭제된 공지나 SENDING 에 갇힌 공지에서 탭 하나가 시간당 약 1200 요청을 영구히 보냈다. 비 2xx 는 영구 오류로 보고 즉시 멈추고, 진행이 안 끝나도 약 10분에서 멈춰 새로고침을 안내한다
- 배너를 붙이다 param.error 비교가 원래부터 죽어 있던 것을 발견했다. param.error 는 String[] 이라 문자열과 직접 비교하면 항상 false 다(metrics.html 만 param.error[0] 로 올바르게 쓰고 있었다). 새로 넣은 3건과 기존 length·image 2건을 함께 고쳤고, 로컬에서 다섯 배너가 모두 뜨는 것을 확인했다
- flatpickr 2건이 무버전 URL 이라 jsDelivr 의 latest 로 해석되고 있었다. breaking 릴리즈가 나면 dateFormat 계약이 조용히 바뀌어 예약 시각이 서버가 못 읽는 모양으로 제출된다. 4.6.13 으로 고정하고, 무버전 URL 이 해석하던 파일과 내용이 같음을 해시로 확인했다
- pretendard·flatpickr 자원에 SRI(integrity·crossorigin)를 걸었다. 이 화면들은 CSRF 토큰을 JS 변수로 들고 전체 사용자 푸시 브로드캐스트를 구동하므로, 무결성 검증 없는 스크립트가 admin 세션 범위에서 도는 상태였다
- toast editor 4건은 SRI 를 걸지 못했다. uicdn.toast.com 이 CORS 헤더를 주지 않아 crossorigin 을 붙이는 순간 브라우저가 차단한다 - 실제로 붙였다가 공지 편집기가 통째로 안 뜨는 것을 로컬에서 확인하고 되돌렸다. CORS 를 주는 jsDelivr 로 옮기면 걸 수 있으나 같은 3.2.2 라도 빌드가 달라(해시 불일치) 별도 확인이 필요해 TODO 로 남겼다
- admin 빈 4개에 @ConditionalOnAdminEnabled 가 빠져 있었다. admin.enabled=false 에서도 생성되고, 그중 DefaultPushImage 는 생성 시점에 S3 프로퍼티를 읽어 부팅을 깬다. 그 어노테이션의 KDoc 이 막으려던 바로 그 누락이다
- EnvironmentAccessFilter 의 @order 가 AccessLogFilter 와 같은 값이라 체인 내 위치가 미정이었다. 게이트가 바깥으로 정렬되면 dev 문서 탐지 시도가 access log 도 traceId 도 없이 사라진다. 한 칸 안으로 내려 순서를 확정했다
- StatsEmbed 가 같은 패키지 DiscordInteractions 의 상수를 private 으로 재선언하고 있었다. 값이 갈리면 /stats 만 공개로 올라가는 식으로 조용히 어긋나므로 참조로 바꿨다
- /docs grant 안내가 IP 등록 수명을 1시간이라 알렸으나 실제 TTL 은 24시간이다. 문구를 설정값에서 파생하게 바꿔 다시 어긋나지 않게 했다
@m-a-king m-a-king added the fix 외부 가시적 결함 수정 label Aug 25, 2026
@m-a-king m-a-king linked an issue Aug 25, 2026 that may be closed by this pull request
@m-a-king m-a-king self-assigned this Aug 25, 2026
@github-actions

Copy link
Copy Markdown

Discord 스레드 연동용 메타데이터입니다. discord-pr-bot 워크플로가 자동 생성하며, 수정·삭제하면 PR 과 Discord 알림 연동이 끊깁니다.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: a8666aae-5906-4c84-90df-973fb4e6d737


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix 외부 가시적 결함 수정

Projects

None yet

Development

Successfully merging this pull request may close these issues.

백오피스 코드 리뷰 후속 - 보안·동작·정합성 14건

1 participant