Security checks

This commit is contained in:
koalasat
2025-07-16 19:13:21 +02:00
parent 058de9f765
commit ef08cf2427
3 changed files with 426 additions and 92 deletions

View File

@ -161,7 +161,7 @@ export interface UseAppStoreType {
export const initialAppContext: UseAppStoreType = {
theme: undefined,
torStatus: 'STARTING',
torStatus: 'ON',
settings: getSettings(),
setSettings: () => {},
page: entryPage,

View File

@ -1,17 +1,29 @@
package com.robosats
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.Log
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.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import com.robosats.R
import com.robosats.tor.TorKmp
import com.robosats.tor.TorKmpManager
import java.io.ByteArrayInputStream
@ -26,6 +38,13 @@ class MainActivity : AppCompatActivity() {
private lateinit var loadingContainer: ConstraintLayout
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?) {
super.onCreate(savedInstanceState)
@ -152,40 +171,8 @@ class MainActivity : AppCompatActivity() {
}
}
// Configure WebView settings on UI thread
val webSettings = webView.settings
// 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
// Configure WebView settings on UI thread with security as priority
secureWebViewSettings()
// Show message that we're setting up secure browsing
runOnUiThread {
@ -222,6 +209,30 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
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
webView.webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
@ -234,10 +245,22 @@ class MainActivity : AppCompatActivity() {
val urlString = request.url.toString()
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 {
// Special handling for .onion domains
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
val proxyType = if (isOnionDomain)
Proxy.Type.SOCKS
@ -254,6 +277,12 @@ class MainActivity : AppCompatActivity() {
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
val url = URL(urlString)
val connection = url.openConnection(torProxy)
@ -266,22 +295,46 @@ class MainActivity : AppCompatActivity() {
// Ensure no connection reuse to prevent proxy leaks
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
request.requestHeaders.forEach { (key, value) ->
connection.setRequestProperty(key, value)
}
// Set the request method
connection.requestMethod = request.method
// Special handling for OPTIONS (CORS preflight) requests
if (request.method == "OPTIONS") {
// Handle preflight CORS request
connection.requestMethod = "OPTIONS"
connection.setRequestProperty("Access-Control-Request-Method",
request.requestHeaders["Access-Control-Request-Method"] ?: "GET, POST, OPTIONS")
connection.setRequestProperty("Access-Control-Request-Headers",
request.requestHeaders["Access-Control-Request-Headers"] ?: "")
} else {
// Set request method for non-OPTIONS requests
connection.requestMethod = request.method
// For OPTIONS, we'll create a custom response without making a network request
// This is the most reliable way to handle CORS preflight
Log.d("CORS", "Handling OPTIONS preflight request for: $urlString")
// Create CORS headers map
val preflightHeaders = HashMap<String, String>()
preflightHeaders["Access-Control-Allow-Origin"] = "*"
preflightHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE, HEAD"
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
@ -301,27 +354,50 @@ class MainActivity : AppCompatActivity() {
connection.inputStream
}
// Create response headers map with CORS headers
// Create response headers map with security headers
val responseHeaders = HashMap<String, String>()
// Add 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
// First copy original response headers, but carefully handle CORS headers
for (i in 0 until connection.headerFields.size) {
val key = connection.headerFields.keys.elementAtOrNull(i)
if (key != null) {
val value = connection.getHeaderField(key)
if (value != null) {
responseHeaders[key] = value
if (key != null && key.isNotEmpty()) {
// Skip any CORS headers from the original response - we'll add our own
if (!key.startsWith("Access-Control-")) {
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(
mimeType,
encoding,
@ -343,11 +419,13 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) {
Log.e("TorProxy", "Error proxying request: $urlString - ${e.message}", e)
// For non-onion domains, let the system handle it
return super.shouldInterceptRequest(view, request)
// For security, block the request rather than falling back to system handling
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 {
// Verify Tor is still connected before allowing any request
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
*/
/**
* 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) {
try {
// First set system properties (required as a foundation)

View File

@ -6,11 +6,28 @@ import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
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) {
private val TAG = "WebAppInterface"
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 {
// Check if libraries are loaded and show a toast notification if there's an issue
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
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 {
val roboname = roboIdentities.generateRoboname(message)
webView.post {
webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${roboname}')", null)
}
// Sanitize the input before passing to native code
val sanitizedMessage = message.trim()
// Generate the roboname
val roboname = roboIdentities.generateRoboname(sanitizedMessage)
// Safely encode and return the result
resolvePromise(uuid, roboname ?: "")
} catch (e: Exception) {
Log.e(TAG, "Error in generateRoboname", e)
// Handle error gracefully by returning a fallback value
webView.post {
webView.evaluateJavascript(
"javascript:window.AndroidRobosats.onRejectPromise('${uuid}', 'Error generating robot name')",
null
)
}
rejectPromise(uuid, "Error generating robot name")
}
}
/**
* 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
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 {
val roboname = roboIdentities.generateRobohash(message)
webView.post {
webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${roboname}')", null)
}
// Sanitize the input before passing to native code
val sanitizedMessage = message.trim()
// Generate the robohash
val robohash = roboIdentities.generateRobohash(sanitizedMessage)
// Safely encode and return the result
resolvePromise(uuid, robohash ?: "")
} catch (e: Exception) {
Log.e(TAG, "Error in generateRobohash", e)
// Handle error gracefully by returning a fallback value
webView.post {
webView.evaluateJavascript(
"javascript:window.AndroidRobosats.onRejectPromise('${uuid}', 'Error generating robot hash')",
null
)
}
rejectPromise(uuid, "Error generating robot hash")
}
}
/**
* Copy text to the clipboard
* @param message The text to copy to the clipboard
*/
@JavascriptInterface
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 {
// 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 clip = android.content.ClipData.newPlainText("RoboSats Data", message)
val clip = android.content.ClipData.newPlainText("RoboSats Data", truncatedMessage)
clipboard.setPrimaryClip(clip)
// Show a toast notification
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
// Log the action
Log.d(TAG, "Text copied to clipboard")
// Log the action (don't log the content for privacy)
Log.d(TAG, "Text copied to clipboard (${truncatedMessage.length} chars)")
} catch (e: Exception) {
Log.e(TAG, "Error copying to clipboard", e)
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
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 {
webView.evaluateJavascript("javascript:window.AndroidRobosats.onResolvePromise('${uuid}', '${torState}')", null)
try {
// 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")
}
}
}