diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e94aa22..10be8b7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,6 +44,18 @@ + + + + + + + + + + + + "/requests"}, + %{"/" => "/requests/*"}, + %{"/" => "/activities"}, + %{"/" => "/activities/*"}, + %{"/" => "/notifications"}, + %{"/" => "/reports"}, + %{"/" => "/users/log-in"}, + %{"/" => "/safety"}, + %{"/" => "/profile"}, + %{"/" => "/people/*"}, + %{"/" => "/leaderboard"}, + %{"/" => "/categories/proposals"}, + %{"/" => "*", "exclude" => true} + ] + def show(conn, _params) do case Application.get_env(:who_need_help, :android_app_links) do %{ @@ -16,6 +35,11 @@ defmodule WhoNeedHelpWeb.AndroidAppLinksController do namespace: "android_app", package_name: package_name, sha256_cert_fingerprints: fingerprints + }, + relation_extensions: %{ + "delegate_permission/common.handle_all_urls" => %{ + dynamic_app_link_components: @dynamic_app_link_components + } } } ]) diff --git a/scripts/android-app-links-verify.sh b/scripts/android-app-links-verify.sh index 36d0d8c..bfa41b9 100755 --- a/scripts/android-app-links-verify.sh +++ b/scripts/android-app-links-verify.sh @@ -106,6 +106,21 @@ if [[ "$online_mode" == --online ]]; then (.target.sha256_cert_fingerprints | index($fingerprint)) != null ) ' <<<"$payload" >/dev/null + + jq -e ' + any( + .[]; + ( + .relation_extensions["delegate_permission/common.handle_all_urls"] + .dynamic_app_link_components + ) as $rules + | ($rules | type == "array" and length > 0) + and any($rules[]; .["/"] == "/safety" and (.exclude // false) == false) + and any($rules[]; .["/"] == "/requests/*" and (.exclude // false) == false) + and any($rules[]; .["/"] == "*" and .exclude == true) + and all($rules[]; ((.["/"] // "") | startswith("/auth")) | not) + ) + ' <<<"$payload" >/dev/null fi echo "Android package, signing certificate, and App Links configuration agree." diff --git a/scripts/android-play-policy-check.sh b/scripts/android-play-policy-check.sh index 46149d4..f48b799 100755 --- a/scripts/android-play-policy-check.sh +++ b/scripts/android-play-policy-check.sh @@ -44,6 +44,23 @@ require_literal \ 'android:foregroundServiceType="location"' \ 'TrackingService is not declared as a location foreground service' +for route in \ + 'android:path="/requests"' \ + 'android:pathPrefix="/requests/"' \ + 'android:path="/activities"' \ + 'android:pathPrefix="/activities/"' \ + 'android:path="/users/log-in"' \ + 'android:path="/safety"'; do + require_literal "$manifest" "$route" \ + "the verified App Link allowlist is missing $route" +done + +if grep -Fq 'android:pathPrefix="/auth' "$manifest" || + grep -Fq 'android:path="/auth' "$manifest"; then + echo "Android Play policy check failed: browser authentication callbacks are claimed as App Links." >&2 + exit 1 +fi + if grep -Fq 'android.permission.ACCESS_BACKGROUND_LOCATION' "$manifest"; then echo "Android Play policy check failed: ACCESS_BACKGROUND_LOCATION was added." >&2 echo "The reviewed flow starts a user-visible location foreground service from the foreground; adding background permission requires a new policy and product review." >&2 diff --git a/scripts/android-staging-smoke.sh b/scripts/android-staging-smoke.sh index 683aab5..01eee2a 100755 --- a/scripts/android-staging-smoke.sh +++ b/scripts/android-staging-smoke.sh @@ -228,6 +228,7 @@ fi same_origin="$WNH_BASE_URL/safety" external_origin=https://example.com/ +browser_callback="$WNH_BASE_URL/auth/google/callback?code=verification&state=browser" if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then echo "The $variant APK does not declare its exact HTTPS host." >&2 @@ -272,6 +273,13 @@ docker exec "$container" adb shell cmd package resolve-activity --brief \ -c android.intent.category.BROWSABLE \ -d "$external_origin" >"$output/external-origin-resolver.txt" +docker exec "$container" adb shell cmd package resolve-activity --brief \ + --user 0 \ + -a android.intent.action.VIEW \ + -c android.intent.category.DEFAULT \ + -c android.intent.category.BROWSABLE \ + -d "$browser_callback" >"$output/browser-callback-resolver.txt" + if ! grep -Fq "Activity: $package/$activity" "$output/same-origin-app-link-start.txt"; then echo "The verified $variant App Link did not open the application." >&2 exit 1 @@ -282,6 +290,11 @@ if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then exit 1 fi +if grep -Fq "$package/" "$output/browser-callback-resolver.txt"; then + echo "The $variant APK incorrectly claimed the browser-only Google OAuth callback." >&2 + exit 1 +fi + docker exec "$container" adb logcat -c docker exec "$container" adb shell am start -W \ -n "$package/$activity" >"$output/home-start.txt" diff --git a/scripts/quality.sh b/scripts/quality.sh index 62d1682..1963a7b 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -569,7 +569,11 @@ REPORT echo "App Link resolution omitted a required intent category." >&2 exit 1 fi - printf '%s\n' "${FAKE_PLAY_ACTIVITY:-org.whoneedhelp.mobile/.MainActivity}" + if [[ " $* " == *"/auth/google/callback"* ]]; then + printf '%s\n' "${FAKE_PLAY_CALLBACK_ACTIVITY:-com.android.browser/.BrowserActivity}" + else + printf '%s\n' "${FAKE_PLAY_ACTIVITY:-org.whoneedhelp.mobile/.MainActivity}" + fi ;; *) printf 'unexpected adb command: %s\n' "$*" >&2 @@ -628,6 +632,15 @@ if WNH_ADB_BIN="$fake_play_adb" \ exit 1 fi +if WNH_ADB_BIN="$fake_play_adb" \ + FAKE_PLAY_SIGNATURE="$play_sha256_one_colon" \ + FAKE_PLAY_CALLBACK_ACTIVITY=org.whoneedhelp.mobile/.MainActivity \ + ./scripts/verify-play-installed-android.sh \ + "$play_identities" quality-play-device 1 0.1.0 >/dev/null 2>&1; then + echo "Play-installed verifier accepted an Android-claimed browser OAuth callback." >&2 + exit 1 +fi + echo "Checking Android environment isolation" ./scripts/android-play-policy-check.sh >/dev/null android_fingerprint=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF diff --git a/scripts/verify-play-installed-android.sh b/scripts/verify-play-installed-android.sh index abecd6b..b220c95 100755 --- a/scripts/verify-play-installed-android.sh +++ b/scripts/verify-play-installed-android.sh @@ -26,6 +26,7 @@ expected_version_name=$4 package_name=org.whoneedhelp.mobile app_link_host=whoneedhelp.com app_link_url=https://whoneedhelp.com/safety +browser_callback_url='https://whoneedhelp.com/auth/google/callback?code=verification&state=browser' expected_activity=org.whoneedhelp.mobile/.MainActivity adb_bin=${WNH_ADB_BIN:-adb} @@ -177,9 +178,23 @@ if ! grep -Fx "$expected_activity" <<<"$resolved_activity" >/dev/null; then exit 1 fi +callback_activity=$( + adb_device shell cmd package resolve-activity --brief \ + -a android.intent.action.VIEW \ + -c android.intent.category.DEFAULT \ + -c android.intent.category.BROWSABLE \ + -d "$browser_callback_url" | + tr -d '\r' +) +if grep -Fx "$expected_activity" <<<"$callback_activity" >/dev/null; then + echo "The browser-only Google OAuth callback is incorrectly claimed by the Android app." >&2 + exit 1 +fi + echo "Google Play installed Android verification passed." echo "Package: $package_name" echo "Version: $observed_version_name ($observed_version_code)" echo "Installer: Google Play" echo "Signing identity: supplied Play App Signing set member" echo "App Link: $app_link_host verified and resolved to MainActivity" +echo "Browser OAuth callback: not claimed by MainActivity" diff --git a/test/who_need_help_web/controllers/android_app_links_controller_test.exs b/test/who_need_help_web/controllers/android_app_links_controller_test.exs index df3ddf4..743dddf 100644 --- a/test/who_need_help_web/controllers/android_app_links_controller_test.exs +++ b/test/who_need_help_web/controllers/android_app_links_controller_test.exs @@ -40,6 +40,26 @@ defmodule WhoNeedHelpWeb.AndroidAppLinksControllerTest do "sha256_cert_fingerprints" => [fingerprint] } + assert get_in(statement, [ + "relation_extensions", + "delegate_permission/common.handle_all_urls", + "dynamic_app_link_components" + ]) == [ + %{"/" => "/requests"}, + %{"/" => "/requests/*"}, + %{"/" => "/activities"}, + %{"/" => "/activities/*"}, + %{"/" => "/notifications"}, + %{"/" => "/reports"}, + %{"/" => "/users/log-in"}, + %{"/" => "/safety"}, + %{"/" => "/profile"}, + %{"/" => "/people/*"}, + %{"/" => "/leaderboard"}, + %{"/" => "/categories/proposals"}, + %{"/" => "*", "exclude" => true} + ] + assert get_resp_header(conn, "cache-control") == ["public, max-age=300"] end end