Keep browser OAuth callbacks out of Android App Links

This commit is contained in:
SimpleTest 2026-08-09 10:10:12 +03:00
parent c97744a777
commit 510ad2898c
12 changed files with 201 additions and 2 deletions

View File

@ -44,6 +44,18 @@
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="${deepLinkHost}" />
<data android:path="/requests" />
<data android:pathPrefix="/requests/" />
<data android:path="/activities" />
<data android:pathPrefix="/activities/" />
<data android:path="/notifications" />
<data android:path="/reports" />
<data android:path="/users/log-in" />
<data android:path="/safety" />
<data android:path="/profile" />
<data android:pathPrefix="/people/" />
<data android:path="/leaderboard" />
<data android:path="/categories/proposals" />
</intent-filter>
</activity>
<service

View File

@ -0,0 +1,36 @@
package org.whoneedhelp.mobile;
import java.net.URI;
import java.net.URISyntaxException;
final class AppLinkRoutePolicy {
private AppLinkRoutePolicy() {}
static boolean allows(String value) {
if (value == null) {
return false;
}
try {
String path = new URI(value).getPath();
if (path == null) {
return false;
}
return path.equals("/requests")
|| path.startsWith("/requests/")
|| path.equals("/activities")
|| path.startsWith("/activities/")
|| path.equals("/notifications")
|| path.equals("/reports")
|| path.equals("/users/log-in")
|| path.equals("/safety")
|| path.equals("/profile")
|| path.startsWith("/people/")
|| path.equals("/leaderboard")
|| path.equals("/categories/proposals");
} catch (URISyntaxException exception) {
return false;
}
}
}

View File

@ -27,7 +27,7 @@ final class LaunchUrlResolver {
) {
if (deepLinkUrl != null) {
String appLink = trustedOrigin.canonicalAppLink(deepLinkUrl);
if (appLink != null) {
if (appLink != null && AppLinkRoutePolicy.allows(appLink)) {
return appLink;
}
}

View File

@ -0,0 +1,26 @@
package org.whoneedhelp.mobile;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class AppLinkRoutePolicyTest {
@Test
public void acceptsOnlyIntentionalApplicationRoutes() {
assertTrue(AppLinkRoutePolicy.allows("https://help.example/requests/123#messages"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/activities/456"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/users/log-in#token=secret"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/safety"));
assertTrue(AppLinkRoutePolicy.allows("https://help.example/people/123"));
assertFalse(
AppLinkRoutePolicy.allows(
"https://help.example/auth/google/callback?code=one-time&state=browser"
)
);
assertFalse(AppLinkRoutePolicy.allows("https://help.example/auth/social/google/callback"));
assertFalse(AppLinkRoutePolicy.allows("https://help.example/"));
assertFalse(AppLinkRoutePolicy.allows("not a URI"));
}
}

View File

@ -30,6 +30,14 @@ public final class LaunchUrlResolverTest {
false
)
);
assertNull(
LaunchUrlResolver.incomingUrl(
origin,
"https://help.example/auth/google/callback?code=one-time&state=browser",
null,
false
)
);
}
@Test

View File

@ -1,6 +1,25 @@
defmodule WhoNeedHelpWeb.AndroidAppLinksController do
use WhoNeedHelpWeb, :controller
# Android 15+ evaluates these server-side rules in order and treats an
# unmatched path as excluded. The Android manifest carries the same positive
# allowlist for Android 14 and earlier.
@dynamic_app_link_components [
%{"/" => "/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
}
}
}
])

View File

@ -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."

View File

@ -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

View File

@ -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"

View File

@ -569,7 +569,11 @@ REPORT
echo "App Link resolution omitted a required intent category." >&2
exit 1
fi
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

View File

@ -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"

View File

@ -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