From 9498f9fc1cf5e0e6f9eb01e7730041f83e52ccfa Mon Sep 17 00:00:00 2001 From: Dome Pongmongkol Date: Wed, 29 Apr 2026 15:46:12 -0700 Subject: [PATCH 1/7] handle app_link Intent Redirection --- .../providers/BrokerInstallLinkValidator.kt | 164 +++++++++ .../providers/RawAuthorizationResult.java | 7 + .../BrokerInstallLinkValidatorTest.kt | 311 ++++++++++++++++++ .../common/java/providers/Constants.java | 5 + .../providers/RawAuthorizationResultTest.java | 51 +++ 5 files changed, 538 insertions(+) create mode 100644 common4j/src/main/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidator.kt create mode 100644 common4j/src/test/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidatorTest.kt diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidator.kt b/common4j/src/main/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidator.kt new file mode 100644 index 0000000000..382496a1bf --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidator.kt @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.providers + +import java.io.UnsupportedEncodingException +import java.net.URI +import java.net.URISyntaxException +import java.net.URLDecoder + +/** + * Strict allowlist validator for the `app_link` query-parameter value carried on + * a broker-installation redirect URI (`msauth://...?app_link=`). + * + * The classifier in [RawAuthorizationResult.fromRedirectUri] uses this to decide + * whether to return [RawAuthorizationResult.ResultCode.BROKER_INSTALLATION_TRIGGERED] + * (which downstream Android sinks turn into `startActivity(ACTION_VIEW, app_link)`). + * + * The Microsoft identity service (eSTS) only ever emits one of three concrete + * `app_link` values today: + * 1. `https://play.google.com/store/apps/details?id=com.azure.authenticator` + * 2. `https://play.google.com/store/apps/details?id=com.microsoft.windowsintune.companyportal` + * 3. `https://go.microsoft.com/fwlink/?linkid=2134649` (China Company Portal) + * Each may carry an optional `referrer` parameter set by the server. + */ +object BrokerInstallLinkValidator { + + private const val SCHEME_HTTPS = "https" + private const val HOST_PLAY = "play.google.com" + private const val HOST_FWLINK = "go.microsoft.com" + private const val PATH_PLAY = "/store/apps/details" + private const val PATH_FWLINK = "/fwlink" + private const val PATH_FWLINK_TRAILING = "/fwlink/" + + private const val PARAM_ID = "id" + private const val PARAM_LINKID = "linkid" + private const val PARAM_REFERRER = "referrer" + + private val ALLOWED_PACKAGE_IDS = setOf( + "com.azure.authenticator", + "com.microsoft.windowsintune.companyportal" + ) + + private val ALLOWED_FWLINK_IDS = setOf("2134649") + + /** + * @return `true` iff [url] decodes to one of the allowlisted broker-install + * destinations defined above; `false` for any other input (including null, + * blank, malformed, or attacker-controlled values). + */ + @JvmStatic + fun isSafeBrokerInstallLink(url: String?): Boolean { + if (url.isNullOrBlank()) return false + + val uri: URI = try { + URI(url) + } catch (e: URISyntaxException) { + return false + } + + // Scheme must be exactly https (case-insensitive). + if (!SCHEME_HTTPS.equals(uri.scheme, ignoreCase = true)) return false + + // Reject embedded credentials, fragments, and non-default ports. + if (uri.rawUserInfo != null) return false + if (uri.rawFragment != null) return false + if (uri.port != -1) return false + + val host = uri.host?.lowercase() ?: return false + val path = uri.rawPath ?: return false + val params = parseQuery(uri.rawQuery) ?: return false + + return when (host) { + HOST_PLAY -> isValidPlayLink(path, params) + HOST_FWLINK -> isValidFwlink(path, params) + else -> false + } + } + + private fun isValidPlayLink(path: String, params: Map): Boolean { + if (path != PATH_PLAY) return false + val id = params[PARAM_ID] ?: return false + if (id !in ALLOWED_PACKAGE_IDS) return false + return hasOnlyAllowedExtras(params, PARAM_ID) + } + + private fun isValidFwlink(path: String, params: Map): Boolean { + if (path != PATH_FWLINK && path != PATH_FWLINK_TRAILING) return false + val linkId = params[PARAM_LINKID] ?: return false + if (linkId !in ALLOWED_FWLINK_IDS) return false + return hasOnlyAllowedExtras(params, PARAM_LINKID) + } + + private fun hasOnlyAllowedExtras(params: Map, requiredKey: String): Boolean { + for (key in params.keys) { + if (key == requiredKey) continue + if (key == PARAM_REFERRER) continue + return false + } + return true + } + + /** + * Parse a URI query string into a key/value map. + * + * Returns `null` if any key appears more than once — this defends against + * parameter-smuggling attacks such as `?id=safe&id=evil` where a permissive + * parser might pick the wrong value. + */ + private fun parseQuery(rawQuery: String?): Map? { + if (rawQuery.isNullOrEmpty()) return emptyMap() + val out = LinkedHashMap() + for (pair in rawQuery.split('&')) { + if (pair.isEmpty()) return null + val eq = pair.indexOf('=') + val rawKey: String + val rawValue: String + if (eq < 0) { + rawKey = pair + rawValue = "" + } else { + rawKey = pair.substring(0, eq) + rawValue = pair.substring(eq + 1) + } + if (rawKey.isEmpty()) return null + val key = try { + URLDecoder.decode(rawKey, "UTF-8") + } catch (e: UnsupportedEncodingException) { + return null + } catch (e: IllegalArgumentException) { + return null + } + val value = try { + URLDecoder.decode(rawValue, "UTF-8") + } catch (e: UnsupportedEncodingException) { + return null + } catch (e: IllegalArgumentException) { + return null + } + if (out.containsKey(key)) return null + out[key] = value + } + return out + } +} diff --git a/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java b/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java index 47f30c6402..e184927845 100644 --- a/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java +++ b/common4j/src/main/com/microsoft/identity/common/java/providers/RawAuthorizationResult.java @@ -225,6 +225,13 @@ private static ResultCode getResultCodeFromFinalRedirectUri(@NonNull final URI u // i.e. (Browser) msauth://com.msft.identity.client.sample.local/1wIqXSqBj7w%2Bh11ZifsnqwgyKrY%3D?wpj=1&username=idlab1%40msidlab4.onmicrosoft.com&app_link=https%3a%2f%2fplay.google.com%2fstore%2fapps%2fdetails%3fid%3dcom.azure.authenticator // (WebView) msauth://wpj/?username=idlab1%40msidlab4.onmicrosoft.com&app_link=https%3a%2f%2fplay.google.com%2fstore%2fapps%2fdetails%3fid%3dcom.azure.authenticator%26referrer%3dcom.msft.identity.client.sample.local if (parameters.containsKey(APP_LINK_KEY)) { + // Only the eSTS-emitted Play Store and China fwlink targets + // are accepted; anything else is treated as a malformed redirect. + final String appLink = parameters.get(APP_LINK_KEY); + if (!BrokerInstallLinkValidator.isSafeBrokerInstallLink(appLink)) { + Logger.warn(methodTag, "Rejected app_link that is not on the broker-install allowlist; treating redirect as malformed."); + throw new URISyntaxException(uri.toString(), "app_link rejected by allowlist"); + } Logger.info(methodTag, "Return to caller with BROWSER_CODE_WAIT_FOR_BROKER_INSTALL, and waiting for result."); return ResultCode.BROKER_INSTALLATION_TRIGGERED; } diff --git a/common4j/src/test/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidatorTest.kt b/common4j/src/test/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidatorTest.kt new file mode 100644 index 0000000000..ecc58b47a7 --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/providers/BrokerInstallLinkValidatorTest.kt @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.providers + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrokerInstallLinkValidatorTest { + + // region Positive cases + + @Test + fun acceptsAuthenticatorPlayLink() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "https://play.google.com/store/apps/details?id=com.azure.authenticator" + ) + ) + } + + @Test + fun acceptsCompanyPortalPlayLink() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "https://play.google.com/store/apps/details?id=com.microsoft.windowsintune.companyportal" + ) + ) + } + + @Test + fun acceptsPlayLinkWithReferrer() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "https://play.google.com/store/apps/details?id=com.azure.authenticator&referrer=com.contoso.app" + ) + ) + } + + @Test + fun acceptsChinaFwlinkWithTrailingSlash() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "https://go.microsoft.com/fwlink/?linkid=2134649" + ) + ) + } + + @Test + fun acceptsChinaFwlinkWithoutTrailingSlash() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "https://go.microsoft.com/fwlink?linkid=2134649" + ) + ) + } + + @Test + fun acceptsHttpsCaseInsensitiveScheme() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "HTTPS://play.google.com/store/apps/details?id=com.azure.authenticator" + ) + ) + } + + @Test + fun acceptsMixedCaseHost() { + assertTrue( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "https://Play.Google.Com/store/apps/details?id=com.azure.authenticator" + ) + ) + } + + // endregion + + // region Negative cases - input shape + + @Test + fun rejectsNull() { + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink(null)) + } + + @Test + fun rejectsEmpty() { + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink("")) + } + + @Test + fun rejectsWhitespace() { + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink(" ")) + } + + @Test + fun rejectsMalformedUri() { + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink(":/")) + } + + // endregion + + // region Negative cases - scheme + + @Test + fun rejectsHttpScheme() { + assertFalse( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "http://play.google.com/store/apps/details?id=com.azure.authenticator" + ) + ) + } + + @Test + fun rejectsJavascriptScheme() { + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink("javascript:alert(1)")) + } + + @Test + fun rejectsIntentScheme() { + assertFalse( + BrokerInstallLinkValidator.isSafeBrokerInstallLink( + "intent://x#Intent;scheme=https;end" + ) + ) + } + + @Test + fun rejectsFileScheme() { + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink("file:///etc/passwd")) + } + + @Test + fun rejectsDataScheme() { + // data: with literal '<' is not even a valid URI; should be rejected without throwing. + assertFalse(BrokerInstallLinkValidator.isSafeBrokerInstallLink("data:text/html,