관리자 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 테스트가 담당합니다. 서로 다른 경계를 한 번의 “앱 실행 성공”으로 묶지 않는 것이 이번 작업에서 중요했습니다.

AndroidWebView보안인증권한 관리