[Android] intent:// スキームとカスタムスキームの安全なハンドリング ― deep linkの落とし穴と対策

WebView を使っていて、リンクをタップしたのに「何も起こらない」、あるいは意図しないアプリが勝手に開いてしまった……という場面に出くわしたことはありませんか?その正体はたいてい、intent:// で始まる URL や、myapp:// のようなカスタムスキームのリンクです。

これらはブラウザや WebView にとっては「普通の Web リンク」ではないため、そのままでは正しく処理されません。しかも、扱い方を一歩間違えるとセキュリティホールになりかねない、少し厄介な存在でもあります。

この記事では、intent:// スキームとカスタムスキームの違いから、WebView での安全なハンドリング方法、そして見落としやすい落とし穴と対策までを、Kotlin のコード付きでまとめていきます。

スポンサーリンク

カスタムスキームと intent:// スキームの違い

まずは用語の整理からいきましょう。どちらも「アプリを起動するためのリンク」ですが、性質が少し異なります。

カスタムスキーム(例: myapp://)

アプリが独自に定義するスキームです。myapp://profile/123 のように、アプリ内の特定画面へ飛ばす deep link としてよく使われます。シンプルですが、そのスキームを扱えるアプリが未インストールだと何も起きない(リンクが「死ぬ」)という弱点があります。

intent:// スキーム

Chrome などが解釈できる、より高機能な仕組みです。起動したいアプリのパッケージ名や、アプリが無かったときの代替 URL(フォールバック)まで指定できます。構造は次のようになっています。

intent://<ホストやパス>#Intent;
  scheme=<スキーム名>;
  package=<パッケージ名>;
  S.browser_fallback_url=<URLエンコードした代替URL>;
end;

HTML のリンクとして書くと、こんなイメージです(サンプルなので値は架空のものです)。

<a href="intent://open/#Intent;scheme=sampleapp;package=com.example.sampleapp;S.browser_fallback_url=https%3A%2F%2Fexample.com%2Fapp;end">
  アプリで開く
</a>

ポイントは S.browser_fallback_url です。S. は文字列の Extra を意味する記法で、指定したアプリが見つからないときはこの URL に自動でフォールバックしてくれます。カスタムスキームの「未インストールで死ぬ」問題を解決できるのが、intent:// スキームの強みですね。

WebView での基本的なハンドリング

WebView 内でリンクがタップされたときの分岐は、WebViewClientshouldOverrideUrlLoading() で行います。まずは全体の骨組みを見てみましょう。

webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(
        view: WebView,
        request: WebResourceRequest
    ): Boolean {
        val uri = request.url
        return when (uri.scheme) {
            // 通常の Web ページは WebView にそのまま任せる
            "http", "https" -> false
            // intent:// スキームは専用処理へ
            "intent" -> handleIntentScheme(uri).let { true }
            // それ以外(カスタムスキーム)も専用処理へ
            else -> handleCustomScheme(uri).let { true }
        }
    }
}

http / https のときだけ false を返して WebView に描画を任せ、それ以外は true を返して「自分で処理したよ」と伝えるのが基本形です。true を返さないと、WebView が intent:// をそのまま読み込もうとして ERR_UNKNOWN_URL_SCHEME エラーになってしまうので注意してください。

まずは「危険な実装」を知っておく

対策を理解するために、あえてやってはいけない書き方を先に見ておきます。ネット上のサンプルでもよく見かける形です。

// ⚠️ 危険:絶対に真似しないこと
private fun handleIntentScheme(uri: Uri) {
    val intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME)
    startActivity(intent) // パースした Intent を無条件で起動している
}

一見動きますし、実際に外部アプリも開きます。ですが Intent.parseUri() は、URL 文字列の中に起動先コンポーネントの指定を埋め込むことを許してしまうのです。

つまり、WebView に表示した Web ページ(あるいは XSS や中間者攻撃で差し込まれた内容)が、あなたのアプリの本来は外部非公開(exported=false)なはずの Activity を直接起動できてしまう可能性があります。これは「Intent リダイレクション」と呼ばれる典型的な脆弱性で、認証画面や内部情報を持つ画面が攻撃者に叩かれる入り口になり得ます。

安全な intent:// ハンドリングの実装

ではどう直すか。ポイントは、パースした Intent から「起動先を明示的に指定している情報」をすべて剥ぎ取ることです。順番に対策を積み上げていきます。

対策1: BROWSABLE カテゴリを付ける

ブラウザから起動して良いと明示されている(android.intent.category.BROWSABLE を持つ)Activity だけを対象にします。これにより、外部起動を想定していない画面が除外されます。

対策2: component と selector を null にする

ここが一番の肝です。setComponent(null) で明示的なコンポーネント指定を消すのは有名ですが、それだけでは不十分です。Intent には「セレクタ(SEL)」という別のルートがあり、これを使うと BROWSABLE を持たない非公開 Activity すら指定できてしまいます。そのため setSelector(null) も必ずセットで行います。

private fun handleIntentScheme(uri: Uri) {
    val intent = try {
        Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME)
    } catch (e: URISyntaxException) {
        return // パースできない不正な URL は無視する
    }
 
    // --- ここからサニタイズ(無害化) ---
    // ブラウザから起動して良い Activity に限定する
    intent.addCategory(Intent.CATEGORY_BROWSABLE)
    // 明示的なコンポーネント指定を削除
    intent.setComponent(null)
    // セレクタ経由の指定も削除(SEL バイパス対策)
    intent.selector = null
 
    launchExternalApp(intent, uri)
}

なお Intent.parseUri()URI_ALLOW_UNSAFE フラグは付けないでください。これを付けると、URI 権限付与フラグ(FLAG_GRANT_READ_URI_PERMISSION など)まで通ってしまい、ContentProvider への不正アクセスを許す恐れがあります。デフォルト(URI_INTENT_SCHEME のみ)ならこれらのフラグは無視されるので安全です。

対策3: 起動前に「本当に開けるか」を確認する

サニタイズした Intent を、いきなり startActivity() するのではなく、解決できるアプリが存在するかをチェックしてから起動します。存在しなければ、フォールバック URL へ逃がします。

private fun launchExternalApp(intent: Intent, originalUri: Uri) {
    // 起動できるアプリがあるか確認
    val canResolve = intent.resolveActivity(packageManager) != null
    if (canResolve) {
        try {
            startActivity(intent)
            return
        } catch (e: ActivityNotFoundException) {
            // 解決できたはずでも起動に失敗するケースの保険
        }
    }
    // アプリが無い / 起動できない場合はフォールバックへ
    openFallback(intent, originalUri)
}

対策4: browser_fallback_url で優雅に着地させる

intent:// には browser_fallback_url を埋め込めましたね。パース後の Intent からこの Extra を取り出し、あればその URL を WebView(または通常ブラウザ)で開いてあげます。アプリ未インストールでも「何も起きない」を防げます。

private fun openFallback(intent: Intent, originalUri: Uri) {
    val fallbackUrl = intent.getStringExtra("browser_fallback_url")
    if (!fallbackUrl.isNullOrEmpty()) {
        // 念のため http(s) スキームだけを許可する
        val fallbackUri = Uri.parse(fallbackUrl)
        if (fallbackUri.scheme == "http" || fallbackUri.scheme == "https") {
            webView.loadUrl(fallbackUrl)
            return
        }
    }
    // フォールバックも無ければ、ユーザーに軽く知らせる程度に留める
    Toast.makeText(this, "対応するアプリが見つかりませんでした", Toast.LENGTH_SHORT).show()
}

フォールバック URL であっても、スキームを http(s) に限定しているのがポイントです。フォールバックと見せかけて別のカスタムスキームや javascript: を差し込む、といった二段構えの攻撃を防げます。

カスタムスキーム(myapp://)を受け取る側の実装

ここまでは「WebView からリンクを送り出す側」の話でした。逆に、自分のアプリを myapp://受け取る側の設定も見ておきましょう。AndroidManifest.xml に intent-filter を定義します。

<activity
    android:name=".DeepLinkActivity"
    android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="sampleapp" />
    </intent-filter>
</activity>

受け取り側の Activity では、飛んできた URI をパースしてパラメータを取り出します。ここでも入力を鵜呑みにしないのが大切です。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
 
    val data: Uri? = intent?.data
    if (data != null && data.scheme == "sampleapp") {
        // 例: sampleapp://profile/123 から ID を取り出す
        val userId = data.lastPathSegment
        // 取り出した値は必ず検証してから使う(数値か、想定の形式か等)
        if (userId?.all { it.isDigit() } == true) {
            navigateToProfile(userId)
        } else {
            navigateToHome() // 不正な値なら安全な画面へ
        }
    }
}

deep link から渡ってくる値は外部から自由に指定できることを忘れずに。ID の形式チェックはもちろん、この値を SQL や WebView の loadUrl にそのまま流し込まないよう気をつけましょう。

ハマりやすいポイントまとめ

  • true の返し忘れで ERR_UNKNOWN_URL_SCHEME になる(http/https 以外は自分で処理して true を返す)
  • setComponent(null) だけで満足してしまう(setSelector(null) もセットで)
  • URI_ALLOW_UNSAFE を安易に付けてしまう(基本は不要)
  • parseUri()URISyntaxException を握らずクラッシュ(不正 URL は無視する)
  • フォールバック URL のスキームを検証していない(http(s) に限定する)
  • deep link のパラメータを検証せず使ってしまう

まとめ

intent:// スキームとカスタムスキームは、Web とネイティブアプリをつなぐ便利な仕組みです。ただ WebView で扱うときは、「外から来た文字列をそのまま Intent に変えて起動しない」という一点を守るだけで、多くの脆弱性を避けられます。

具体的には、BROWSABLE カテゴリの付与、componentselector の null 化、起動可否のチェック、そして browser_fallback_url での優雅な着地。この4つをテンプレートとして持っておくと安心です。

WebView と外部連携まわりは他にもハマりどころが多い領域なので、また別の記事で少しずつ掘り下げていければと思います。今日の内容が、あなたの実装のちょっとした助けになればうれしいです。

コメント

タイトルとURLをコピーしました