Keep browser OAuth callbacks out of Android App Links
This commit is contained in:
parent
c97744a777
commit
510ad2898c
|
|
@ -44,6 +44,18 @@
|
||||||
<data android:scheme="http" />
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
<data android:host="${deepLinkHost}" />
|
<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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<service
|
<service
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ final class LaunchUrlResolver {
|
||||||
) {
|
) {
|
||||||
if (deepLinkUrl != null) {
|
if (deepLinkUrl != null) {
|
||||||
String appLink = trustedOrigin.canonicalAppLink(deepLinkUrl);
|
String appLink = trustedOrigin.canonicalAppLink(deepLinkUrl);
|
||||||
if (appLink != null) {
|
if (appLink != null && AppLinkRoutePolicy.allows(appLink)) {
|
||||||
return appLink;
|
return appLink;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,14 @@ public final class LaunchUrlResolverTest {
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
assertNull(
|
||||||
|
LaunchUrlResolver.incomingUrl(
|
||||||
|
origin,
|
||||||
|
"https://help.example/auth/google/callback?code=one-time&state=browser",
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,25 @@
|
||||||
defmodule WhoNeedHelpWeb.AndroidAppLinksController do
|
defmodule WhoNeedHelpWeb.AndroidAppLinksController do
|
||||||
use WhoNeedHelpWeb, :controller
|
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
|
def show(conn, _params) do
|
||||||
case Application.get_env(:who_need_help, :android_app_links) do
|
case Application.get_env(:who_need_help, :android_app_links) do
|
||||||
%{
|
%{
|
||||||
|
|
@ -16,6 +35,11 @@ defmodule WhoNeedHelpWeb.AndroidAppLinksController do
|
||||||
namespace: "android_app",
|
namespace: "android_app",
|
||||||
package_name: package_name,
|
package_name: package_name,
|
||||||
sha256_cert_fingerprints: fingerprints
|
sha256_cert_fingerprints: fingerprints
|
||||||
|
},
|
||||||
|
relation_extensions: %{
|
||||||
|
"delegate_permission/common.handle_all_urls" => %{
|
||||||
|
dynamic_app_link_components: @dynamic_app_link_components
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,21 @@ if [[ "$online_mode" == --online ]]; then
|
||||||
(.target.sha256_cert_fingerprints | index($fingerprint)) != null
|
(.target.sha256_cert_fingerprints | index($fingerprint)) != null
|
||||||
)
|
)
|
||||||
' <<<"$payload" >/dev/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
|
fi
|
||||||
|
|
||||||
echo "Android package, signing certificate, and App Links configuration agree."
|
echo "Android package, signing certificate, and App Links configuration agree."
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,23 @@ require_literal \
|
||||||
'android:foregroundServiceType="location"' \
|
'android:foregroundServiceType="location"' \
|
||||||
'TrackingService is not declared as a location foreground service'
|
'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
|
if grep -Fq 'android.permission.ACCESS_BACKGROUND_LOCATION' "$manifest"; then
|
||||||
echo "Android Play policy check failed: ACCESS_BACKGROUND_LOCATION was added." >&2
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,7 @@ fi
|
||||||
|
|
||||||
same_origin="$WNH_BASE_URL/safety"
|
same_origin="$WNH_BASE_URL/safety"
|
||||||
external_origin=https://example.com/
|
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
|
if ! grep -Fq "Authority: \"$expected_host\"" "$output/package.txt"; then
|
||||||
echo "The $variant APK does not declare its exact HTTPS host." >&2
|
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 \
|
-c android.intent.category.BROWSABLE \
|
||||||
-d "$external_origin" >"$output/external-origin-resolver.txt"
|
-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
|
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
|
echo "The verified $variant App Link did not open the application." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -282,6 +290,11 @@ if grep -Fq "$package/" "$output/external-origin-resolver.txt"; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 logcat -c
|
||||||
docker exec "$container" adb shell am start -W \
|
docker exec "$container" adb shell am start -W \
|
||||||
-n "$package/$activity" >"$output/home-start.txt"
|
-n "$package/$activity" >"$output/home-start.txt"
|
||||||
|
|
|
||||||
|
|
@ -569,7 +569,11 @@ REPORT
|
||||||
echo "App Link resolution omitted a required intent category." >&2
|
echo "App Link resolution omitted a required intent category." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
printf 'unexpected adb command: %s\n' "$*" >&2
|
||||||
|
|
@ -628,6 +632,15 @@ if WNH_ADB_BIN="$fake_play_adb" \
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
echo "Checking Android environment isolation"
|
||||||
./scripts/android-play-policy-check.sh >/dev/null
|
./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
|
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
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ expected_version_name=$4
|
||||||
package_name=org.whoneedhelp.mobile
|
package_name=org.whoneedhelp.mobile
|
||||||
app_link_host=whoneedhelp.com
|
app_link_host=whoneedhelp.com
|
||||||
app_link_url=https://whoneedhelp.com/safety
|
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
|
expected_activity=org.whoneedhelp.mobile/.MainActivity
|
||||||
adb_bin=${WNH_ADB_BIN:-adb}
|
adb_bin=${WNH_ADB_BIN:-adb}
|
||||||
|
|
||||||
|
|
@ -177,9 +178,23 @@ if ! grep -Fx "$expected_activity" <<<"$resolved_activity" >/dev/null; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 "Google Play installed Android verification passed."
|
||||||
echo "Package: $package_name"
|
echo "Package: $package_name"
|
||||||
echo "Version: $observed_version_name ($observed_version_code)"
|
echo "Version: $observed_version_name ($observed_version_code)"
|
||||||
echo "Installer: Google Play"
|
echo "Installer: Google Play"
|
||||||
echo "Signing identity: supplied Play App Signing set member"
|
echo "Signing identity: supplied Play App Signing set member"
|
||||||
echo "App Link: $app_link_host verified and resolved to MainActivity"
|
echo "App Link: $app_link_host verified and resolved to MainActivity"
|
||||||
|
echo "Browser OAuth callback: not claimed by MainActivity"
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,26 @@ defmodule WhoNeedHelpWeb.AndroidAppLinksControllerTest do
|
||||||
"sha256_cert_fingerprints" => [fingerprint]
|
"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"]
|
assert get_resp_header(conn, "cache-control") == ["public, max-age=300"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user