Skip to content
Merged
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
vNext
----------
- [PATCH] Handle app_link Intent redirection by validating broker install links and rejecting unsupported redirect URIs with appropriate error responses (#3102)
- [PATCH] Extend filter-then-clone optimization to load() and getIdTokensForAccountRecord() in MsalOAuth2TokenCache: when ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE flight is enabled, skip clone-all preload and call direct flight-gated overloads that clone only matching credentials; add new getCredentialsFilteredBy overload with kid support (#3100)
- [PATCH] Move Multiple Listening apps check to the authorization layer (#3070)
- [PATCH] Edge TB: Fix lookup mode (#3108)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// 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 com.microsoft.identity.common.java.util.CommonURIBuilder
import org.apache.hc.core5.http.NameValuePair
import java.net.URISyntaxException

/**
* Strict allowlist validator for the `app_link` query-parameter value carried on
* a broker-installation redirect URI (`msauth://...?app_link=<url>`).
*
* The classifier in [RawAuthorizationResult.getResultCodeFromFinalRedirectUri] 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"
Comment thread
rpdome marked this conversation as resolved.
)

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 builder: CommonURIBuilder = try {
CommonURIBuilder(url)
} catch (e: URISyntaxException) {
return false
}

// Scheme must be exactly https (case-insensitive).
if (!SCHEME_HTTPS.equals(builder.scheme, ignoreCase = true)) return false

// Reject embedded credentials, fragments, and non-default ports.
if (builder.userInfo != null) return false
if (builder.fragment != null) return false
if (builder.port != -1) return false

val host = builder.host ?: return false
val path = builder.path ?: return false
Comment thread
rpdome marked this conversation as resolved.

// Use getQueryParams() from CommonURIBuilder to parse query parameters.
// toUniqueParamMap returns null if any key appears more than once, defending
// against parameter-smuggling attacks such as ?id=safe&id=evil.
val params = toUniqueParamMap(builder.queryParams) ?: return false

return when {
HOST_PLAY.equals(host, ignoreCase = true) -> isValidPlayLink(path, params)
HOST_FWLINK.equals(host, ignoreCase = true) -> isValidFwlink(path, params)
else -> false
}
}

private fun isValidPlayLink(path: String, params: Map<String, String>): Boolean {
if (path != PATH_PLAY) return false
val id = params[PARAM_ID] ?: return false
if (!ALLOWED_PACKAGE_IDS.any { it.equals(id, ignoreCase = true) }) return false
return hasOnlyAllowedExtras(params, PARAM_ID)
}

private fun isValidFwlink(path: String, params: Map<String, String>): 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<String, String>, requiredKey: String): Boolean {
for (key in params.keys) {
if (key == requiredKey) continue
if (key == PARAM_REFERRER) continue
return false
}
return true
}

/**
* Converts a [NameValuePair] list (from [CommonURIBuilder.getQueryParams]) into a 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 toUniqueParamMap(pairs: List<NameValuePair>): Map<String, String>? {
val out = LinkedHashMap<String, String>()
for (pair in pairs) {
if (out.containsKey(pair.name)) return null
out[pair.name] = pair.value ?: ""
}
return out
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ public static RawAuthorizationResult fromRedirectUri(@NonNull final String redir
.resultCode(getResultCodeFromFinalRedirectUri(uri))
.authorizationFinalUri(uri)
.build();
} catch (final ClientException e) {
return fromException(e);
} catch (final URISyntaxException e) {
return fromException(new ClientException(ClientException.MALFORMED_URL,
"Failed to parse redirect URL", e));
Expand All @@ -217,14 +219,22 @@ public static RawAuthorizationResult fromPropertyBag(@NonNull final PropertyBag
.build();
}

private static ResultCode getResultCodeFromFinalRedirectUri(@NonNull final URI uri) throws URISyntaxException {
private static ResultCode getResultCodeFromFinalRedirectUri(@NonNull final URI uri) throws URISyntaxException, ClientException {
final String methodTag = TAG + "getResultCodeFromFinalRedirectUri";
final Map<String, String> parameters = UrlUtil.getParameters(uri);

if (REDIRECT_PREFIX.equalsIgnoreCase(uri.getScheme())) {
// 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 rejected as an unsupported redirect (UNSUPPORTED_URL).
final String appLink = parameters.get(APP_LINK_KEY);
if (!BrokerInstallLinkValidator.isSafeBrokerInstallLink(appLink)) {
Comment thread
rpdome marked this conversation as resolved.
Logger.warn(methodTag, "Rejected app_link that is not on the broker-install allowlist.");
throw new ClientException(ClientException.UNSUPPORTED_URL,
"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;
}
Expand Down
Loading
Loading