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:
Claude
2026-04-21 18:13:35 +00:00
parent 38adeec215
commit e5913e2cf3
7 changed files with 369 additions and 6 deletions
+1
View File
@@ -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
}
}
}
+2
View File
@@ -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())
}
}