mirror of
https://github.com/RoboSats/robosats.git
synced 2025-09-13 00:56:22 +00:00
Security checks
This commit is contained in:
@ -161,7 +161,7 @@ export interface UseAppStoreType {
|
|||||||
|
|
||||||
export const initialAppContext: UseAppStoreType = {
|
export const initialAppContext: UseAppStoreType = {
|
||||||
theme: undefined,
|
theme: undefined,
|
||||||
torStatus: 'STARTING',
|
torStatus: 'ON',
|
||||||
settings: getSettings(),
|
settings: getSettings(),
|
||||||
setSettings: () => {},
|
setSettings: () => {},
|
||||||
page: entryPage,
|
page: entryPage,
|
||||||
|
|||||||
@ -1,17 +1,29 @@
|
|||||||
package com.robosats
|
package com.robosats
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.webkit.*
|
import android.webkit.ConsoleMessage
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.GeolocationPermissions
|
||||||
|
import android.webkit.PermissionRequest
|
||||||
|
import android.webkit.ServiceWorkerController
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebStorage
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import com.robosats.R
|
|
||||||
import com.robosats.tor.TorKmp
|
import com.robosats.tor.TorKmp
|
||||||
import com.robosats.tor.TorKmpManager
|
import com.robosats.tor.TorKmpManager
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
@ -26,6 +38,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var loadingContainer: ConstraintLayout
|
private lateinit var loadingContainer: ConstraintLayout
|
||||||
private lateinit var statusTextView: TextView
|
private lateinit var statusTextView: TextView
|
||||||
|
|
||||||
|
// Security constants
|
||||||
|
private val ALLOWED_DOMAINS = arrayOf(".onion")
|
||||||
|
private val CONTENT_SECURITY_POLICY = "default-src 'self'; connect-src 'self' https://*.onion http://*.onion; " +
|
||||||
|
"script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"img-src 'self' data:; font-src 'self' data:; object-src 'none'; " +
|
||||||
|
"media-src 'none'; frame-src 'none'; worker-src 'self';"
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@ -152,40 +171,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure WebView settings on UI thread
|
// Configure WebView settings on UI thread with security as priority
|
||||||
val webSettings = webView.settings
|
secureWebViewSettings()
|
||||||
|
|
||||||
// Enable JavaScript
|
|
||||||
webSettings.javaScriptEnabled = true
|
|
||||||
|
|
||||||
// Enable DOM storage for HTML5 apps
|
|
||||||
webSettings.domStorageEnabled = true
|
|
||||||
|
|
||||||
// Enable CORS and cross-origin requests
|
|
||||||
webSettings.allowUniversalAccessFromFileURLs = true
|
|
||||||
webSettings.allowFileAccessFromFileURLs = true
|
|
||||||
|
|
||||||
// Disable cache completely to prevent leaks
|
|
||||||
webSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
|
||||||
|
|
||||||
// Enable mixed content (http in https)
|
|
||||||
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
|
||||||
|
|
||||||
// Enable zooming
|
|
||||||
webSettings.setSupportZoom(true)
|
|
||||||
webSettings.builtInZoomControls = true
|
|
||||||
webSettings.displayZoomControls = false
|
|
||||||
|
|
||||||
// Enable HTML5 features
|
|
||||||
webSettings.allowFileAccess = true
|
|
||||||
webSettings.allowContentAccess = true
|
|
||||||
webSettings.loadWithOverviewMode = true
|
|
||||||
webSettings.useWideViewPort = true
|
|
||||||
webSettings.setSupportMultipleWindows(true)
|
|
||||||
webSettings.javaScriptCanOpenWindowsAutomatically = true
|
|
||||||
|
|
||||||
// Improve display for better Android integration
|
|
||||||
webSettings.textZoom = 100 // Normal text zoom
|
|
||||||
|
|
||||||
// Show message that we're setting up secure browsing
|
// Show message that we're setting up secure browsing
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
@ -222,6 +209,30 @@ class MainActivity : AppCompatActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
updateStatus("Secure connection established. Loading app...")
|
updateStatus("Secure connection established. Loading app...")
|
||||||
|
|
||||||
|
// Set up WebChromeClient with restricted permissions
|
||||||
|
webView.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun onGeolocationPermissionsShowPrompt(
|
||||||
|
origin: String,
|
||||||
|
callback: GeolocationPermissions.Callback
|
||||||
|
) {
|
||||||
|
// Deny all geolocation requests
|
||||||
|
callback.invoke(origin, false, false)
|
||||||
|
Log.d("SecurityPolicy", "Blocked geolocation request from: $origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPermissionRequest(request: PermissionRequest) {
|
||||||
|
// Deny all permission requests from web content
|
||||||
|
request.deny()
|
||||||
|
Log.d("SecurityPolicy", "Denied permission request: ${request.resources.joinToString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control console messages
|
||||||
|
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
|
||||||
|
Log.d("WebViewConsole", "${consoleMessage.message()} -- From line ${consoleMessage.lineNumber()} of ${consoleMessage.sourceId()}")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a custom WebViewClient that forces all traffic through Tor
|
// Create a custom WebViewClient that forces all traffic through Tor
|
||||||
webView.webViewClient = object : WebViewClient() {
|
webView.webViewClient = object : WebViewClient() {
|
||||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||||
@ -234,10 +245,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val urlString = request.url.toString()
|
val urlString = request.url.toString()
|
||||||
Log.d("TorProxy", "Intercepting request: $urlString")
|
Log.d("TorProxy", "Intercepting request: $urlString")
|
||||||
|
|
||||||
|
// Block all external requests that aren't to .onion domains or local files
|
||||||
|
if (!isAllowedRequest(urlString)) {
|
||||||
|
Log.e("SecurityPolicy", "Blocked forbidden request to: $urlString")
|
||||||
|
return WebResourceResponse("text/plain", "UTF-8", null)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Special handling for .onion domains
|
// Special handling for .onion domains
|
||||||
val isOnionDomain = urlString.contains(".onion")
|
val isOnionDomain = urlString.contains(".onion")
|
||||||
|
|
||||||
|
// Only proceed if it's an onion domain or local file
|
||||||
|
if (!isOnionDomain && !urlString.startsWith("file://")) {
|
||||||
|
Log.e("SecurityPolicy", "Blocked non-onion external request: $urlString")
|
||||||
|
return WebResourceResponse("text/plain", "UTF-8", null)
|
||||||
|
}
|
||||||
|
|
||||||
// For .onion domains, we must use SOCKS proxy type
|
// For .onion domains, we must use SOCKS proxy type
|
||||||
val proxyType = if (isOnionDomain)
|
val proxyType = if (isOnionDomain)
|
||||||
Proxy.Type.SOCKS
|
Proxy.Type.SOCKS
|
||||||
@ -254,6 +277,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Log.d("TorProxy", "Handling .onion domain with SOCKS proxy: $urlString")
|
Log.d("TorProxy", "Handling .onion domain with SOCKS proxy: $urlString")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If it's a local file, return it directly
|
||||||
|
if (urlString.startsWith("file://")) {
|
||||||
|
// Let the system handle local files
|
||||||
|
return super.shouldInterceptRequest(view, request)
|
||||||
|
}
|
||||||
|
|
||||||
// Create connection with proxy already configured
|
// Create connection with proxy already configured
|
||||||
val url = URL(urlString)
|
val url = URL(urlString)
|
||||||
val connection = url.openConnection(torProxy)
|
val connection = url.openConnection(torProxy)
|
||||||
@ -266,22 +295,46 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// Ensure no connection reuse to prevent proxy leaks
|
// Ensure no connection reuse to prevent proxy leaks
|
||||||
connection.setRequestProperty("Connection", "close")
|
connection.setRequestProperty("Connection", "close")
|
||||||
|
|
||||||
|
// Add security headers
|
||||||
|
connection.setRequestProperty("Sec-Fetch-Site", "same-origin")
|
||||||
|
connection.setRequestProperty("Sec-Fetch-Mode", "cors")
|
||||||
|
connection.setRequestProperty("DNT", "1") // Do Not Track
|
||||||
|
|
||||||
// Copy request headers
|
// Copy request headers
|
||||||
request.requestHeaders.forEach { (key, value) ->
|
request.requestHeaders.forEach { (key, value) ->
|
||||||
connection.setRequestProperty(key, value)
|
connection.setRequestProperty(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the request method
|
||||||
|
connection.requestMethod = request.method
|
||||||
|
|
||||||
// Special handling for OPTIONS (CORS preflight) requests
|
// Special handling for OPTIONS (CORS preflight) requests
|
||||||
if (request.method == "OPTIONS") {
|
if (request.method == "OPTIONS") {
|
||||||
// Handle preflight CORS request
|
// For OPTIONS, we'll create a custom response without making a network request
|
||||||
connection.requestMethod = "OPTIONS"
|
// This is the most reliable way to handle CORS preflight
|
||||||
connection.setRequestProperty("Access-Control-Request-Method",
|
Log.d("CORS", "Handling OPTIONS preflight request for: $urlString")
|
||||||
request.requestHeaders["Access-Control-Request-Method"] ?: "GET, POST, OPTIONS")
|
|
||||||
connection.setRequestProperty("Access-Control-Request-Headers",
|
// Create CORS headers map
|
||||||
request.requestHeaders["Access-Control-Request-Headers"] ?: "")
|
val preflightHeaders = HashMap<String, String>()
|
||||||
} else {
|
preflightHeaders["Access-Control-Allow-Origin"] = "*"
|
||||||
// Set request method for non-OPTIONS requests
|
preflightHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE, HEAD"
|
||||||
connection.requestMethod = request.method
|
preflightHeaders["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization"
|
||||||
|
preflightHeaders["Access-Control-Max-Age"] = "86400" // Cache preflight for 24 hours
|
||||||
|
preflightHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
preflightHeaders["Content-Type"] = "text/plain"
|
||||||
|
|
||||||
|
// Log CORS headers for debugging
|
||||||
|
Log.d("CORS", "Preflight response with CORS headers: $preflightHeaders")
|
||||||
|
|
||||||
|
// Return a custom preflight response without actually connecting
|
||||||
|
return WebResourceResponse(
|
||||||
|
"text/plain",
|
||||||
|
"UTF-8",
|
||||||
|
200,
|
||||||
|
"OK",
|
||||||
|
preflightHeaders,
|
||||||
|
ByteArrayInputStream("".toByteArray())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to connect
|
// Try to connect
|
||||||
@ -301,27 +354,50 @@ class MainActivity : AppCompatActivity() {
|
|||||||
connection.inputStream
|
connection.inputStream
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create response headers map with CORS headers
|
// Create response headers map with security headers
|
||||||
val responseHeaders = HashMap<String, String>()
|
val responseHeaders = HashMap<String, String>()
|
||||||
|
|
||||||
// Add CORS headers
|
// First copy original response headers, but carefully handle CORS headers
|
||||||
responseHeaders["Access-Control-Allow-Origin"] = "*"
|
|
||||||
responseHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
||||||
responseHeaders["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept"
|
|
||||||
responseHeaders["Access-Control-Allow-Credentials"] = "true"
|
|
||||||
|
|
||||||
// Copy original response headers
|
|
||||||
for (i in 0 until connection.headerFields.size) {
|
for (i in 0 until connection.headerFields.size) {
|
||||||
val key = connection.headerFields.keys.elementAtOrNull(i)
|
val key = connection.headerFields.keys.elementAtOrNull(i)
|
||||||
if (key != null) {
|
if (key != null && key.isNotEmpty()) {
|
||||||
val value = connection.getHeaderField(key)
|
// Skip any CORS headers from the original response - we'll add our own
|
||||||
if (value != null) {
|
if (!key.startsWith("Access-Control-")) {
|
||||||
responseHeaders[key] = value
|
val value = connection.getHeaderField(key)
|
||||||
|
if (value != null) {
|
||||||
|
responseHeaders[key] = value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Log any CORS headers we're skipping from the original response
|
||||||
|
Log.d("CORS", "Skipping original CORS header: $key: ${connection.getHeaderField(key)}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return proxied response with CORS headers
|
// Add our own CORS headers
|
||||||
|
responseHeaders["Access-Control-Allow-Origin"] = "*"
|
||||||
|
responseHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE, HEAD"
|
||||||
|
responseHeaders["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization"
|
||||||
|
responseHeaders["Access-Control-Allow-Credentials"] = "true"
|
||||||
|
if (!responseHeaders.containsKey("Content-Security-Policy")) {
|
||||||
|
responseHeaders["Content-Security-Policy"] = CONTENT_SECURITY_POLICY
|
||||||
|
}
|
||||||
|
if (!responseHeaders.containsKey("X-Content-Type-Options")) {
|
||||||
|
responseHeaders["X-Content-Type-Options"] = "nosniff"
|
||||||
|
}
|
||||||
|
if (!responseHeaders.containsKey("X-Frame-Options")) {
|
||||||
|
responseHeaders["X-Frame-Options"] = "DENY"
|
||||||
|
}
|
||||||
|
if (!responseHeaders.containsKey("Referrer-Policy")) {
|
||||||
|
responseHeaders["Referrer-Policy"] = "no-referrer"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the CORS headers for debugging
|
||||||
|
responseHeaders["Access-Control-Allow-Origin"]?.let {
|
||||||
|
Log.d("CORS", "Access-Control-Allow-Origin: $it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return proxied response with security headers
|
||||||
return WebResourceResponse(
|
return WebResourceResponse(
|
||||||
mimeType,
|
mimeType,
|
||||||
encoding,
|
encoding,
|
||||||
@ -343,11 +419,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("TorProxy", "Error proxying request: $urlString - ${e.message}", e)
|
Log.e("TorProxy", "Error proxying request: $urlString - ${e.message}", e)
|
||||||
|
|
||||||
// For non-onion domains, let the system handle it
|
// For security, block the request rather than falling back to system handling
|
||||||
return super.shouldInterceptRequest(view, request)
|
return WebResourceResponse("text/plain", "UTF-8", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We're not handling SSL, so we don't need the onReceivedSslError method
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
// Verify Tor is still connected before allowing any request
|
// Verify Tor is still connected before allowing any request
|
||||||
if (!torKmp.isConnected()) {
|
if (!torKmp.isConnected()) {
|
||||||
@ -450,6 +528,117 @@ class MainActivity : AppCompatActivity() {
|
|||||||
/**
|
/**
|
||||||
* Sets the proxy for WebView using the most direct approach that's known to work with Tor
|
* Sets the proxy for WebView using the most direct approach that's known to work with Tor
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Configure WebView settings with a security-first approach
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
private fun secureWebViewSettings() {
|
||||||
|
val webSettings = webView.settings
|
||||||
|
|
||||||
|
// --- SECURITY SETTINGS ---
|
||||||
|
|
||||||
|
// 1. JavaScript is required for the app to function, but we restrict it
|
||||||
|
webSettings.javaScriptEnabled = true // Required, but we'll restrict its capabilities
|
||||||
|
|
||||||
|
// 2. Disable features that could lead to data leakage
|
||||||
|
webSettings.saveFormData = false
|
||||||
|
webSettings.savePassword = false
|
||||||
|
webSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||||
|
webSettings.setGeolocationEnabled(false)
|
||||||
|
|
||||||
|
// 3. Disable database access
|
||||||
|
webSettings.databaseEnabled = false
|
||||||
|
webSettings.domStorageEnabled = true // Required for most modern web apps
|
||||||
|
|
||||||
|
// 4. File access settings - must allow cross-origin access for our use case
|
||||||
|
webSettings.allowFileAccess = true // Needed for loading internal HTML
|
||||||
|
webSettings.allowContentAccess = false
|
||||||
|
webSettings.allowFileAccessFromFileURLs = true // Required for local HTML to work
|
||||||
|
webSettings.allowUniversalAccessFromFileURLs = true // Required to allow CORS from file:// to onion URLs
|
||||||
|
|
||||||
|
// Log these critical settings for debugging
|
||||||
|
Log.d("WebViewSettings", "allowFileAccessFromFileURLs: ${webSettings.allowFileAccessFromFileURLs}")
|
||||||
|
Log.d("WebViewSettings", "allowUniversalAccessFromFileURLs: ${webSettings.allowUniversalAccessFromFileURLs}")
|
||||||
|
|
||||||
|
// 5. Disable potentially risky features
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
webSettings.javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
}
|
||||||
|
webSettings.setSupportMultipleWindows(false)
|
||||||
|
|
||||||
|
// 6. Set secure mixed content mode
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Disable plugins (none needed for our app)
|
||||||
|
webSettings.pluginState = WebSettings.PluginState.OFF
|
||||||
|
|
||||||
|
// 8. Configure cookies for security
|
||||||
|
val cookieManager = CookieManager.getInstance()
|
||||||
|
cookieManager.setAcceptCookie(true) // We need cookies for the app to function
|
||||||
|
cookieManager.setAcceptThirdPartyCookies(webView, false) // Block 3rd party cookies
|
||||||
|
|
||||||
|
// 10. Disable Service Workers (not needed for our local app)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
ServiceWorkerController.getInstance().setServiceWorkerClient(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- USABILITY SETTINGS ---
|
||||||
|
|
||||||
|
// Allow zooming for better accessibility
|
||||||
|
webSettings.setSupportZoom(true)
|
||||||
|
webSettings.builtInZoomControls = true
|
||||||
|
webSettings.displayZoomControls = false
|
||||||
|
|
||||||
|
// Improve display for better Android integration
|
||||||
|
webSettings.loadWithOverviewMode = true
|
||||||
|
webSettings.useWideViewPort = true
|
||||||
|
webSettings.textZoom = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL request is allowed based on security policy
|
||||||
|
*/
|
||||||
|
private fun isAllowedRequest(url: String): Boolean {
|
||||||
|
// Always allow local file requests
|
||||||
|
if (url.startsWith("file:///android_asset/") || url.startsWith("file:///data/")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow onion domains
|
||||||
|
if (ALLOWED_DOMAINS.any { url.contains(it) }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block everything else
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSL error description method removed as we're not using SSL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all WebView data when activity is destroyed
|
||||||
|
*/
|
||||||
|
override fun onDestroy() {
|
||||||
|
// Clear all cookies, cache, and WebView data for privacy
|
||||||
|
CookieManager.getInstance().removeAllCookies(null)
|
||||||
|
CookieManager.getInstance().flush()
|
||||||
|
|
||||||
|
webView.clearCache(true)
|
||||||
|
webView.clearHistory()
|
||||||
|
webView.clearFormData()
|
||||||
|
webView.clearSslPreferences()
|
||||||
|
|
||||||
|
WebStorage.getInstance().deleteAllData()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
CookieManager.getInstance().removeSessionCookies(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
private fun setWebViewProxy(context: Context, proxyHost: String, proxyPort: Int) {
|
private fun setWebViewProxy(context: Context, proxyHost: String, proxyPort: Int) {
|
||||||
try {
|
try {
|
||||||
// First set system properties (required as a foundation)
|
// First set system properties (required as a foundation)
|
||||||
|
|||||||
@ -6,11 +6,28 @@ import android.webkit.JavascriptInterface
|
|||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.robosats.tor.TorKmpManager.getTorKmpObject
|
import com.robosats.tor.TorKmpManager.getTorKmpObject
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.text.TextUtils
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a secure bridge between JavaScript and native Android code.
|
||||||
|
* This class is designed with security in mind, implementing input validation,
|
||||||
|
* sanitization, and proper error handling.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
class WebAppInterface(private val context: Context, private val webView: WebView) {
|
class WebAppInterface(private val context: Context, private val webView: WebView) {
|
||||||
private val TAG = "WebAppInterface"
|
private val TAG = "WebAppInterface"
|
||||||
private val roboIdentities = RoboIdentities()
|
private val roboIdentities = RoboIdentities()
|
||||||
|
|
||||||
|
// Security patterns for input validation
|
||||||
|
private val UUID_PATTERN = Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", Pattern.CASE_INSENSITIVE)
|
||||||
|
private val SAFE_STRING_PATTERN = Pattern.compile("^[a-zA-Z0-9\\s_\\-.,:;!?()\\[\\]{}]*$")
|
||||||
|
|
||||||
|
// Maximum length for input strings
|
||||||
|
private val MAX_INPUT_LENGTH = 1000
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Check if libraries are loaded and show a toast notification if there's an issue
|
// Check if libraries are loaded and show a toast notification if there's an issue
|
||||||
if (!RoboIdentities.areLibrariesLoaded()) {
|
if (!RoboIdentities.areLibrariesLoaded()) {
|
||||||
@ -23,70 +40,198 @@ class WebAppInterface(private val context: Context, private val webView: WebView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a string contains only safe characters and is within length limits
|
||||||
|
*/
|
||||||
|
private fun isValidInput(input: String?, maxLength: Int = MAX_INPUT_LENGTH): Boolean {
|
||||||
|
if (input == null || input.isEmpty() || input.length > maxLength) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return SAFE_STRING_PATTERN.matcher(input).matches()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a string is a valid UUID
|
||||||
|
*/
|
||||||
|
private fun isValidUuid(uuid: String?): Boolean {
|
||||||
|
if (uuid == null || uuid.isEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return UUID_PATTERN.matcher(uuid).matches()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely evaluates JavaScript, escaping any potentially dangerous characters
|
||||||
|
*/
|
||||||
|
private fun safeEvaluateJavascript(script: String) {
|
||||||
|
// Remove any null bytes which could be used to trick the JS interpreter
|
||||||
|
val sanitizedScript = script.replace("\u0000", "")
|
||||||
|
|
||||||
|
webView.post {
|
||||||
|
try {
|
||||||
|
webView.evaluateJavascript(sanitizedScript, null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error evaluating JavaScript: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely encodes a string for use in JavaScript
|
||||||
|
*/
|
||||||
|
private fun encodeForJavaScript(input: String): String {
|
||||||
|
return input.replace("\\", "\\\\")
|
||||||
|
.replace("'", "\\'")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("<", "\\u003C")
|
||||||
|
.replace(">", "\\u003E")
|
||||||
|
.replace("&", "\\u0026")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a robot name from the given message
|
||||||
|
* @param uuid A unique identifier for the JavaScript Promise
|
||||||
|
* @param message The input message to generate a robot name from
|
||||||
|
*/
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun generateRoboname(uuid: String, message: String) {
|
fun generateRoboname(uuid: String, message: String) {
|
||||||
|
// Validate inputs before processing
|
||||||
|
if (!isValidUuid(uuid) || !isValidInput(message)) {
|
||||||
|
Log.e(TAG, "Invalid input for generateRoboname: uuid=$uuid, message=$message")
|
||||||
|
rejectPromise(uuid, "Invalid input parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val roboname = roboIdentities.generateRoboname(message)
|
// Sanitize the input before passing to native code
|
||||||
webView.post {
|
val sanitizedMessage = message.trim()
|
||||||
webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${roboname}')", null)
|
|
||||||
}
|
// Generate the roboname
|
||||||
|
val roboname = roboIdentities.generateRoboname(sanitizedMessage)
|
||||||
|
|
||||||
|
// Safely encode and return the result
|
||||||
|
resolvePromise(uuid, roboname ?: "")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error in generateRoboname", e)
|
Log.e(TAG, "Error in generateRoboname", e)
|
||||||
|
rejectPromise(uuid, "Error generating robot name")
|
||||||
// Handle error gracefully by returning a fallback value
|
|
||||||
webView.post {
|
|
||||||
webView.evaluateJavascript(
|
|
||||||
"javascript:window.AndroidRobosats.onRejectPromise('${uuid}', 'Error generating robot name')",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to safely resolve a JavaScript Promise
|
||||||
|
*/
|
||||||
|
private fun resolvePromise(uuid: String, result: String) {
|
||||||
|
if (!isValidUuid(uuid)) {
|
||||||
|
Log.e(TAG, "Invalid UUID for promise resolution: $uuid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val encodedResult = encodeForJavaScript(result)
|
||||||
|
safeEvaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('$uuid', '$encodedResult')")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to safely reject a JavaScript Promise
|
||||||
|
*/
|
||||||
|
private fun rejectPromise(uuid: String, errorMessage: String) {
|
||||||
|
if (!isValidUuid(uuid)) {
|
||||||
|
Log.e(TAG, "Invalid UUID for promise rejection: $uuid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val encodedError = encodeForJavaScript(errorMessage)
|
||||||
|
safeEvaluateJavascript("javascript:window.AndroidRobosats.onRejectPromise('$uuid', '$encodedError')")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a robot hash from the given message
|
||||||
|
* @param uuid A unique identifier for the JavaScript Promise
|
||||||
|
* @param message The input message to generate a robot hash from
|
||||||
|
*/
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun generateRobohash(uuid: String, message: String) {
|
fun generateRobohash(uuid: String, message: String) {
|
||||||
|
// Validate inputs before processing
|
||||||
|
if (!isValidUuid(uuid) || !isValidInput(message)) {
|
||||||
|
Log.e(TAG, "Invalid input for generateRobohash: uuid=$uuid, message=$message")
|
||||||
|
rejectPromise(uuid, "Invalid input parameters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val roboname = roboIdentities.generateRobohash(message)
|
// Sanitize the input before passing to native code
|
||||||
webView.post {
|
val sanitizedMessage = message.trim()
|
||||||
webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${roboname}')", null)
|
|
||||||
}
|
// Generate the robohash
|
||||||
|
val robohash = roboIdentities.generateRobohash(sanitizedMessage)
|
||||||
|
|
||||||
|
// Safely encode and return the result
|
||||||
|
resolvePromise(uuid, robohash ?: "")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error in generateRobohash", e)
|
Log.e(TAG, "Error in generateRobohash", e)
|
||||||
|
rejectPromise(uuid, "Error generating robot hash")
|
||||||
// Handle error gracefully by returning a fallback value
|
|
||||||
webView.post {
|
|
||||||
webView.evaluateJavascript(
|
|
||||||
"javascript:window.AndroidRobosats.onRejectPromise('${uuid}', 'Error generating robot hash')",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to the clipboard
|
||||||
|
* @param message The text to copy to the clipboard
|
||||||
|
*/
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun copyToClipboard(message: String) {
|
fun copyToClipboard(message: String) {
|
||||||
|
// Validate input
|
||||||
|
if (!isValidInput(message, 10000)) { // Allow longer text for clipboard
|
||||||
|
Log.e(TAG, "Invalid input for copyToClipboard")
|
||||||
|
Toast.makeText(context, "Invalid content for clipboard", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Limit clipboard content size for security
|
||||||
|
val truncatedMessage = if (message.length > 10000) {
|
||||||
|
message.substring(0, 10000) + "... (content truncated for security)"
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||||
val clip = android.content.ClipData.newPlainText("RoboSats Data", message)
|
val clip = android.content.ClipData.newPlainText("RoboSats Data", truncatedMessage)
|
||||||
clipboard.setPrimaryClip(clip)
|
clipboard.setPrimaryClip(clip)
|
||||||
|
|
||||||
// Show a toast notification
|
// Show a toast notification
|
||||||
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
// Log the action
|
// Log the action (don't log the content for privacy)
|
||||||
Log.d(TAG, "Text copied to clipboard")
|
Log.d(TAG, "Text copied to clipboard (${truncatedMessage.length} chars)")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error copying to clipboard", e)
|
Log.e(TAG, "Error copying to clipboard", e)
|
||||||
Toast.makeText(context, "Failed to copy to clipboard", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Failed to copy to clipboard", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current Tor connection status
|
||||||
|
* @param uuid A unique identifier for the JavaScript Promise
|
||||||
|
*/
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun getTorStatus(uuid: String) {
|
fun getTorStatus(uuid: String) {
|
||||||
val torState = getTorKmpObject().torState.state.name
|
// Validate UUID
|
||||||
|
if (!isValidUuid(uuid)) {
|
||||||
|
Log.e(TAG, "Invalid UUID for getTorStatus: $uuid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
webView.post {
|
try {
|
||||||
webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${torState}')", null)
|
// Get Tor status safely
|
||||||
|
val torState = getTorKmpObject().torState.state.name
|
||||||
|
|
||||||
|
// Return the status through the secure promise resolution
|
||||||
|
resolvePromise(uuid, torState)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting Tor status", e)
|
||||||
|
rejectPromise(uuid, "Error retrieving Tor status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user