관리자 WebView 보안 — URL 경계와 서버 권한을 함께 지키기
관리자 WebView 앱에서는 어떤 웹페이지를 앱 안에 남길지 먼저 정해야 합니다. 로그인한 화면의 경계와 서버의 권한 검사가 함께 유지되어야 하기 때문입니다.
앞 편에서는 기존 React 관리자와 API를 재사용하는 구조를 설명했습니다. 이번에는 그 구조에 넣은 방어를 정리합니다. 아래 내용은 구현한 설정과 검사 범위이며, WebView 앱의 보안을 모두 증명하는 점검표는 아닙니다.
URL은 문자열 접두사가 아닌 origin으로 비교했습니다
운영 주소가 https://admin.example.com이라고 해보겠습니다. startsWith()로 이 문자열이 앞에 있는지만 확인하면 https://admin.example.com.attacker.test처럼 다른 호스트인 주소를 잘못 허용할 수 있습니다. @ 앞의 사용자 정보와 실제 호스트를 혼동하는 경우도 있습니다.
이번에는 URL을 java.net.URI로 파싱한 뒤 scheme, host, 실제 적용 포트를 비교했습니다. 사용자 정보가 들어간 URL은 제외했습니다. Android의 Unsafe URI Loading 문서도 신뢰할 scheme과 host를 확인하도록 안내합니다.
다음은 프로젝트의 내부 주소 판정을 예제 도메인으로 옮겨 단독 실행할 수 있게 정리한 코드입니다. HTTPS의 기본 포트 443과 명시된 443을 같은 값으로 처리합니다.
import java.net.URI;
import java.net.URISyntaxException;
public final class OriginPolicy {
private final URI origin;
public OriginPolicy(String startUrl) {
origin = URI.create(startUrl);
}
public boolean isInternal(String url) {
try {
URI candidate = new URI(url);
return candidate.getRawUserInfo() == null
&& origin.getScheme().equals(candidate.getScheme())
&& origin.getHost().equals(candidate.getHost())
&& port(origin) == port(candidate);
} catch (URISyntaxException | NullPointerException error) {
return false;
}
}
private static int port(URI uri) {
if (uri.getPort() != -1) return uri.getPort();
return "https".equals(uri.getScheme()) ? 443 : 80;
}
public static void main(String[] args) {
OriginPolicy policy =
new OriginPolicy("https://admin.example.com/admin");
System.out.println(policy.isInternal(
"https://admin.example.com:443/login")); // true
System.out.println(policy.isInternal(
"https://admin.example.com.attacker.test/")); // false
}
}
위 코드를 OriginPolicy.java로 저장한 다음 JDK가 설치된 환경에서 실행할 수 있습니다. 두 줄의 결과는 차례로 true, false입니다.
javac OriginPolicy.java
java OriginPolicy
이 정책은 같은 origin 안에서 화면 이동을 허용하는 정책입니다. /admin 같은 특정 경로만을 보안 경계로 삼지는 않았습니다. 로그인 화면처럼 같은 origin의 다른 경로로도 이동해야 하기 때문입니다. 각 경로와 API를 사용할 수 있는지는 웹의 라우팅과 서버 권한 검사에 맡겼습니다.
외부 링크와 내부 화면의 실행 위치를 나눴습니다
WebViewClient.shouldOverrideUrlLoading()에서 최상위 화면 이동을 확인합니다. 같은 origin이면 WebView에서 이어서 처리합니다. 외부 HTTPS 링크는 사용자의 동작으로 발생한 경우 브라우저로 넘깁니다. 전화와 이메일 링크도 허용한 scheme만 외부 앱에 전달합니다.
javascript:, file:, 임의의 intent: 주소를 외부 실행 명령으로 받아들이지는 않습니다. 새 창 요청에서도 내부·외부 주소 정책을 다시 적용합니다. 또한 onPageStarted()에서 외부 origin의 최상위 페이지가 시작되면 로딩을 중단하도록 보완했습니다.
여기서 구분할 점이 있습니다. 이 코드는 최상위 페이지 이동의 경계를 다룹니다. 페이지 안의 모든 이미지·스크립트·API 요청을 도메인 허용 목록으로 검사하는 전체 네트워크 방화벽은 아닙니다. 웹 자체의 XSS 방어, CSP, 서버 검증은 여전히 필요합니다.
React를 실행하는 권한과 기기 기능을 노출하는 권한은 다릅니다
관리자 화면은 React 애플리케이션이므로 JavaScript와 DOM Storage를 켰습니다. 하지만 addJavascriptInterface()로 임의의 Java 메서드를 웹에 제공하는 범용 브리지는 만들지 않았습니다. 웹을 실행하기 위해 JavaScript가 필요하다는 사실이, 기기 기능까지 폭넓게 노출해야 한다는 뜻은 아닙니다.
파일 시스템과 content URL에 대한 WebView의 직접 접근은 끄고, HTTPS 페이지의 혼합 콘텐츠도 허용하지 않았습니다. Safe Browsing을 켰으며 release 빌드에서는 WebView 디버깅을 비활성화했습니다. Android의 WebView 안내에 나오는 네이티브 브리지 주의 사항도 이 판단의 참고가 됐습니다.
| 항목 | 이번 구현 |
|---|---|
| JavaScript·DOM Storage | 관리자 웹 실행에 사용 |
| JavaScript/native 범용 브리지 | 추가하지 않음 |
| 파일·content 직접 접근 | WebView 설정에서 차단 |
| 제3자 쿠키 | 차단 |
| TLS 인증서 오류 | 연결 취소 |
| release WebView 디버깅·백업 | 비활성화 |
인증서 오류는 통과시키지 않았습니다
인증서 오류가 발생했을 때 SslErrorHandler.proceed()를 호출하면 화면은 열릴 수 있습니다. 하지만 그 연결을 신뢰해도 되는지 확인하는 절차를 건너뛰게 됩니다. 관리자 앱에서는 로그인 정보와 업무 데이터를 다루므로, 이번 구현은 handler.cancel()로 연결을 취소했습니다.
운영 release는 HTTPS를 사용합니다. 로컬 테스트가 필요한 debug 빌드에서만 127.0.0.1의 HTTP를 허용했습니다. 개발 편의를 위한 예외가 운영 빌드의 네트워크 정책까지 넓히지 않도록 분리한 것입니다.
파일 선택은 넓은 저장소 접근과 분리했습니다
WebView에서 파일 입력을 누르면 onShowFileChooser()가 Android 파일 선택 화면을 엽니다. 새 선택을 시작할 때 이전 콜백을 정리하고, 취소하거나 앱을 찾을 수 없을 때도 결과를 명확히 돌려줍니다. 반환된 URI는 content:인지 확인합니다.
이 방식은 웹페이지가 기기 파일 경로를 자유롭게 읽도록 열어두는 것과 다릅니다. 다만 선택기가 돌려준 파일이라는 이유로 파일 내용까지 신뢰할 수는 없습니다. 업로드 가능한 형식·크기·사용자 권한은 기존 서버에서 다시 확인해야 합니다. 카메라 촬영과 추가 카메라 권한은 이번 버전에 넣지 않았습니다.
권한의 최종 판정은 계속 서버에 있습니다
특정 업무만 담당하는 계정에는 그 업무의 읽기·쓰기 권한만 부여하는 방식으로 접근했습니다. APK는 하나지만 계정마다 보이는 메뉴와 수행 가능한 요청이 다릅니다. 메뉴를 숨기는 것은 사용자의 동선을 정리하는 일이고, 서버가 권한 없는 요청을 거절하는 것은 데이터 접근을 막는 일입니다.
관리자 전용 화면도 같은 원칙을 따릅니다. 프론트에서 접근을 막는 것과 함께, 백엔드가 요청마다 현재 계정의 상태와 역할을 확인해야 합니다. 로그인 토큰에 남은 예전 역할만으로 권한을 판정하면, 계정의 권한을 회수해도 효과가 늦게 나타날 수 있기 때문입니다.
Android 셸은 이 권한 체계를 우회하지 않습니다. 웹의 토큰 갱신이 실패하면 앱에서도 다시 로그인해야 할 수 있습니다. 앱이 생겼다는 이유로 기존 인증 구현의 제약이 사라지는 것은 아니어서, 첫 버전 문서에도 이 한계를 남겼습니다.
URL 정책은 단위 테스트로, WebView 보안 설정은 로컬 화면을 띄운 계측 테스트로 확인했습니다. 서버의 권한 검증은 별도 웹·API 테스트가 담당합니다. 서로 다른 경계를 한 번의 “앱 실행 성공”으로 묶지 않는 것이 이번 작업에서 중요했습니다.
관련 글
관리자 웹을 Android APK로 옮기기 — WebView 셸의 역할
운영자용 관리자 웹을 Android APK로 만드는 과정을 정리했습니다. 기존 React 화면과 서버 권한을 유지하고, Java Activity와 WebView가 맡을 역할을 작게 정했습니다. 네이티브 앱·PWA와 비교한 선택 이유, 웹 배포와 APK 업데이트의 차이, 첫 버전에 남긴 제약을 함께 설명합니다.
직접 구축한 개발 블로그의 '관리자 페이지', 서버에 올리지 않은 이유
직접 개발한 블로그의 관리자 페이지를 서버에 올리지 않고 운영하는 독특한 방식을 소개합니다. 개인적인 작업 환경과 효율성, 서버 비용 절감을 위한 저자의 이유를 자세히 알아보세요.
PWA로 admin 페이지에 모바일 푸시 알림 붙이기
알림 하나 받자고 네이티브 앱을 만드는 건 오버킬이었습니다. 그래서 이미 쓰던 admin 웹앱에 PWA를 최소한으로 얹어 모바일 푸시 알림만 받기로 했습니다. iOS 설치형 제약과 푸시 전용 service worker, Slack fan-out까지 직접 부딪쳐 얻은 교훈을 담았습니다.