Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.credentials.manager
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary
import com.bitwarden.ui.platform.base.util.prefixWwwIfNecessary
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
import com.x8bit.bitwarden.data.credentials.repository.PrivilegedAppRepository
import com.x8bit.bitwarden.data.platform.manager.AssetManager
Expand Down Expand Up @@ -40,7 +41,13 @@ class OriginManagerImpl(
): ValidateOriginResult {
return digitalAssetLinkService
.checkDigitalAssetLinksRelations(
sourceWebSite = relyingPartyId.prefixHttpsIfNecessary(),
sourceWebSite = relyingPartyId
// The DAL API does not allow redirects, so we add `www.` to prevent redirects
// when it is absent from the `relyingPartyId`. This ensures that relying
// parties storing their `assetlinks.json` at the `www.` subdomain do not fail
// verification checks.
.prefixWwwIfNecessary()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ญ Consider the security implications of automatic www prefixing

While the www-prefixing improves compatibility, it's worth considering the security implications:

Current behavior: If a site hosts their Digital Asset Links file at https://www.example.com/.well-known/assetlinks.json but NOT at https://example.com/.well-known/assetlinks.json, this code will allow passkey authentication by automatically trying the www variant.

Question: Is this the intended behavior? Should we:

  1. Keep current approach (automatic fallback to www) - More permissive, better UX
  2. Try both variants (with and without www) - Most robust
  3. Only use the exact rpId provided - Most restrictive, follows spec strictly

From a security perspective, option 1 (current) seems reasonable since:

  • DAL files are cryptographically verifiable via certificate fingerprints
  • The www subdomain is typically controlled by the same entity as the apex domain
  • This aligns with how browsers handle www/non-www redirects

However, this should be documented as an intentional security decision, not just a compatibility fix.

.prefixHttpsIfNecessary(),
targetPackageName = callingAppInfo.packageName,
targetCertificateFingerprint = callingAppInfo
.getSignatureFingerprintAsHexString()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,64 @@ class OriginManagerTest {
),
)
}

@Test
fun `validateOrigin should prefix www to rpId without www before checking asset links`() =
runTest {
coEvery {
mockDigitalAssetLinkService.checkDigitalAssetLinksRelations(
sourceWebSite = "https://www.example.com",
targetPackageName = DEFAULT_PACKAGE_NAME,
targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT,
relations = listOf("delegate_permission/common.handle_all_urls"),
)
} returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess()

val result = originManager.validateOrigin(
relyingPartyId = "example.com",
callingAppInfo = mockNonPrivilegedAppInfo,
)

assertEquals(ValidateOriginResult.Success(null), result)
}

@Test
fun `validateOrigin should preserve existing www prefix when present`() = runTest {
coEvery {
mockDigitalAssetLinkService.checkDigitalAssetLinksRelations(
sourceWebSite = "https://www.example.com",
targetPackageName = DEFAULT_PACKAGE_NAME,
targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT,
relations = listOf("delegate_permission/common.handle_all_urls"),
)
} returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess()

val result = originManager.validateOrigin(
relyingPartyId = "www.example.com",
callingAppInfo = mockNonPrivilegedAppInfo,
)

assertEquals(ValidateOriginResult.Success(null), result)
}

@Test
fun `validateOrigin should handle rpId with https scheme correctly`() = runTest {
coEvery {
mockDigitalAssetLinkService.checkDigitalAssetLinksRelations(
sourceWebSite = "https://www.example.com",
targetPackageName = DEFAULT_PACKAGE_NAME,
targetCertificateFingerprint = DEFAULT_CERT_FINGERPRINT,
relations = listOf("delegate_permission/common.handle_all_urls"),
)
} returns DEFAULT_ASSET_LINKS_CHECK_RESPONSE.asSuccess()

val result = originManager.validateOrigin(
relyingPartyId = "https://example.com",
callingAppInfo = mockNonPrivilegedAppInfo,
)

assertEquals(ValidateOriginResult.Success(null), result)
}
}

private const val DEFAULT_PACKAGE_NAME = "com.x8bit.bitwarden"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,34 @@ fun String.prefixHttpsIfNecessaryOrNull(): String? =
fun String.prefixHttpsIfNecessary(): String =
prefixHttpsIfNecessaryOrNull() ?: this

/**
* If the given [String] is a valid URI, "www." will be prepended (or inserted after the scheme
* if present) if it is not already present. Otherwise `null` will be returned.
*
* Examples:
* - "example.com" -> "www.example.com"
* - "www.example.com" -> "www.example.com"
* - "https://example.com" -> "https://www.example.com"
* - "https://www.example.com" -> "https://www.example.com"
*/
fun String.prefixWwwIfNecessaryOrNull(): String? =
when {
this.isBlank() || !this.isValidUri() -> null
this.startsWith("www.") -> this
this.startsWith("http://") || this.startsWith("https://") -> {
if ("://www." in this) this else this.replaceFirst("://", "://www.")
}

else -> "www.$this"
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ‘ Excellent implementation

The logic correctly handles all cases:

  • Blank/invalid URIs โ†’ null
  • Already has www. prefix โ†’ unchanged
  • Has scheme (http:// or https://) โ†’ inserts www. after scheme
  • No scheme โ†’ prepends www.

The check for "://www." prevents double-prefixing when www is already present after a scheme, which is good.

Edge case verification

Verified the logic handles these correctly:

  • "example.com" โ†’ "www.example.com" โœ“
  • "www.example.com" โ†’ "www.example.com" โœ“
  • "http://example.com" โ†’ "http://www.example.com" โœ“
  • "https://www.example.com" โ†’ "https://www.example.com" โœ“
  • "" โ†’ null โœ“
  • "invalid uri" โ†’ null โœ“


/**
* If the given [String] is a valid URI, "www." will be prepended (or inserted after the scheme
* if present) if it is not already present. Otherwise the original [String] will be returned.
*/
fun String.prefixWwwIfNecessary(): String =
prefixWwwIfNecessaryOrNull() ?: this

/**
* Checks if a string is using base32 digits.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,65 @@ class StringExtensionsTest {
fun `orZeroWidthSpace returns the original value for a non-blank string`() {
assertEquals("test", "test".orZeroWidthSpace())
}

@Suppress("MaxLineLength")
@Test
fun `prefixWwwIfNecessaryOrNull should prefix www when URI is valid and no scheme and no www`() {
val uri = "example.com"
val expected = "www.$uri"
val actual = uri.prefixWwwIfNecessaryOrNull()

assertEquals(expected, actual)
}

@Test
fun `prefixWwwIfNecessaryOrNull should return URI unchanged when starts with www`() {
val uri = "www.example.com"
val actual = uri.prefixWwwIfNecessaryOrNull()
assertEquals(uri, actual)
}

@Test
fun `prefixWwwIfNecessaryOrNull should prefix www when scheme is http and no www`() {
val uri = "http://example.com"
val expected = "http://www.example.com"
val actual = uri.prefixWwwIfNecessaryOrNull()
assertEquals(expected, actual)
}

@Test
fun `prefixWwwIfNecessaryOrNull should prefix www when scheme is https and no www`() {
val uri = "https://example.com"
val expected = "https://www.example.com"
val actual = uri.prefixWwwIfNecessaryOrNull()
assertEquals(expected, actual)
}

@Suppress("MaxLineLength")
@Test
fun `prefixWwwIfNecessaryOrNull should return URI unchanged when scheme is http and www is present`() {
val uri = "http://www.example.com"
val actual = uri.prefixWwwIfNecessaryOrNull()
assertEquals(uri, actual)
}

@Suppress("MaxLineLength")
@Test
fun `prefixWwwIfNecessaryOrNull should return URI unchanged when scheme is https and www is present`() {
val uri = "https://www.example.com"
val actual = uri.prefixWwwIfNecessaryOrNull()
assertEquals(uri, actual)
}

@Test
fun `prefixWwwIfNecessaryOrNull should return null when URI is empty string`() {
val uri = ""
assertNull(uri.prefixWwwIfNecessaryOrNull())
}

@Test
fun `prefixWwwIfNecessaryOrNull should return null when URI is invalid`() {
val invalidUri = "invalid uri"
assertNull(invalidUri.prefixWwwIfNecessaryOrNull())
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ‘ Comprehensive test coverage for string extensions

The test coverage for the new prefixWwwIfNecessaryOrNull() function is excellent:

  • โœ… No scheme, no www
  • โœ… Already has www prefix
  • โœ… HTTP scheme without www
  • โœ… HTTPS scheme without www
  • โœ… HTTP scheme with www
  • โœ… HTTPS scheme with www
  • โœ… Empty string
  • โœ… Invalid URI

All major edge cases are covered. Good work!

Loading