Security: tighten redirect URI matching to prevent prefix-confusion attacks (FireWatch c1bf88bd), Fixes AB#3665141#3136
Security: tighten redirect URI matching to prevent prefix-confusion attacks (FireWatch c1bf88bd), Fixes AB#3665141#3136wzhipan wants to merge 11 commits into
Conversation
AzureActiveDirectoryWebViewClient.isRedirectUrl() used String#startsWith() to identify navigations carrying the OAuth2 authorization response back to the configured redirect URI. Because the match was a prefix check, a URL such as msauth://com.outlook/HASH=.attacker.com/x?code=<AUTH_CODE> would also match the registered redirect URI msauth://com.outlook/HASH= and the auth code would be handed to processRedirectUrl() on a URL controlled by an attacker (in the event of a malicious server response or a MITM'd connection). eSTS performs exact redirect URI validation on the server side and the finding is therefore tagged Moderate / Not Exploitable, but the client should also do an exact check (defense-in-depth, MSRC Ceiling Rule 3). This change parses both the candidate URL and the configured redirect URI and requires that scheme, authority (host[:port]) and path match. Query string and fragment are intentionally ignored because they carry the auth code and state on legitimate redirects. Fixes FireWatch finding c1bf88bd-5fce-454c-a028-cbfe176639e0 Fixes IcM 31000000624712 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
❌ Work item link check failed. Description does not contain AB#{ID}. Click here to Learn more. |
- Add ENABLE_STRICT_REDIRECT_URI_MATCHING CommonFlight (default on) as an ECS kill switch. When disabled, isRedirectUrl falls back to the historical String#startsWith prefix match so the change can be turned off via flighting without a code rollback. - Fix the failing unit tests. The previous spoofing tests asserted shouldOverrideUrlLoading() returns false, which is wrong: for an msauth:// redirect URI a spoofed URL is still caught downstream by isInstallRequestUrl (BROWSER_EXT_INSTALL_PREFIX = "msauth://") whose processInstallRequest also extracts the code, so the isRedirectUrl fix is not observable at the callback for msauth. Rewrite the tests against an https:// redirect URI (which isolates isRedirectUrl) and assert the security property via the completion callback: spoofed URLs are never delivered; the legitimate redirect is. - Add a kill-switch test proving the flag reverts to prefix-match behavior. Note: isInstallRequestUrl matching the entire msauth:// scheme means the isRedirectUrl fix alone does not close the prefix-confusion path for broker (msauth) redirect URIs. Tracked as a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
❌ Invalid work item number: AB#`. Work item number must be a valid integer. Click here to learn more. |
|
✅ Work item link check complete. Description contains link AB#3665141 to an Azure Boards work item. |
isRedirectUrl now treats a registered "/auth" and an incoming "/auth/" as the same resource by collapsing a single trailing slash before the path comparison (normalizePath). Only one trailing slash is removed; any other path difference (extra segment, suffix, "/auth//") is still rejected, so the prefix-confusion protection is unchanged. Adds a unit test asserting the trailing-slash redirect is still delivered to the completion callback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Trim the vNext changelog line to one concise sentence and add the PR number (#3136). - Condense the verbose comments on isRedirectUrl, normalizePath, the CommonFlight kill-switch doc, and the test vector block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#3128 was a vNext entry when this branch was created; it has since been released into "Version 24.3.0" on dev. The branch carried the old vNext copy in addition to the released one (merged from dev), producing a duplicate. Remove the stale vNext copy so the changelog matches dev and this PR's changelog diff is just the single new entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
❌ Invalid work item number: AB#3665141 ##. Work item number must be a valid integer. Click here to learn more. |
|
✅ Work item link check complete. Description contains link AB#3665141 to an Azure Boards work item. |
|
❌ Work item link check failed. Description contains AB#3665141 but the Bot could not link it to an Azure Boards work item. Click here to learn more. |
There was a problem hiding this comment.
Pull request overview
Hardens WebView redirect handling by replacing redirect-URI prefix matching with structured URI comparison (scheme + authority + path) to mitigate redirect URI prefix-confusion attacks, with an ECS kill switch for rollback.
Changes:
- Added
CommonFlight.ENABLE_STRICT_REDIRECT_URI_MATCHING(default on) as a kill switch for strict redirect-URI matching. - Updated
AzureActiveDirectoryWebViewClient.isRedirectUrlto useUri-based scheme/authority/path comparison (instead ofstartsWith), with trailing-slash normalization. - Added/updated unit tests to validate strict matching behavior and the kill switch; updated changelog entry.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java | Adds a new ECS flight to gate strict redirect-URI matching. |
| common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java | Implements strict redirect-URI matching logic with kill switch. |
| common/src/test/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClientTest.java | Adds tests for strict matching, spoof rejection, trailing slash handling, and kill switch behavior. |
| changelog.txt | Documents the security hardening as a vNext [PATCH]. |
Addresses Copilot review on PR #3136. - Opaque URIs (e.g. the broker OOB redirect urn:ietf:wg:oauth:2.0:oob, AuthenticationConstants.Broker.BROKER_REDIRECT_URI) have null authority and path, so the scheme+authority+path comparison degenerated to scheme-only and would accept ANY same-scheme urn (e.g. urn:...:oobstolen?code=STOLEN). Now, when either URI is opaque, compare the scheme-specific part (minus appended query/fragment) and require both to be opaque. - Stop logging the Throwable on the parse-failure path; its message could embed the URL (and thus the auth code). Log a static message instead. - Add positive and negative opaque (OOB) unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keeps the exception class name for debuggability while still avoiding logging the message, which could embed the URL/auth code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…i-matching # Conflicts: # common4j/src/main/com/microsoft/identity/common/java/flighting/CommonFlight.java
in isSwitchBrowserRequest() case, it's formattedUrl is /switch_browser. Now, isRedirectUrl() check would fail for this right? You might want to separate branch for isSwitchBrowserRedirect() Refers to: common/src/main/java/com/microsoft/identity/common/internal/ui/webview/AzureActiveDirectoryWebViewClient.java:358 in 9507dc9. [](commit_id = 9507dc9, deletion_comment = False) |
Fixes AB#3665141 — redirect URI prefix-confusion hardening.
Summary
Tighten redirect URI matching in
AzureActiveDirectoryWebViewClient.isRedirectUrlto use structured scheme + authority + path comparison instead ofString#startsWith, eliminating a prefix-confusion attack vector. Gated by an ECS kill switch.Why
isRedirectUrlusedurl.startsWith(mRedirectUrl.toLowerCase(Locale.US)). BecausestartsWithis a prefix match, a URL such aspassed the check for the registered redirect URI
msauth://com.outlook/HASH=, and the OAuth2 auth code was handed toprocessRedirectUrl()on a URL controlled by an attacker. eSTS performs exact redirect URI validation server-side, but the client check should be exact too (defense-in-depth, MSRC Ceiling Rule 3).What changes
isRedirectUrl()— replaced the prefix match withUri.parse-based comparison requiring scheme + authority (host[:port]) + path to all match (case-insensitive). Query and fragment are ignored (they carrycode/stateon legit redirects). A single trailing slash on the path is normalized, so a registered/authstill matches an incoming/auth/. Any other path difference is rejected. Unparseable URLs fail closed (false).Opaque redirect URIs (e.g. the broker OOB redirect
urn:ietf:wg:oauth:2.0:oob) — opaque URIs have null authority/path, so a naive scheme+authority+path comparison would degenerate to scheme-only and accept any same-schemeurn:*. These are now compared by scheme-specific part (minus any appended?query/#fragment), and a spoofed URI must be opaque on both sides. Parse-failure logging records the exception type only (never the throwable message, which could embed the URL and thus the auth code).ECS kill switch —
ENABLE_STRICT_REDIRECT_URI_MATCHINGCommonFlight (default on). When disabled via ECS,isRedirectUrlreverts to the historicalString#startsWithbehavior, allowing a config-only rollback without a code change.For msauth:// redirect URIs (the broker case), this fix alone is not sufficient. After
isRedirectUrlcorrectly rejects a spoofedmsauth://…URL, the very next branch inhandleUrl—isInstallRequestUrl(BROWSER_EXT_INSTALL_PREFIX = "msauth://") — matches anymsauth://URL, andprocessInstallRequestalso callsRawAuthorizationResult.fromRedirectUri(url), delivering the code to the completion callback. So for msauth the prefix-confusion outcome is unchanged at the callback.The fix is effective for non-msauth (e.g.
https://, custom-scheme) redirect URIs, which is why the new tests use anhttps://redirect URI to isolate and prove theisRedirectUrlbehavior.Behavior delta for msauth (no regression): only the routing of a spoofed/suffixed
msauth://URL changes — it now falls through fromisRedirectUrltoisInstallRequestUrland is handled byprocessInstallRequestinstead ofprocessRedirectUrl. The auth-code outcome at the callback is identical (both deliver the sameCOMPLETEDresult), so no legitimate redirect or install request changes behavior. The only observable difference is for attack/malformed traffic: it is now logged and counted as a broker-install step (STEP_BROKER_INSTALL_PROMPTED) rather than a redirect — cosmetic, and fully reverted by theENABLE_STRICT_REDIRECT_URI_MATCHINGkill switch.Plan: closing the msauth path (tightening
isInstallRequestUrl/processInstallRequestso a genericmsauth://…?code=…withoutapp_link/wpjis not treated as an install request) will be handled as a separate follow-up PR, to keep this defense-in-depth change focused and independently shippable.Tests
Assert the real security property via the completion callback (not just the
shouldOverrideUrlLoadingreturn value), usinghttps://and opaqueurn:redirect URIs:testStrictMatching_acceptsLegitimateHttpsRedirecttestStrictMatching_acceptsTrailingSlashPathDifference/authregistered,/auth/incoming -> still deliveredtestStrictMatching_acceptsLegitimateOpaqueOobRedirecturn:ietf:wg:oauth:2.0:oob?code=…-> code delivered (COMPLETED)testStrictMatching_rejectsSpoofedOpaqueOobRedirecturn:…:oobstolen?code=…-> never delivered as COMPLETEDtestStrictMatching_rejectsSpoofedRedirectWithSuffixHost…/auth.attacker.com/x?code=…-> callback never invokedtestStrictMatching_rejectsSpoofedRedirectWithPathSuffix…/authstolen?code=…-> callback never invokedtestKillSwitch_disablesStrictMatching_revertsToPrefixMatchtestUrlOverrideHandlesRedirectUriString/…WithFragmentReferences
c1bf88bd-5fce-454c-a028-cbfe176639e0Validation
Local Gradle build needs ADO credential setup unavailable here; PR pipelines run the full unit-test suite. Branch is up to date with
dev.Opened by Copilot CLI on behalf of @zhipanwang for IcM 31000000624712.