1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-04 05:50:10 +05:30

Fix 5 security issues flagged in review

1. Filter injection: escape quotes/backslashes in search query
   before interpolating into API filter parameter

2. Backup data leak: configure backup_rules.xml and
   data_extraction_rules.xml to exclude sharedprefs, database,
   and datastore files from cloud backup and device transfer

3. Cleartext traffic: add network_security_config.xml with
   cleartextTrafficPermitted=false, referenced from manifest

4. Debug logging: remove all Log.d() calls from
   TaskCheckWorker, DirectAlarmScheduler, TaskReminderReceiver
   that logged task content and scheduling details

5. Token obfuscation: XOR + Base64 obfuscation for credentials
   stored in DataStore. Prefixed with "OBF:" for seamless
   migration of existing plaintext values on next login.
   Not cryptographic — prevents casual file inspection.

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
Co-Authored-By: Claude Opus 4.6 (1M context)
This commit is contained in:
2026-05-22 17:35:37 +05:30
parent 44b00736db
commit b8d4f52e22
9 changed files with 45 additions and 44 deletions
+1
View File
@@ -12,6 +12,7 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:networkSecurityConfig="@xml/network_security_config"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -7,13 +7,10 @@ import com.avinal.memos.notifications.TaskNotificationManager
class TaskReminderReceiver : BroadcastReceiver() { class TaskReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
android.util.Log.d("TaskReminderReceiver", "onReceive fired!")
val taskText = intent.getStringExtra("task_text") ?: "Task reminder" val taskText = intent.getStringExtra("task_text") ?: "Task reminder"
val dueLabel = intent.getStringExtra("due_label") ?: "due" val dueLabel = intent.getStringExtra("due_label") ?: "due"
val notificationId = intent.getIntExtra("notification_id", 0) val notificationId = intent.getIntExtra("notification_id", 0)
val priority = intent.getIntExtra("priority", 0) val priority = intent.getIntExtra("priority", 0)
android.util.Log.d("TaskReminderReceiver", "Showing: $taskText - $dueLabel (p=$priority, id=$notificationId)")
TaskNotificationManager.createChannels(context) TaskNotificationManager.createChannels(context)
TaskNotificationManager.showTaskNotification( TaskNotificationManager.showTaskNotification(
context = context, context = context,
+4 -11
View File
@@ -1,13 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><!-- <?xml version="1.0" encoding="utf-8"?>
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content> <full-backup-content>
<!-- <exclude domain="sharedpref" path="." />
<include domain="sharedpref" path="."/> <exclude domain="database" path="." />
<exclude domain="sharedpref" path="device.xml"/> <exclude domain="file" path="datastore/" />
-->
</full-backup-content> </full-backup-content>
@@ -1,19 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!-- <?xml version="1.0" encoding="utf-8"?>
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules> <data-extraction-rules>
<cloud-backup> <cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up. <exclude domain="sharedpref" path="." />
<include .../> <exclude domain="database" path="." />
<exclude .../> <exclude domain="file" path="datastore/" />
-->
</cloud-backup> </cloud-backup>
<!--
<device-transfer> <device-transfer>
<include .../> <exclude domain="sharedpref" path="." />
<exclude .../> <exclude domain="database" path="." />
<exclude domain="file" path="datastore/" />
</device-transfer> </device-transfer>
-->
</data-extraction-rules> </data-extraction-rules>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>
@@ -48,7 +48,5 @@ object DirectAlarmScheduler {
alarmManager.set(AlarmManager.RTC_WAKEUP, alarm.triggerAtMillis, pendingIntent) alarmManager.set(AlarmManager.RTC_WAKEUP, alarm.triggerAtMillis, pendingIntent)
} }
} }
android.util.Log.d("DirectAlarmScheduler", "Scheduled ${alarms.size} alarms from ${memos.size} memos")
} }
} }
@@ -50,17 +50,9 @@ class TaskCheckWorker(
val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val tz = TimeZone.currentSystemDefault() val tz = TimeZone.currentSystemDefault()
android.util.Log.d("TaskCheckWorker", "Found ${memos.size} memos, ${allTasks.size} tasks, scheduled=${scheduledIds.size}")
allTasks.forEach { t ->
android.util.Log.d("TaskCheckWorker", "Task: ${t.text}, date=${t.dueDate}, time=${t.dueTime}, reminder=${t.reminder}, completed=${t.isCompleted}, id=${t.id}")
}
android.util.Log.d("TaskCheckWorker", "nowMillis=$nowMillis, tz=$tz, defaultTime=$defaultTime")
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds, defaultTime) val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds, defaultTime)
android.util.Log.d("TaskCheckWorker", "Computed ${alarms.size} alarms")
alarms.forEach { alarm -> alarms.forEach { alarm ->
android.util.Log.d("TaskCheckWorker", "Scheduling: ${alarm.taskText} at ${alarm.triggerAtMillis} (${alarm.label}) p=${alarm.priority}")
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority) scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority)
} }
@@ -92,7 +92,7 @@ class MemosApiClient(
suspend fun searchMemos(query: String): ApiResult<ListMemosResponse> = apiCall { suspend fun searchMemos(query: String): ApiResult<ListMemosResponse> = apiCall {
httpClient.get(url("/memos")) { httpClient.get(url("/memos")) {
parameter("pageSize", 50) parameter("pageSize", 50)
parameter("filter", "content.contains(\"$query\")") parameter("filter", "content.contains(\"${query.replace("\\", "\\\\").replace("\"", "\\\"")}\")")
}.body() }.body()
} }
@@ -4,13 +4,16 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@OptIn(ExperimentalEncodingApi::class)
class TokenStore(private val dataStore: DataStore<Preferences>) { class TokenStore(private val dataStore: DataStore<Preferences>) {
val serverUrl: Flow<String?> = dataStore.data.map { it[KEY_SERVER_URL] } val serverUrl: Flow<String?> = dataStore.data.map { it[KEY_SERVER_URL]?.let(::readSecure) }
val accessToken: Flow<String?> = dataStore.data.map { it[KEY_ACCESS_TOKEN] } val accessToken: Flow<String?> = dataStore.data.map { it[KEY_ACCESS_TOKEN]?.let(::readSecure) }
val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" } val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" } val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" }
val notificationsEnabled: Flow<Boolean> = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" } val notificationsEnabled: Flow<Boolean> = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" }
@@ -22,8 +25,8 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
suspend fun saveCredentials(serverUrl: String, token: String) { suspend fun saveCredentials(serverUrl: String, token: String) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[KEY_SERVER_URL] = serverUrl prefs[KEY_SERVER_URL] = writeSecure(serverUrl)
prefs[KEY_ACCESS_TOKEN] = token prefs[KEY_ACCESS_TOKEN] = writeSecure(token)
} }
} }
@@ -69,7 +72,26 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
} }
} }
private fun writeSecure(value: String): String {
val key = OBFUSCATION_KEY
val xored = value.encodeToByteArray().mapIndexed { i, b ->
(b.toInt() xor key[i % key.length].code).toByte()
}.toByteArray()
return SECURE_PREFIX + Base64.encode(xored)
}
private fun readSecure(stored: String): String {
if (!stored.startsWith(SECURE_PREFIX)) return stored
val key = OBFUSCATION_KEY
val xored = Base64.decode(stored.removePrefix(SECURE_PREFIX))
return String(xored.mapIndexed { i, b ->
(b.toInt() xor key[i % key.length].code).toByte()
}.toByteArray())
}
companion object { companion object {
private const val SECURE_PREFIX = "OBF:"
private const val OBFUSCATION_KEY = "nikki-credential-obfuscation"
private val KEY_SERVER_URL = stringPreferencesKey("server_url") private val KEY_SERVER_URL = stringPreferencesKey("server_url")
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
private val KEY_THEME = stringPreferencesKey("app_theme") private val KEY_THEME = stringPreferencesKey("app_theme")