Overview › WebView surface — security + lifecycle + JS bridge

WebView surface — security + lifecycle + JS bridge

Generated 2026-05-11 · Costco Android

The WebView footprint

The Android app uses WebView in several distinct contexts. Each context has its own security, lifecycle, and performance posture — and they have drifted apart over time. This deep dive maps every WebView site, classifies the risks, and prescribes a uniform defensive posture.

Inventory

SiteFilesPurposeRisk concentration
Main WebViewCostco/.../MainWebViewFragment.kt (2,567 lines)Hosts the bulk of the e-commerce experience inside a Compose-shell appLifecycle leaks, force-unwraps, JS bridge surface
Pharmacy WebViewCostco/.../PharmacyWebViewFragment.ktEmbedded pharmacy flowForce-unwraps, retain in companion object
Offers WebViewCostco/.../WarehouseOffersListView.java + WarehouseOffersAdapter.javaPer-row WebView in offers listsetJavaScriptEnabled(true) + addJavascriptInterface; dynamic loadUrl javascript:
Click-to-PayCostco/.../MastercardClickToPay.ktMastercard payment flowUnsafe casts on WebView and message objects
Generic WebView utilCostco/.../webview/WebViewUtilImpl.javaCross-cutting helperDynamic loadUrl(jsCode); Uri.parse() result misuse

Findings concentrated in this surface

Across these files: 4 critical security findings + 11 critical/high force-unwraps + 11 lifecycle leaks (WebView not destroyed) + 1 logic bug:

Anatomy of the JS injection risk

// WarehouseOffersListView.java:266 — F074
String state = clipState.toJSON();  // user-influenced data
offerDataWebView.loadUrl("javascript:setClipState(" + state + ")");
//                                                  ^^^^^^^^ string concatenation

Attack vector: If clipState.toJSON() ever produces a string containing "); maliciousJs(); //, the resulting URL becomes javascript:setClipState(""); maliciousJs(); //") — arbitrary JS execution in the WebView, with read access to cookies for the loaded origin.

Fix:

// Use evaluateJavaScript with parameter binding
offerDataWebView.evaluateJavascript(
    "setClipState(" + JSONObject.quote(state) + ")",
    null
);
// JSONObject.quote escapes everything — quotes, newlines, embedded JSON

Anatomy of the JS-bridge risk

// WarehouseOffersAdapter.java:339 — F076
offerDataWebView.addJavascriptInterface(new Object() {
    @JavascriptInterface
    public void clip(String offerId) {
        // called from the WebView's JS context
        offerManager.clip(offerId);
    }
}, "AndroidOffers");

Attack vector: Any JS in the loaded page can call AndroidOffers.clip("anything"). If the offer page is ever served over a compromised CDN or via an MITM that bypasses cert pinning, an attacker can call native methods.

Defensive layers:

// 1) Validate the calling URL before honoring the call
@JavascriptInterface
public void clip(String offerId) {
    String currentUrl = webView.getUrl();
    if (!isAllowedOriginForOffersBridge(currentUrl)) {
        Timber.w("Offers bridge called from %s — rejecting", currentUrl);
        return;
    }
    if (!isValidOfferId(offerId)) return;
    offerManager.clip(offerId);
}

// 2) Validate the input
private boolean isValidOfferId(String s) {
    return s != null && s.matches("^[A-Za-z0-9_-]{1,32}$");
}

// 3) Allowlist origin
private boolean isAllowedOriginForOffersBridge(String url) {
    if (url == null) return false;
    Uri u = Uri.parse(url);
    return "https".equals(u.getScheme())
        && ("costco.com".equals(u.getHost()) || u.getHost().endsWith(".costco.com"));
}

Lifecycle: WebView not destroyed

11 sites store a WebView reference but never call destroy(). WebView holds a Chromium renderer process — leaking it means the renderer process stays alive across orientation changes and screen swaps.

// PharmacyWebViewFragment.kt — F161 / F162 / F163 / F164
private var mPharmacyWebView: WebView? = null
private var mWebView: WebView? = null
private var mAndroidWebView: WebView? = null

// What's missing: onDestroyView cleanup
override fun onDestroyView() {
    mPharmacyWebView?.let {
        it.stopLoading()
        it.removeAllViews()
        (it.parent as? ViewGroup)?.removeView(it)
        it.destroy()
    }
    mPharmacyWebView = null
    mWebView?.destroy(); mWebView = null
    mAndroidWebView?.destroy(); mAndroidWebView = null
    super.onDestroyView()
}

The "WebView in MainViewModel" anti-pattern

F169MainViewModel.kt:499 stores a WebView reference in a ViewModel. This is structurally wrong. ViewModels survive configuration changes; views don't. Storing a WebView in a ViewModel guarantees the renderer process leaks across rotation. The fix is architectural, not a one-line change:

// Before — wrong
class MainViewModel @Inject constructor(...) : ViewModel() {
    var webView: WebView? = null  // STRUCTURAL BUG
}

// After — WebView lives in the Fragment; VM holds the URL/state
class MainViewModel @Inject constructor(...) : ViewModel() {
    private val _currentUrl = MutableStateFlow<String>(INITIAL_URL)
    val currentUrl: StateFlow<String> = _currentUrl

    fun navigateTo(url: String) { _currentUrl.value = url }
}

// Fragment subscribes:
viewLifecycleOwner.lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.currentUrl.collect { webView.loadUrl(it) }
    }
}

Uniform WebView-hardening checklist

Apply this to every WebView site:

fun WebView.applyCostcoSecurityDefaults() {
    settings.apply {
        javaScriptEnabled = true                          // only if needed by the page
        allowFileAccess = false
        allowContentAccess = false
        allowFileAccessFromFileURLs = false
        allowUniversalAccessFromFileURLs = false
        mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
        domStorageEnabled = true                          // typically yes
        databaseEnabled = false                           // typically no
        cacheMode = WebSettings.LOAD_DEFAULT
        userAgentString = userAgentString + " CostcoApp/${BuildConfig.VERSION_NAME}"
    }
}

fun WebView.cleanUp() {
    stopLoading()
    onPause()
    removeAllViews()
    (parent as? ViewGroup)?.removeView(this)
    destroy()
}

Verification

Costco Android · Code Review Report · Generated 2026-05-11 · 626 machine-curated findings