Skip to content

security: 레드팀 감사 취약점 일괄 수정 (HIGH 2 · MED 9 · LOW 다수) - #82

Merged
mack-erel merged 16 commits into
mainfrom
redteam-hardening
Jul 8, 2026
Merged

security: 레드팀 감사 취약점 일괄 수정 (HIGH 2 · MED 9 · LOW 다수)#82
mack-erel merged 16 commits into
mainfrom
redteam-hardening

Conversation

@mack-erel

Copy link
Copy Markdown
Owner

개요

레드팀 화이트박스 감사(OIDC / SAML / 인증 코어 / 관리자 접근제어 / API·Skin·주입 5개 도메인)에서 발견한 취약점을 취약점별 커밋 단위로 수정합니다. 종합 위험도 MEDIUM(Critical/RCE·원격 무인증 인증우회 없음)이나 배포형태 의존 HIGH 2건 + 다수 MEDIUM을 해결했습니다.

  • DB 스키마 변경 없음 — 추가 마이그레이션 없이 배포 가능 (M-6은 기존 inResponseTo 컬럼 재활용).
  • 각 커밋마다 typecheck 통과 후 커밋(pre-commit prettier+eslint). 전체 테스트 241개 통과, lint 통과.

수정 내역

등급 내용
HIGH H-1 Node 배포의 위조 가능한 cf-connecting-ip → 실제 소켓 주소 사용 (레이트리밋 우회/감사로그 오염)
HIGH H-2 LDAP starttls 가 STARTTLS 를 실제 협상(평문 자격증명 전송 차단, fail-closed)
MED M-1 OIDC 아웃바운드 웹훅(backchannel/frontchannel/role-change) URI SSRF 호스트 게이트(등록+fetch 이중 검증)
MED M-2 계정 잠금 DoS 제거 — "올바른 비밀번호는 항상 통과"
MED M-3 탈권한 시 refresh token 폐기 + refresh grant hasServiceAccess 재검증
MED M-4 MFA 백업코드 무염 SHA-256 → scrypt(salted), 레거시 하위호환
MED M-5 계정 self-service MFA step-up 스로틀 + lastUsedStep replay 방어
MED M-6 IdP-initiated SAML SLO 응답 InResponseTo 바인딩 검증(로그아웃 DoS 차단)
MED M-7 skin sanitizer 인라인 style 오버레이/리드레싱 속성 무력화(피싱 차단)
MED M-8 admin 로그인 계정 단위 잠금(패스워드 스프레이 차단)
MED M-9 api/users/lookup 레이트리밋 + 감사 로그(토큰 유출 시 PII 덤프 상한/탐지)
LOW 로그아웃 CSRF(Sec-Fetch 가드) · admin 대시보드 role 재확인 · crud-factory tenantId spread 순서 · 패스워드 최대 길이(128) · OIDC auth_time 정확도 · service-token 상수시간 비교 · SAML forceAuthn 쿠키 ID 정규화

코드 미변경(운영/제품 판단 영역)

  • 단일 마스터키 분리(아키텍처 + 키 회전 인프라 필요)
  • SAML 아웃바운드 AES-CBC/RSA-OAEP-SHA1(SP 상호운용성, IdP 대상 비악용)
  • wantAuthnRequestsSigned 기본값 / signup 계정열거 / 멀티테넌트 세션 스코프(스키마·UX·현행 무해)
  • 의존성 bun update(dev/build 툴링 5건, 런타임 Critical/High 0)

검증

  • bun run typecheck ✅ / npm run lint ✅ / bunx vitest run → 31 파일 · 241 테스트 통과

🤖 Generated with Claude Code

mack-erel and others added 16 commits July 8, 2026 18:59
레이트리밋·감사로그가 cf-connecting-ip 헤더를 무조건 신뢰했다. adapter-node 등
비-Cloudflare 배포에서는 이 헤더를 클라이언트가 임의 주입할 수 있어, 요청마다 IP 를
회전시켜 IP 기반 rate-limit 을 우회하고 audit log 에 위조 IP 를 주입할 수 있었다.
Workers 여부(platform.ctx.waitUntil)를 판별해 Workers 에서만 cf-connecting-ip 를
신뢰하고, 그 외에는 event.getClientAddress() 로 실제 peer 주소를 사용한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
tlsMode='starttls' 일 때 ldap://(평문) 으로 연결하고 tlsOptions 만 설정한 채
STARTTLS extended operation 을 호출하지 않아, 관리자가 "암호화(starttls)"를
선택했음에도 bind 자격증명·인증 사용자 비밀번호가 평문 소켓으로 전송됐다.
connectLdapClient() 를 추가해 bind 전에 STARTTLS 를 협상하고, 업그레이드 실패 시
평문 bind 로 진행하지 않고 fail-closed 한다. ldapBind/ldapSearchDn/ldapFetchEntry
모두 이 경로를 거친다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
backchannel/frontchannel logout·role-change URI 는 IdP 가 서버측에서 직접 fetch
하는데도 scheme 검증만 있어, 관리자가 169.254.169.254(IMDS)·RFC1918 내부 호스트를
지정하면 Worker egress 에서 서명된 SET/logout JWT 를 내부 엔드포인트로 흘릴 수 있었다
(블라인드 SSRF). LDAP 게이트와 동일 취지로 loopback/사설망/CGNAT/ULA/메타데이터
호스트를 차단하는 isForbiddenWebhookHost + validateWebhookUrl(https 강제) 을 추가하고,
oidc-clients create/update 검증(validateSingleUri)에 적용했다. 또한 등록 이전 저장
행을 방어하기 위해 role-change/backchannel logout fetch 직전 assertPublicWebhookUrl
재검증(fail-closed)을 추가했다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
계정 단위 잠금(login:user:<username>)이 인증 전에 하드 차단되어, 무인증 공격자가
임의 username 으로 15분당 10회 실패를 유발하면 올바른 비밀번호를 가진 피해자(관리자
포함)까지 로그인 불가로 만들 수 있었다.

잠금 상태여도 인증을 수행하고, 인증까지 실패한 경우에만 잠금 응답(429)으로 전환하도록
변경했다. 정상 사용자는 user 가 채워져 실패 분기에 도달하지 않으므로 절대 차단되지
않는다. 브루트포스 방어(오답 시 잠금 + IP당 10회/15분)는 그대로 유지된다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
admin 로그인이 IP당 제한(admin-login:<ip>)만 있어, 다수 IP 를 가진 공격자가 admin
계정에 패스워드 스프레이를 할 수 있었다(사용자 로그인엔 계정별 잠금이 있었으나 admin
경로엔 없었다). admin-login:user:<username> 계정별 잠금(15분/10회)을 추가했다.
사용자 로그인(M-2)과 동일한 "올바른 비밀번호는 항상 통과" 모델을 적용해 잠금이 정상
관리자를 차단하지 않는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
assignment 회수(revokeAssignment)가 role-change SET(roles:[])만 발행하고 refresh
token/세션은 폐기하지 않아, 탈권한된 사용자가 보유 중인 refresh token 으로 최대 30일간
access/id token 을 계속 재발급받고 userinfo 를 조회할 수 있었다(비밀번호 리셋·계정삭제
경로는 이미 refresh token 을 폐기하는데 assignment 회수만 누락).

- revokeAssignment: OIDC 타깃일 때 revokeRefreshTokenFamily 로 해당 클라이언트의
  이 사용자 refresh token 을 폐기.
- token refresh grant: 매 refresh 마다 hasServiceAccess 재검증(authorize 와 동일
  게이트) — 놓친 폐기 경로가 있어도 access token TTL(5분) 안에 fail-closed.
  allowAllUsers 클라이언트는 예외.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
백업코드가 무염 단일 SHA-256(fast hash)으로 저장되어, DB 유출 시 ~2^40 엔트로피의
코드를 전 사용자에 걸쳐 병렬 GPU 크랙 → 대규모 오프라인 MFA 우회가 가능했다.
TOTP 시크릿은 AES-GCM 암호화되지만 백업코드가 같은 테이블의 약한 고리였다.

패스워드와 동일한 scrypt(salted, memory-hard) KDF 를 재사용하도록 hashBackupCode 를
변경했다. verifyBackupCode 는 scrypt/argon2/pbkdf2 형식은 verifyPassword 로,
레거시 무염 SHA-256 hex 는 하위호환 상수시간 비교로 처리한다(일회성이라 자연 소멸).

trade-off: 검증이 저장된 미사용 코드를 순회하므로 시도당 최대 ~1초지만, 백업코드
로그인은 드물고 MFA 라우트 rate-limit 으로 상한이 걸려 있다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
account/mfa 의 confirm/delete/regenerate 와 danger-zone 삭제 step-up 에 rate limit 이
없어, 세션 탈취자가 6자리 TOTP 를 무제한 브루트포스해 피해자의 MFA 를 제거/회전(지속성
확보)할 수 있었다. 또 delete/regenerate 는 lastUsedStep 없이 검증해 다른 엔드포인트에서
쓴 코드를 replay 할 수 있었다.

- 세 액션 + danger-zone 에 mfa-stepup:<userId> 공유 스로틀(5분/10회) 추가 —
  엔드포인트를 옮겨가며 횟수를 우회하지 못하게 키를 공유.
- delete/regenerate 의 verifyTotp 에 lastUsedStep(counter) 바인딩, regenerate 는
  성공 시 counter 갱신(credential 유지). 로그인 /mfa·/api/totp/* 와 정합.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
sanitizer 가 denylist 이며 <style>/<link> 태그는 제거하지만 style 속성은 보존해,
침해된 skin 호스트가 position:fixed/absolute + z-index 로 가짜 로그인 필드를 실제
로그인 폼 위에 오버레이하는 자격증명 피싱(UI-redress)이 가능했다(JS 는 CSP 로 이미 차단).

style 속성에서 position/top/left/right/bottom/inset/z-index/transform/perspective/
float/clip 계열을 제거하고 색상·폰트·여백 등 정상 스타일은 보존한다(정상 skin 은
인라인 style 로 디자인하므로 통째 제거는 회피). 주된 오버레이 수단을 없애는 심층 방어.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
service-to-service user lookup 이 레이트리밋·감사 로그 없이 tenant 스코프 PII
({id,email,displayName,role,status})를 반환해, 단일 전역 service token 유출 시
무제한 사용자 디렉터리 덤프가 흔적 없이 가능했다.

IP당 분당 120회 상한을 추가하고, 초과 시 429 + 감사 이벤트(service_lookup_throttled)를
남겨 대량 열거 abuse 를 탐지 가능하게 했다. 정상 dispatcher 매핑 조회량은 통과한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
IdP-initiated SLO 체인(initiatingSpEntityId NULL)은 LogoutResponse 를 서명 검증 없이
RelayState(=stateId)만으로 진행했다. stateId 는 체인의 모든 downstream SP 에 전달되어
Referer/브라우저 기록/SP 로그로 유출되므로, 이를 관측한 제3자(체인 내 악성 SP 포함)가
임의 SAMLResponse 로 체인을 순서 밖에서 구동/조기 종료(로그아웃 DoS)할 수 있었다.

각 hop 에 보내는 LogoutRequest ID(122-bit 랜덤, 해당 SP 에게만 전달)를 저장하고, 반환
LogoutResponse 의 InResponseTo 가 일치하는지 검증한다. 스키마 변경을 피하기 위해
IdP-initiated 체인에서 미사용인 inResponseTo 컬럼을 hop 추적에 재활용했다(SP-initiated
체인은 Case A 에서 cert 서명 검증되고 inResponseTo 를 최초 SP 응답용으로 쓰므로 미변경).
parseSamlLogoutResponseInResponseTo 파서는 LogoutRequest 파서와 동일하게 XXE/DTD 차단.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…순서 (LOW)

- admin/+page.server.ts load 가 requireDbContext(db/tenant 존재만 확인)를 써서 role
  재확인을 layout 게이트에만 의존했다. 다른 admin load 와 동일하게 requireAdminContext
  로 변경(방어 심층화 — layout 리팩터/action 추가 시 테넌트 카운트 유출 방지).
- crud-factory create 의 .values({ tenantId, ...values }) spread 순서를
  { ...values, tenantId } 로 뒤집어 form 이 주입한 tenantId 가 서버 값을 덮어쓰지
  못하게 명시 보장(현재는 zod strip 에 암묵 의존).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- GET /oidc/end-session: 유출된 id_token_hint 로 <img>/<iframe> drive-by 로그아웃이
  가능했다. RP-Initiated Logout 은 최상위 네비게이션(Sec-Fetch-Dest=document)이므로
  임베드 요청(image/iframe/empty 등)은 204 로 거부. 구형 브라우저는 통과(소유 증명 유지).
- GET /saml/slo Case D fallback: 파라미터 없는 state-changing GET 이 세션을 폐기해
  cross-site 임베드로 강제 로그아웃이 가능했다. 진짜 SAML 로그아웃은 Case C(서명 검증)로
  처리되므로, fallback 세션 폐기는 same-site 최상위 네비게이션에서만 수행한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
signup/reset-password/accept-invite 가 min 8 만 검사하고 max length 가드가 없어,
API/curl 직접 호출로 극단적으로 긴 비밀번호를 넣어 KDF/HMAC 자원을 낭비할 수 있었다.
공용 MAX_PASSWORD_LENGTH(=128, 클라이언트 64 제한보다 여유) + errors.password_too_long
로 세 흐름 모두에 상한을 강제한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- authorization_code grant 의 id_token auth_time 이 code 발급 시각(grant.createdAt)을
  써서 실제 인증 시각보다 부풀려졌다. 세션 createdAt 을 사용하도록 수정(refresh grant 와
  정합) — RP 의 max_age 검증 정확도 향상.
- requireServiceToken 의 토큰 비교가 원문 문자열 길이 조기 반환으로 토큰 길이를 타이밍
  누출하고 JS 문자열 비교의 상수시간성도 미보장이었다. 양쪽을 고정 길이 SHA-256
  다이제스트로 만든 뒤 상수시간 비교(길이 무관). 호출부 5곳에 await 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
forceAuthn 재진입 가드가 공격자 제어 XML 값인 AuthnRequest.id 를 그대로 쿠키명
(saml_reauth_<id>)에 사용해, space/;/제어문자가 포함된 위조 AuthnRequest 로
cookie.serialize 가 throw → 500 DoS 를 유발할 수 있었다. 안전 문자([A-Za-z0-9_.-])만
남기고 128자로 제한해 결정론적으로 정규화한다(loop 가드 동작 불변).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@mack-erel
mack-erel merged commit f7e9e5e into main Jul 8, 2026
6 checks passed
@mack-erel
mack-erel deleted the redteam-hardening branch July 8, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant