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
| Site | Files | Purpose | Risk concentration |
|---|---|---|---|
| Main WebView | Costco/.../MainWebViewFragment.kt (2,567 lines) | Hosts the bulk of the e-commerce experience inside a Compose-shell app | Lifecycle leaks, force-unwraps, JS bridge surface |
| Pharmacy WebView | Costco/.../PharmacyWebViewFragment.kt | Embedded pharmacy flow | Force-unwraps, retain in companion object |
| Offers WebView | Costco/.../WarehouseOffersListView.java + WarehouseOffersAdapter.java | Per-row WebView in offers list | setJavaScriptEnabled(true) + addJavascriptInterface; dynamic loadUrl javascript: |
| Click-to-Pay | Costco/.../MastercardClickToPay.kt | Mastercard payment flow | Unsafe casts on WebView and message objects |
| Generic WebView util | Costco/.../webview/WebViewUtilImpl.java | Cross-cutting helper | Dynamic 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:
- F073 —
WebViewUtilImpl.java:501—view.loadUrl(jsCode)with dynamic JS — XSS-equivalent risk if jsCode is influenced by network input - F074 —
WarehouseOffersListView.java:266—loadUrl("javascript:setClipState(...)")with dynamic data - F075 —
WarehouseOffersAdapter.java:303—setJavaScriptEnabled(true)on a per-row WebView - F076 —
WarehouseOffersAdapter.java:339—addJavascriptInterface(...)with broad surface on an offers row - F077 —
WebViewUtilImpl.java:586—Uri.parse(url) == nulldead-code check (parse never returns null) - F161-F171 — 11 WebViews not destroyed in onDestroyView (PharmacyWebViewFragment, MastercardClickToPayFragment, MoreMenuFragment, MainViewModel — yes, ViewModel)
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
F169 — MainViewModel.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
- Pen test scope — engage the next NowSecure scan to attempt JS injection through every offers route
- Memory profile — record renderer process count before/after the cleanup PR; should drop to a steady-state count within ~10s of leaving WebView screens
- Per-WebView audit — every
WebViewdeclaration in the codebase should pass throughapplyCostcoSecurityDefaults()on init andcleanUp()on destroy. SwiftLint-style custom rule can enforce.