mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 11:28:22 +00:00
Rate-limit incoming intents per (caller, type, kind)
A misbehaving caller could previously spawn many SignerActivity instances by
sending nostrsigner:// intents in a burst, stacking up approval screens.
Two complementary changes collapse this:
- SignerActivity now declares launchMode=singleTask, so external intents
merge into the existing instance via onNewIntent (batch approval) rather
than creating new tasks.
- A lightweight in-memory sliding-window rate limiter gates only the new-
screen path (onCreate). Buckets are keyed on (packageName, type, kind?),
so an app flooding one event kind does not exhaust budgets for others.
onNewIntent (batch merge) is intentionally exempt.
Defaults: 30 requests / 10 s per bucket; 5 / 10 s for unknown callers.
Tunable via AmberSettings.rateLimit{Enabled,MaxPerWindow,WindowSeconds}.
Toast feedback is throttled to at most once per bucket per window.
https://claude.ai/code/session_01BKBb8ahDx1xKWH6GAqJSM6
This commit is contained in:
@@ -86,6 +86,7 @@
|
||||
<activity
|
||||
android:name=".SignerActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.NostrSigner.Modal">
|
||||
<intent-filter>
|
||||
|
||||
@@ -66,6 +66,9 @@ private enum class SettingsKeys(val key: String) {
|
||||
WEBDAV_PASSWORD_ENCRYPTED("webdav_password_encrypted"),
|
||||
WEBDAV_FILENAME("webdav_filename"),
|
||||
GDRIVE_FOLDER_URI("gdrive_folder_uri"),
|
||||
RATE_LIMIT_ENABLED("rate_limit_enabled"),
|
||||
RATE_LIMIT_MAX_PER_WINDOW("rate_limit_max_per_window"),
|
||||
RATE_LIMIT_WINDOW_SECONDS("rate_limit_window_seconds"),
|
||||
}
|
||||
|
||||
@Immutable
|
||||
@@ -141,6 +144,9 @@ object LocalPreferences {
|
||||
putString(SettingsKeys.LANGUAGE_PREFS.key, settings.language)
|
||||
putStringSet(SettingsKeys.AUTH_WHITELIST.key, settings.authWhitelist.toSet())
|
||||
putBoolean(SettingsKeys.START_SERVICE_ON_BOOT.key, settings.startServiceOnBoot)
|
||||
putBoolean(SettingsKeys.RATE_LIMIT_ENABLED.key, settings.rateLimitEnabled)
|
||||
putInt(SettingsKeys.RATE_LIMIT_MAX_PER_WINDOW.key, settings.rateLimitMaxPerWindow)
|
||||
putInt(SettingsKeys.RATE_LIMIT_WINDOW_SECONDS.key, settings.rateLimitWindowSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +263,9 @@ object LocalPreferences {
|
||||
UpdateCheckFrequency.DAILY
|
||||
},
|
||||
startServiceOnBoot = getBoolean(SettingsKeys.START_SERVICE_ON_BOOT.key, true),
|
||||
rateLimitEnabled = getBoolean(SettingsKeys.RATE_LIMIT_ENABLED.key, true),
|
||||
rateLimitMaxPerWindow = getInt(SettingsKeys.RATE_LIMIT_MAX_PER_WINDOW.key, 30),
|
||||
rateLimitWindowSeconds = getInt(SettingsKeys.RATE_LIMIT_WINDOW_SECONDS.key, 10),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -35,6 +36,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
|
||||
import com.greenart7c3.nostrsigner.service.BunkerRequestUtils
|
||||
import com.greenart7c3.nostrsigner.service.IntentRateLimitInspector
|
||||
import com.greenart7c3.nostrsigner.service.IntentRateLimiter
|
||||
import com.greenart7c3.nostrsigner.service.IntentUtils
|
||||
import com.greenart7c3.nostrsigner.ui.AccountScreen
|
||||
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
|
||||
@@ -59,6 +62,11 @@ class SignerActivity : AppCompatActivity() {
|
||||
Amber.isAppInForeground = true
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Rate-limited per (caller, type, kind). onNewIntent is intentionally exempt —
|
||||
// it is the batch merge into an already-open approval screen.
|
||||
if (rejectIfRateLimited(intent)) return
|
||||
|
||||
Amber.instance.setMainActivity(this)
|
||||
mainViewModel = MainViewModel(applicationContext)
|
||||
intent?.let { mainViewModel.onNewIntent(it, callingPackage) }
|
||||
@@ -189,7 +197,9 @@ class SignerActivity : AppCompatActivity() {
|
||||
Amber.isAppInForeground = true
|
||||
Amber.instance.setMainActivity(this)
|
||||
Amber.instance.startServiceFromUi()
|
||||
mainViewModel.showBunkerRequests()
|
||||
if (::mainViewModel.isInitialized) {
|
||||
mainViewModel.showBunkerRequests()
|
||||
}
|
||||
if (!BuildFlavorChecker.isOfflineFlavor()) {
|
||||
val connectivityManager =
|
||||
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
|
||||
@@ -203,15 +213,46 @@ class SignerActivity : AppCompatActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
mainViewModel.onNewIntent(intent, callingPackage)
|
||||
if (::mainViewModel.isInitialized) {
|
||||
mainViewModel.onNewIntent(intent, callingPackage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val ownedIds = mainViewModel.addedIntentIds
|
||||
val toRemove = IntentUtils.intents.value.filter { it.id in ownedIds }
|
||||
if (toRemove.isNotEmpty()) {
|
||||
IntentUtils.removeAll(toRemove)
|
||||
val ownedIds = if (::mainViewModel.isInitialized) mainViewModel.addedIntentIds else emptySet()
|
||||
if (ownedIds.isNotEmpty()) {
|
||||
val toRemove = IntentUtils.intents.value.filter { it.id in ownedIds }
|
||||
if (toRemove.isNotEmpty()) {
|
||||
IntentUtils.removeAll(toRemove)
|
||||
}
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun rejectIfRateLimited(intent: Intent?): Boolean {
|
||||
if (intent == null) return false
|
||||
val key = IntentRateLimitInspector.inspect(intent, callingPackage) ?: return false
|
||||
if (IntentRateLimiter.checkAndRecord(key)) return false
|
||||
|
||||
if (IntentRateLimiter.shouldShowToast(key)) {
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
getString(R.string.rate_limit_too_many_requests, resolveCallerLabel(callingPackage)),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
Log.w(Amber.TAG, "Rate-limited intent from ${key.pkg} type=${key.type} kind=${key.kind}")
|
||||
finishAndRemoveTask()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun resolveCallerLabel(pkg: String?): String {
|
||||
if (pkg.isNullOrBlank()) return getString(R.string.rate_limit_unknown_app)
|
||||
return try {
|
||||
val info = applicationContext.packageManager.getApplicationInfo(pkg, 0)
|
||||
applicationContext.packageManager.getApplicationLabel(info).toString()
|
||||
} catch (_: Exception) {
|
||||
pkg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ data class AmberSettings(
|
||||
val autoCheckUpdates: Boolean = true,
|
||||
val updateCheckFrequency: UpdateCheckFrequency = UpdateCheckFrequency.DAILY,
|
||||
val startServiceOnBoot: Boolean = true,
|
||||
val rateLimitEnabled: Boolean = true,
|
||||
val rateLimitMaxPerWindow: Int = 30,
|
||||
val rateLimitWindowSeconds: Int = 10,
|
||||
) {
|
||||
val useProxy: Boolean get() = torMode != TorMode.DISABLED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.greenart7c3.nostrsigner.service
|
||||
|
||||
import android.content.Intent
|
||||
import com.greenart7c3.nostrsigner.Amber
|
||||
import com.greenart7c3.nostrsigner.models.SignerType
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import java.net.URLDecoder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Per-caller sliding-window rate limiter for incoming `nostrsigner://` intents.
|
||||
*
|
||||
* Gated only at [com.greenart7c3.nostrsigner.SignerActivity.onCreate] (the "new screen" path).
|
||||
* Intents that merge into an already-open approval UI via `onNewIntent` are the legitimate
|
||||
* batch flow and are never throttled here.
|
||||
*/
|
||||
object IntentRateLimiter {
|
||||
const val UNKNOWN_PACKAGE = "<unknown>"
|
||||
private const val UNKNOWN_PACKAGE_LIMIT = 5
|
||||
private const val CLEANUP_WHEN_SIZE_EXCEEDS = 256
|
||||
private const val CLEANUP_STALE_MS = 60L * 60L * 1000L
|
||||
|
||||
data class BucketKey(
|
||||
val pkg: String,
|
||||
val type: String,
|
||||
val kind: Int? = null,
|
||||
)
|
||||
|
||||
internal data class Config(
|
||||
val enabled: Boolean = true,
|
||||
val maxPerWindow: Int = 30,
|
||||
val windowSeconds: Int = 10,
|
||||
)
|
||||
|
||||
private val buckets = ConcurrentHashMap<BucketKey, ArrayDeque<Long>>()
|
||||
private val lastToastAt = ConcurrentHashMap<BucketKey, Long>()
|
||||
|
||||
@Volatile
|
||||
private var clock: () -> Long = { System.currentTimeMillis() }
|
||||
|
||||
@Volatile
|
||||
private var configOverride: Config? = null
|
||||
|
||||
internal fun setClockForTest(source: () -> Long) {
|
||||
clock = source
|
||||
}
|
||||
|
||||
internal fun setConfigForTest(config: Config?) {
|
||||
configOverride = config
|
||||
}
|
||||
|
||||
internal fun resetForTest() {
|
||||
buckets.clear()
|
||||
lastToastAt.clear()
|
||||
clock = { System.currentTimeMillis() }
|
||||
configOverride = null
|
||||
}
|
||||
|
||||
fun checkAndRecord(key: BucketKey): Boolean {
|
||||
val cfg = currentConfig()
|
||||
if (!cfg.enabled) return true
|
||||
|
||||
val now = clock()
|
||||
val windowMs = cfg.windowSeconds.toLong() * 1000L
|
||||
val limit = if (key.pkg == UNKNOWN_PACKAGE) UNKNOWN_PACKAGE_LIMIT else cfg.maxPerWindow
|
||||
|
||||
val deque = buckets.getOrPut(key) { ArrayDeque() }
|
||||
val allowed = synchronized(deque) {
|
||||
while (deque.isNotEmpty() && deque.first() <= now - windowMs) {
|
||||
deque.removeFirst()
|
||||
}
|
||||
if (deque.size >= limit) {
|
||||
false
|
||||
} else {
|
||||
deque.addLast(now)
|
||||
true
|
||||
}
|
||||
}
|
||||
maybeCleanup(now)
|
||||
return allowed
|
||||
}
|
||||
|
||||
fun shouldShowToast(key: BucketKey): Boolean {
|
||||
val cfg = currentConfig()
|
||||
val windowMs = cfg.windowSeconds.toLong() * 1000L
|
||||
val now = clock()
|
||||
val prev = lastToastAt[key]
|
||||
return if (prev == null || now - prev >= windowMs) {
|
||||
lastToastAt[key] = now
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentConfig(): Config {
|
||||
configOverride?.let { return it }
|
||||
return try {
|
||||
val s = Amber.instance.settings
|
||||
Config(s.rateLimitEnabled, s.rateLimitMaxPerWindow, s.rateLimitWindowSeconds)
|
||||
} catch (_: Throwable) {
|
||||
Config()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeCleanup(now: Long) {
|
||||
if (buckets.size <= CLEANUP_WHEN_SIZE_EXCEEDS) return
|
||||
val iterator = buckets.entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
val newest = synchronized(entry.value) { entry.value.lastOrNull() ?: 0L }
|
||||
if (now - newest > CLEANUP_STALE_MS) {
|
||||
iterator.remove()
|
||||
lastToastAt.remove(entry.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extractor that derives a [IntentRateLimiter.BucketKey] from a raw [Intent]
|
||||
* cheaply enough to run on the UI thread at the top of `onCreate`. Returns `null` when the
|
||||
* intent cannot be classified — unclassifiable intents will be rejected downstream anyway.
|
||||
*/
|
||||
object IntentRateLimitInspector {
|
||||
fun inspect(intent: Intent, callingPackage: String?): IntentRateLimiter.BucketKey? {
|
||||
val pkg = callingPackage?.takeIf { it.isNotBlank() } ?: IntentRateLimiter.UNKNOWN_PACKAGE
|
||||
|
||||
val typeStr = intent.extras?.getString("type") ?: extractTypeFromUrl(intent)
|
||||
val type = typeStr?.let { parseSignerType(it) } ?: return null
|
||||
|
||||
val kind = if (type == SignerType.SIGN_EVENT) extractEventKind(intent) else null
|
||||
return IntentRateLimiter.BucketKey(pkg, type.name, kind)
|
||||
}
|
||||
|
||||
private fun parseSignerType(value: String): SignerType? = when (value) {
|
||||
"sign_message" -> SignerType.SIGN_MESSAGE
|
||||
"sign_event" -> SignerType.SIGN_EVENT
|
||||
"get_public_key" -> SignerType.GET_PUBLIC_KEY
|
||||
"nip04_encrypt" -> SignerType.NIP04_ENCRYPT
|
||||
"nip04_decrypt" -> SignerType.NIP04_DECRYPT
|
||||
"nip44_encrypt" -> SignerType.NIP44_ENCRYPT
|
||||
"nip44_decrypt" -> SignerType.NIP44_DECRYPT
|
||||
"decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun extractTypeFromUrl(intent: Intent): String? {
|
||||
val decoded = decodeDataSafely(intent) ?: return null
|
||||
val query = decoded.substringAfter('?', "")
|
||||
if (query.isEmpty()) return null
|
||||
for (pair in query.split('&')) {
|
||||
val idx = pair.indexOf('=')
|
||||
if (idx <= 0) continue
|
||||
if (pair.substring(0, idx) == "type") {
|
||||
val value = pair.substring(idx + 1)
|
||||
if (value.isNotBlank()) return value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractEventKind(intent: Intent): Int? {
|
||||
val decoded = decodeDataSafely(intent) ?: return null
|
||||
val json = decoded.substringBefore('?')
|
||||
if (json.isBlank() || !json.trimStart().startsWith('{')) return null
|
||||
return try {
|
||||
JacksonMapper.mapper.readTree(json).get("kind")?.asInt()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeDataSafely(intent: Intent): String? {
|
||||
val raw = intent.data?.toString() ?: return null
|
||||
val stripped = raw.removePrefix("nostrsigner:")
|
||||
return try {
|
||||
URLDecoder.decode(stripped.replace("+", "%2b"), "utf-8")
|
||||
} catch (_: Throwable) {
|
||||
stripped
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -775,4 +775,6 @@
|
||||
<string name="event_kind_37516">Geocache Listing</string>
|
||||
<string name="event_kind_36787">Music Track</string>
|
||||
<string name="event_kind_34139">Music Playlist</string>
|
||||
<string name="rate_limit_too_many_requests">Too many requests from %1$s — ignoring for now</string>
|
||||
<string name="rate_limit_unknown_app">an unknown app</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.greenart7c3.nostrsigner.service
|
||||
|
||||
import com.greenart7c3.nostrsigner.models.SignerType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class IntentRateLimiterTest {
|
||||
private var now = 0L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
IntentRateLimiter.resetForTest()
|
||||
IntentRateLimiter.setClockForTest { now }
|
||||
IntentRateLimiter.setConfigForTest(
|
||||
IntentRateLimiter.Config(enabled = true, maxPerWindow = 3, windowSeconds = 10),
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
IntentRateLimiter.resetForTest()
|
||||
}
|
||||
|
||||
private fun key(pkg: String = "com.example.app", type: String = SignerType.SIGN_EVENT.name, kind: Int? = null) = IntentRateLimiter.BucketKey(pkg, type, kind)
|
||||
|
||||
@Test
|
||||
fun `allows requests up to the limit and blocks after`() {
|
||||
val k = key()
|
||||
repeat(3) { assertTrue(IntentRateLimiter.checkAndRecord(k)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(k))
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(k))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sliding window drops expired timestamps`() {
|
||||
val k = key()
|
||||
repeat(3) { assertTrue(IntentRateLimiter.checkAndRecord(k)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(k))
|
||||
|
||||
now += 10_000L
|
||||
assertTrue(IntentRateLimiter.checkAndRecord(k))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different types use separate buckets`() {
|
||||
val signEvent = key(type = SignerType.SIGN_EVENT.name)
|
||||
val getPubKey = key(type = SignerType.GET_PUBLIC_KEY.name)
|
||||
|
||||
repeat(3) { assertTrue(IntentRateLimiter.checkAndRecord(signEvent)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(signEvent))
|
||||
|
||||
assertTrue(IntentRateLimiter.checkAndRecord(getPubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different event kinds use separate buckets for SIGN_EVENT`() {
|
||||
val kind1 = key(kind = 1)
|
||||
val kind22242 = key(kind = 22242)
|
||||
|
||||
repeat(3) { assertTrue(IntentRateLimiter.checkAndRecord(kind1)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(kind1))
|
||||
|
||||
repeat(3) { assertTrue(IntentRateLimiter.checkAndRecord(kind22242)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(kind22242))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different callers use separate buckets`() {
|
||||
val appA = key(pkg = "com.a")
|
||||
val appB = key(pkg = "com.b")
|
||||
|
||||
repeat(3) { assertTrue(IntentRateLimiter.checkAndRecord(appA)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(appA))
|
||||
assertTrue(IntentRateLimiter.checkAndRecord(appB))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown package bucket has a tighter limit`() {
|
||||
val unknown = key(pkg = IntentRateLimiter.UNKNOWN_PACKAGE)
|
||||
repeat(5) { assertTrue(IntentRateLimiter.checkAndRecord(unknown)) }
|
||||
assertFalse(IntentRateLimiter.checkAndRecord(unknown))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled flag short-circuits to allow everything`() {
|
||||
IntentRateLimiter.setConfigForTest(
|
||||
IntentRateLimiter.Config(enabled = false, maxPerWindow = 1, windowSeconds = 10),
|
||||
)
|
||||
val k = key()
|
||||
repeat(100) { assertTrue(IntentRateLimiter.checkAndRecord(k)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toast shown at most once per window per key`() {
|
||||
val k = key()
|
||||
assertTrue(IntentRateLimiter.shouldShowToast(k))
|
||||
assertFalse(IntentRateLimiter.shouldShowToast(k))
|
||||
|
||||
now += 10_000L
|
||||
assertTrue(IntentRateLimiter.shouldShowToast(k))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toast throttling is per key`() {
|
||||
val a = key(pkg = "com.a")
|
||||
val b = key(pkg = "com.b")
|
||||
assertTrue(IntentRateLimiter.shouldShowToast(a))
|
||||
assertTrue(IntentRateLimiter.shouldShowToast(b))
|
||||
assertFalse(IntentRateLimiter.shouldShowToast(a))
|
||||
assertFalse(IntentRateLimiter.shouldShowToast(b))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bucket key equality ignores reference identity`() {
|
||||
val a = IntentRateLimiter.BucketKey("pkg", SignerType.SIGN_EVENT.name, 1)
|
||||
val b = IntentRateLimiter.BucketKey("pkg", SignerType.SIGN_EVENT.name, 1)
|
||||
assertEquals(a, b)
|
||||
assertEquals(a.hashCode(), b.hashCode())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user