-
Notifications
You must be signed in to change notification settings - Fork 4
fix: secure shop payments and locked links #1158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
7dc11dc
fix: gate shop origin and pin pay
cursoragent d7f8e30
chore: rename changelog fragment
cursoragent ae32fb1
fix: handle null shop origin scheme
cursoragent d317fb8
test: reject shop origin without scheme
cursoragent 553bd65
fix: satisfy detekt on scan log ids
cursoragent d1fc2a3
fix: keep queued scans while locked
cursoragent 645d114
fix: harden shop bridge and scan queue
ben-kaufman 2aa9927
fix: harden payment flow safeguards
ben-kaufman 6f5d346
fix: scope shop payment origin
ben-kaufman ad154ff
refactor: simplify deferred payment flow
ben-kaufman db928b5
fix: address payment review feedback
ben-kaufman 49ab192
fix: preserve quickpay pin behavior
ben-kaufman File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
65 changes: 65 additions & 0 deletions
65
app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopOrigin.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| package to.bitkit.ui.screens.shop.shopWebView | ||
|
|
||
| import to.bitkit.env.Env | ||
| import java.net.URI | ||
|
|
||
| /** Root host for Bitrefill shop pages and payment_intent messages. */ | ||
| internal const val BITREFILL_ROOT_HOST = "bitrefill.com" | ||
|
|
||
| /** Default HTTPS port accepted for the trusted shop payment origin. */ | ||
| private const val HTTPS_DEFAULT_PORT = 443 | ||
|
|
||
| internal fun isAllowedShopHost(host: String?): Boolean { | ||
| val normalized = host?.lowercase()?.trim('.') ?: return false | ||
| return normalized == BITREFILL_ROOT_HOST || normalized.endsWith(".$BITREFILL_ROOT_HOST") | ||
| } | ||
|
|
||
| internal fun isAllowedShopOrigin(url: String?): Boolean { | ||
| if (url.isNullOrBlank()) return false | ||
| val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false | ||
| if (parsed.scheme?.equals("https", ignoreCase = true) != true) return false | ||
| return isAllowedShopHost(parsed.host) | ||
| } | ||
|
|
||
| private val bitrefillEmbedOrigin = URI(Env.BITREFILL_URL) | ||
|
|
||
| private fun hasTrustedPaymentOrigin(parsed: URI): Boolean { | ||
| val hasTrustedScheme = parsed.scheme.equals(bitrefillEmbedOrigin.scheme, ignoreCase = true) | ||
| val hasTrustedHost = parsed.host.equals(bitrefillEmbedOrigin.host, ignoreCase = true) | ||
| val hasTrustedPort = parsed.port == -1 || parsed.port == HTTPS_DEFAULT_PORT | ||
| return hasTrustedScheme && hasTrustedHost && hasTrustedPort && parsed.rawUserInfo == null | ||
| } | ||
|
|
||
| internal fun isAllowedShopPaymentPage(url: String?): Boolean { | ||
| if (url.isNullOrBlank()) return false | ||
| val parsed = runCatching { URI(url.trim()) }.getOrNull() ?: return false | ||
| return hasTrustedPaymentOrigin(parsed) | ||
| } | ||
|
|
||
| internal fun isAllowedShopPaymentOrigin(origin: String?): Boolean { | ||
| if (origin.isNullOrBlank()) return false | ||
| val parsed = runCatching { URI(origin.trim()) }.getOrNull() ?: return false | ||
| return hasTrustedPaymentOrigin(parsed) && | ||
| parsed.rawPath.isNullOrEmpty() && | ||
| parsed.rawQuery == null && | ||
| parsed.rawFragment == null | ||
| } | ||
|
|
||
| internal fun shopPaymentOriginRules(): Set<String> = setOf(Env.BITREFILL_URL) | ||
|
|
||
| internal fun shopMessageBridgeScript(): String = """ | ||
| if (!window.__bitkitShopBridgeInstalled) { | ||
| window.__bitkitShopBridgeInstalled = true; | ||
| window.ReactNativeWebView = { | ||
| postMessage: function(data) { | ||
| Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); | ||
| } | ||
| }; | ||
| window.addEventListener('message', function(event) { | ||
| if (event.origin !== '${Env.BITREFILL_URL}') return; | ||
| var data = event.data; | ||
| if (data == null) return; | ||
| Android.postMessage(typeof data === 'string' ? data : JSON.stringify(data)); | ||
| }); | ||
| } | ||
| """.trimIndent() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 69 additions & 47 deletions
116
app/src/main/java/to/bitkit/ui/screens/shop/shopWebView/ShopWebViewInterface.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,70 +1,92 @@ | ||
| package to.bitkit.ui.screens.shop.shopWebView | ||
|
|
||
| import android.webkit.JavascriptInterface | ||
| import android.webkit.WebView | ||
| import androidx.webkit.WebMessageCompat | ||
| import androidx.webkit.WebViewCompat | ||
| import androidx.webkit.WebViewFeature | ||
| import kotlinx.serialization.json.Json | ||
| import to.bitkit.utils.Logger | ||
|
|
||
| /** | ||
| * JavaScript interface for handling WebView messages. | ||
| * | ||
| * SECURITY NOTE: This interface is exposed to JavaScript running in the WebView. | ||
| * Only methods annotated with @JavascriptInterface are accessible from JavaScript | ||
| * on API 17+ (Android 4.2+). All methods should validate input and handle errors | ||
| * gracefully since they run on a background thread. | ||
| * | ||
| * Thread Safety: JavaScript interacts with this object on a private background | ||
| * thread. All callbacks should be thread-safe or use appropriate dispatching. | ||
| * [attachTo] uses an origin-scoped WebMessageListener. Payment handling is | ||
| * disabled when that listener is unavailable because legacy JavaScript | ||
| * interfaces cannot identify the calling frame. | ||
| */ | ||
| class ShopWebViewInterface( | ||
| private val onPaymentIntent: (String) -> Unit, | ||
| private val isWebMessageListenerSupported: () -> Boolean = { | ||
| WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) | ||
| }, | ||
| private val addWebMessageListener: ( | ||
| WebView, | ||
| String, | ||
| Set<String>, | ||
| WebViewCompat.WebMessageListener, | ||
| ) -> Unit = { webView, jsObjectName, allowedOriginRules, listener -> | ||
| WebViewCompat.addWebMessageListener(webView, jsObjectName, allowedOriginRules, listener) | ||
| }, | ||
| ) { | ||
| private companion object { | ||
| const val TAG = "ShopWebViewInterface" | ||
| const val JS_OBJECT_NAME = "Android" | ||
| const val PAYMENT_INTENT_EVENT = "payment_intent" | ||
| } | ||
|
|
||
| private val json = Json { ignoreUnknownKeys = true } | ||
| private val webMessageListenerSupported by lazy(isWebMessageListenerSupported) | ||
|
|
||
| /** | ||
| * Handles messages posted from JavaScript. | ||
| * This method is called on a background thread - ensure thread safety. | ||
| * | ||
| * @param message JSON string containing the message data | ||
| */ | ||
| @Suppress("NestedBlockDepth") | ||
| @JavascriptInterface | ||
| fun postMessage(message: String) { | ||
| if (message.isBlank()) { | ||
| Logger.warn("Received empty message", context = "WebView") | ||
| internal fun supportsPaymentBridge() = webMessageListenerSupported | ||
|
|
||
| fun attachTo(webView: WebView) { | ||
| if (!supportsPaymentBridge()) { | ||
| Logger.warn("Disabled shop payment bridge because WebMessageListener is unavailable", context = TAG) | ||
| return | ||
| } | ||
|
|
||
| runCatching { | ||
| val data = json.decodeFromString<WebViewMessage>(message) | ||
| when (data.event) { | ||
| "payment_intent" -> { | ||
| data.paymentUri?.let { uri -> | ||
| // Validate URI before passing it along | ||
| if (uri.isNotBlank()) { | ||
| onPaymentIntent(uri) | ||
| } else { | ||
| Logger.warn("Received payment_intent with empty URI", context = "WebView") | ||
| } | ||
| } ?: Logger.warn("Received payment_intent without URI", context = "WebView") | ||
| } | ||
| addWebMessageListener( | ||
| webView, | ||
| JS_OBJECT_NAME, | ||
| shopPaymentOriginRules(), | ||
| ) { _, message, sourceOrigin, _, _ -> | ||
| onWebMessage(message, sourceOrigin.toString()) | ||
| } | ||
| } | ||
|
|
||
| else -> { | ||
| Logger.debug("Unknown event type: ${data.event}", context = "WebView") | ||
| } | ||
| } | ||
| }.onFailure { | ||
| Logger.error("Error parsing message: $message", it, context = "WebView") | ||
| internal fun onWebMessage(message: WebMessageCompat, sourceOrigin: String?) { | ||
| if (message.type != WebMessageCompat.TYPE_STRING) { | ||
| Logger.warn("Rejected non-string shop WebView message", context = TAG) | ||
| return | ||
| } | ||
| val data = message.data.orEmpty() | ||
| if (data.isBlank()) { | ||
| Logger.warn("Received empty shop WebView message", context = TAG) | ||
| return | ||
| } | ||
| handlePaymentMessage(data, sourceOrigin) | ||
| } | ||
|
|
||
| /** | ||
| * Returns whether the interface is ready to receive messages. | ||
| * | ||
| * @return true if the interface is initialized and ready | ||
| */ | ||
| @Suppress("FunctionOnlyReturningConstant") | ||
| @JavascriptInterface | ||
| fun isReady(): Boolean { | ||
| return true | ||
| internal fun handlePaymentMessage(message: String, sourceOrigin: String?) { | ||
| if (!isAllowedShopPaymentOrigin(sourceOrigin)) { | ||
| Logger.warn("Rejected shop payment_intent from untrusted origin '$sourceOrigin'", context = TAG) | ||
| return | ||
| } | ||
|
|
||
| val data = runCatching { json.decodeFromString<WebViewMessage>(message) }.getOrElse { | ||
| Logger.debug("Ignored unrecognized shop WebView message", context = TAG) | ||
| return | ||
| } | ||
| when (data.event) { | ||
| PAYMENT_INTENT_EVENT -> { | ||
| val uri = data.paymentUri?.trim().orEmpty() | ||
| if (uri.isBlank()) { | ||
| Logger.warn("Received payment_intent with empty URI", context = TAG) | ||
| return | ||
| } | ||
| onPaymentIntent(uri) | ||
| } | ||
| else -> Logger.debug("Ignored shop WebView event '${data.event}'", context = TAG) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.