Skip to content

fix: 푸시로 열리는 웹뷰의 학생 토큰 주입과 외부 링크 처리를 고친다 - #40

Open
seongwon030 wants to merge 5 commits into
mainfrom
fix/inject-student-token-slug-webview
Open

seongwon030 wants to merge 5 commits into
mainfrom
fix/inject-student-token-slug-webview

Conversation

@seongwon030

@seongwon030 seongwon030 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

원래 세 PR(#40, #41, #42)이었는데 같은 파일들을 연달아 고치는 한 덩어리라 합쳤습니다. 커밋 5개입니다. #41에 달렸던 CodeRabbit 지적과 대응은 아래 3번에 그대로 옮겼습니다.

앱이 여는 웹뷰의 경계 문제 모음입니다. utils/webview.ts, ui/home/home-webview-screen.tsx, app/webview/[slug].tsx.


1. ddddf1c — 답장 알림으로 연 편지함이 비어 보인다

우체통은 로그인 없이 쓰므로 익명 학생 토큰으로 사람을 구분합니다. 웹은 앱이 주입한 토큰을 먼저 쓰고(studentFetch.ts:98), 없으면 자기 토큰을 발급합니다. 그러면 앱과 신원이 갈려 편지함이 비어 보입니다.

PR #28이 주입을 넣었지만 홈 웹뷰에만 있었습니다:

  1. FeedbackAdminService.java:239-240 — 답장 푸시가 path=/feedback/letters/<id> 전송
  2. use-fcm.ts:36-50 — /webview/clubDetail/로 시작하지 않는 path는 /webview/[slug]로
  3. [slug].tsx — 주입 없음
  4. studentFetch.ts:102-103 — localStorage도 비어 있어(주입 경로는 localStorage에 안 씀) 새 신원 발급

주입 스크립트 생성을 utils/webview.ts로 올려 공유합니다.

가드 기준을 진입 URL이 아니라 모아동 URL로 바꿨습니다. 기존 홈 코드는 new URL(BASE_URL).origin !== window.location.origin인데, [slug]는 진입 URL 자체가 외부일 수 있어 "외부 == 외부"로 통과합니다. 그대로 옮겼으면 외부 사이트에 베어러 토큰이 주입됐습니다.

가드가 두 겹입니다 — RN 쪽(진입 URL)과 스크립트 안(실행 시점 origin). 두 번째를 웹뷰 안에서 하는 건 RN의 URL 폴리필이 호스트 대소문자·기본 포트를 정규화하지 않아 RN에서 만든 origin 문자열이 window.location.origin과 어긋날 수 있기 때문입니다(기존 주석의 지식).

club-detail-screen.tsx에는 넣지 않았습니다 — studentFetch를 쓰는 페이지를 직접 열지 않습니다.

2. e537a34 — 외부 링크에 session_id가 붙고 Android에선 안 열린다

iOS: session_id는 웹 Mixpanel의 distinct_id인데 [slug].tsx가 목적지를 안 가리고 붙여서 제3자 도메인 로그에 남았습니다. → 모아동 오리진일 때만 붙입니다.

Android: setSupportMultipleWindows 기본값 true라 target='_blank'가 onCreateWindow로 갑니다. onOpenWindow 핸들러가 없으면 RNCWebChromeClient.java:89-114가 WebViewClient도 없는 new WebView(context) 를 만들어 넘기는데 그 뷰가 어떤 계층에도 안 붙습니다 → 무반응. → setSupportMultipleWindows={false}.

둘을 같이 고칩니다. Android 링크만 살리면 session_id가 나가는 경로가 Android로도 번집니다.

3. 0042397 — 내부 URL 판정이 prefix 비교였다 (CodeRabbit 지적)

🟠 Major · CWE-346 Origin Validation Error
request.url.startsWith(baseOrigin)는 https://moadong.com.evil.com을 내부 URL로 승인합니다.

확인한 판정 차이:

URL 수정 전 수정 후
moadong.com/clubs 내부 승인 내부 승인
moadong.com.evil.com/pwn 내부 승인 외부 판정
moadong.community-evil.io/ 내부 승인 외부 판정
instagram.com/x 외부 판정 외부 판정

파싱한 origin끼리 비교합니다(isWebViewOrigin 재사용). 고아가 된 baseOrigin 지역변수는 제거했습니다. 주입 토큰 가드는 원래 new URL(...).origin 비교라 이 경로로는 유출되지 않았습니다.

지적의 나머지("onMessage에도 origin 검증")는 4번이 해결합니다 — 외부 페이지가 onMessage 붙은 WebView에 아예 뜨지 않게 됩니다.

4. d386506 — 외부 사이트를 앱 화면에 띄우고 있었다

외부 URL이 [slug].tsx의 <WebView>에 로드됐습니다. 사용자에게는 모아동 헤더가 붙고 제목은 기본값 "웹페이지" 로 떠서 어느 사이트인지 알 단서가 없습니다. 기술적으로는 앱 프로세스 안이라 그 페이지가 window.ReactNativeWebView.postMessage로 브리지를 그대로 씁니다.

WebBrowser.openBrowserAsync로 넘깁니다(iOS SFSafariViewController, Android Custom Tabs). OS 브라우저 프로세스라 앱 JS에 닿지 못하고, 도메인이 표시되며, 시스템 브라우저 세션을 공유합니다.

새로 도입하는 방식이 아닙니다. banner.tsx:328, external-link.tsx:18, OPEN_EXTERNAL_URL 핸들러가 이미 같은 함수를 씁니다. slug: 'external' 경로만 예외였습니다.

대상: ClubUnionPage.tsx:76,89(인스타·카톡), ContactSection.tsx:19(문의하기) — preventDefault 없는 생 <a target="_blank">.


검증

  • tsc --noEmit 통과 (exit 0)
  • npm run lint — 0 errors, 5 warnings 전부 기존 import/no-named-as-default
  • 토큰 주입 가드 7가지 (모아동만 주입, 인스타·Notion·서브도메인·깨진 URL 차단)
  • URL session_id 6가지 (모아동만 부착)
  • 내비게이션 판정 6가지 (외부 3종 → 브라우저, 내부 이동·intent:// 그대로)

실기기 확인

#39가 선행입니다. iOS는 푸시 탭이 화면 이동을 못 해 이 화면에 도달하지 못합니다.

  • 답장 푸시 탭 → 편지 상세에 내 편지가 보인다
  • Android 총동아리연합회 인스타 링크 탭 → 열린다 (지금은 무반응)
  • iOS 같은 링크 → 브라우저 시트로 열리고 주소에 session_id 없음
  • 홈 웹뷰에서 우체통 진입 회귀 없음
  • 모아동 내부 화면은 session_id가 그대로 붙어 웹 Mixpanel identify 동작

5. e9d9989 — 목적지를 url 파라미터로 받지 않는다

4번으로 앱 안에서 이 화면에 url을 넘기는 곳이 없어졌습니다(/webview/[slug] 호출 4곳 모두 path나 slug만 넘김). 그런데 받는 쪽이 남아 있으면 moadongapp://webview/x?url=https://... 딥링크로 임의 사이트를 이 화면에 띄울 수 있습니다. 이 화면은 onMessage가 붙어 있어 그 페이지가 앱 브리지를 그대로 쓰고, 사용자에겐 모아동 헤더가 붙은 화면으로 보입니다.

목적지를 path 또는 pageConfig로만 정합니다.

진입 결과
푸시 답장 (use-fcm, path=/feedback/letters/L1) moadong.com/feedback/letters/L1
홍보 (브리지, path) moadong.com/promotions/...
소개 (slug만, pageConfig) moadong.com/introduce
딥링크 ?url= 오류 화면
등록 안 된 slug 오류 화면 (기존 동작)

오리진 가드는 그대로 뒀습니다 — pageConfig에 외부 URL 항목(privacy-policy의 Notion 주소)이 남아 있어 baseUrl이 모아동이 아닐 수 있습니다.


남는 것

isUserInitiated가 false인 경로(서버 리다이렉트로 외부 이동)는 그대로 홈 웹뷰에 로드됩니다.

pageConfig['privacy-policy']의 Notion 주소는 도달 불가한 죽은 설정입니다 — 개인정보처리방침이 웹으로 이관됐고(be032f8e7), 여기로 들어가는 유일한 길인 requestNavigateWebview는 웹에 호출처가 0건입니다. 이 PR과 무관해서 건드리지 않았습니다.

우체통은 로그인 없이 쓰므로 익명 학생 토큰으로 사람을 구분한다. 웹은 앱이 주입한
토큰을 먼저 쓰고(studentFetch), 없으면 자기 토큰을 발급한다. 그러면 앱과 신원이
갈려서 백엔드가 Feedback.studentId로 찾는 편지함이 비어 보인다.

주입이 홈 웹뷰에만 있었다. 답장 푸시는 path=/feedback/letters/<id> 를 보내고
(FeedbackAdminService), use-fcm 은 /webview/clubDetail/ 로 시작하지 않는 path 를
/webview/[slug] 로 넘긴다. 그 화면에는 주입이 없어서, 답장 알림을 탭해 연 편지함이
방금 보낸 편지를 못 찾는다.

주입 스크립트 생성을 utils/webview.ts 로 올려 홈과 공유한다.

가드 기준을 진입 URL이 아니라 모아동 URL로 바꿨다. 기존 홈 코드는
new URL(BASE_URL).origin !== window.location.origin 으로 비교했는데, 홈은 BASE_URL 이
항상 모아동이라 맞지만 webview/[slug] 는 진입 URL 자체가 외부일 수 있어(slug=external)
"외부 == 외부" 로 통과해 버린다. 그대로 옮기면 외부 사이트에 베어러 토큰이 주입된다.

그래서 가드가 두 겹이다. RN 쪽에서 진입 URL이 모아동 오리진일 때만 스크립트를 만들고,
스크립트 안에서 실행 시점 origin 을 다시 본다(웹뷰가 나중에 외부로 이동하는 경우).
두 번째 비교는 웹뷰 안에서 한다 - RN 의 URL 폴리필이 호스트 대소문자와 기본 포트를
정규화하지 않아 RN 에서 만든 origin 문자열이 window.location.origin 과 어긋날 수 있다.

주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다.
외부 URL 진입이면 주입할 일이 없으니 기다리지 않는다.

club-detail-screen 에는 넣지 않았다. studentFetch 를 쓰는 페이지를 직접 열지 않는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 10 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 68a4abdc-e5ab-40bf-9dee-28562df21787

📥 Commits

Reviewing files that changed from the base of the PR and between 16ae7da and e9d9989.

📒 Files selected for processing (3)
  • app/webview/[slug].tsx
  • ui/home/home-webview-screen.tsx
  • utils/webview.ts

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.

두 증상이 한 경로에서 나온다. 홈 웹뷰에서 외부 링크를 탭하면
handleShouldStartLoadWithRequest 가 가로채 /webview/[slug] 로 넘기는데,
그 화면이 목적지를 가리지 않고 appendSessionId 를 붙였다.

iOS: session_id 는 웹 Mixpanel 의 distinct_id 다. 그게 쿼리에 실려 제3자
도메인으로 나가 상대 액세스 로그에 남는다. 모아동 오리진일 때만 붙인다.

Android: setSupportMultipleWindows 기본값이 true 라 target=_blank 가
onCreateWindow 로 간다. onOpenWindow 핸들러가 없으면 RNCWebChromeClient 가
WebViewClient 도 없는 new WebView(context) 를 만들어 transport 로 넘기는데,
그 뷰는 어떤 계층에도 붙지 않는다. 그래서 링크를 눌러도 아무 일이 안 일어난다.
false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은
경로가 된다.

둘을 같이 고친다. Android 링크만 살리면 session_id 가 나가는 경로가 Android
로도 번진다.

대상은 preventDefault 없는 생 <a target="_blank"> 들이다(ClubUnionPage 의
인스타·카톡, IntroducePage 의 문의하기). useNavigator 를 거치는 링크는
requestOpenExternalUrl -> WebBrowser 로 나가므로 원래 영향이 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seongwon030 and others added 2 commits September 22, 2026 16:56
request.url.startsWith(baseOrigin) 은 https://moadong.com.evil.com 을 내부 URL
로 승인한다. 그 페이지가 홈 웹뷰에 뜨면 window.ReactNativeWebView.postMessage
로 브리지를 그대로 쓸 수 있다. 이 웹뷰는 onMessage 에서 SUBSCRIBE_TOGGLE,
NAVIGATE_WEBVIEW, OPEN_EXTERNAL_URL, SHARE 를 origin 검증 없이 처리한다.

파싱한 origin 끼리 비교한다. #40 에서 추가한 isWebViewOrigin 을 그대로 쓴다.
주입 토큰 쪽은 원래 new URL(...).origin 으로 비교하고 있어 영향이 없었다.

setSupportMultipleWindows={false} 로 Android 의 target=_blank 요청도 이 검사에
들어오므로 같이 고친다. CodeRabbit 지적 반영.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
외부 사이트를 앱 화면(WebView)에 띄우고 있었다. 사용자에게는 모아동 헤더가
붙은 채로 뜨고 제목은 기본값 "웹페이지" 라 어느 사이트인지 알 단서가 없다.
주소창도 없다.

기술적으로 더 문제인 건 그 WebView 가 앱 프로세스 안이라는 것이다.
onMessage 가 붙어 있어 외부 페이지가 window.ReactNativeWebView.postMessage 로
SUBSCRIBE_TOGGLE, NAVIGATE_WEBVIEW, OPEN_EXTERNAL_URL 을 그대로 호출할 수 있다.

WebBrowser.openBrowserAsync 로 넘긴다. iOS SFSafariViewController,
Android Chrome Custom Tabs 라 OS 브라우저 프로세스에서 돌고 앱 JS 에 닿지
못한다. 도메인이 표시되고 시스템 브라우저 세션을 공유한다.

새로 도입하는 방식이 아니다. 배너(banner.tsx), 동아리 SNS(useNavigator ->
OPEN_EXTERNAL_URL), external-link.tsx 가 이미 같은 함수를 쓴다. slug=external
경로만 예외로 남아 있었다.

열기 실패는 로그만 남긴다. 실패해도 웹뷰 이동은 이미 취소된 상태라 조용히
아무 일도 안 일어나면 원인을 알 수 없다.

router 는 이 콜백에서 더 쓰이지 않아 deps 에서 뺀다. handleMessage 의 내부
라우팅에는 그대로 쓴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@seongwon030 seongwon030 changed the title fix: 푸시로 열리는 웹뷰에도 학생 토큰을 주입한다 fix: 푸시로 열리는 웹뷰의 학생 토큰 주입과 외부 링크 처리를 고친다 Sep 29, 2026
앞 커밋으로 홈의 외부 링크가 OS 브라우저로 가면서, 앱 안에서 이 화면에
url 파라미터를 넘기는 곳이 없어졌다. /webview/[slug] 로 가는 호출 4곳은 모두
path 나 slug 만 넘긴다.

그런데 파라미터를 받는 쪽이 남아 있으면 moadongapp://webview/x?url=https://...
딥링크로 임의 사이트를 이 화면에 띄울 수 있다. 이 화면은 onMessage 가 붙어
있어서 그 페이지가 window.ReactNativeWebView.postMessage 로 앱 브리지를
그대로 쓴다. 사용자에게는 모아동 헤더가 붙은 화면으로 보인다.

목적지를 path 또는 pageConfig 로만 정한다. 등록되지 않은 slug 는 기존 오류
화면으로 간다.

오리진 가드는 그대로 둔다. pageConfig 에 외부 URL 항목(privacy-policy 의
notion 주소)이 남아 있어 baseUrl 이 모아동이 아닐 수 있다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
seongwon030 added a commit that referenced this pull request Sep 30, 2026
…e-unknown-message-reporting

#40 과 충돌하는 두 파일을 미리 해소한다. 내용은 겹치지 않고 추가 위치만 겹쳤다.

- utils/webview.ts: 양쪽이 파일 끝에 서로 무관한 함수를 붙였다. #40 의 URL/오리진
  헬퍼(isWebViewOrigin, buildStudentTokenInjection)를 appendSessionId 뒤에 모으고,
  진단용 reportUnknownBridgeMessage 를 맨 끝에 뒀다.
- ui/home/home-webview-screen.tsx: import 한 줄만 충돌했다. 두 목록을 합쳤다.

양쪽 변경이 모두 살아 있는 것을 확인했다. #35 쪽은 네 지점의 리포팅
(home / club_detail / webview_slug / use-webview-message-handler), #40 쪽은
토큰 주입·session_id 오리진 가드·origin 비교·외부 링크 브라우저 전환·urlParam 제거.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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