Loading...
Loading...
Best practices for Android Intent security. Use this skill when auditing component configurations in AndroidManifest.xml activities, services, receivers) or source code handling incoming Intents (getIntent, getParcelableExtra) to prevent Intent Redirection and unauthorized access.
npx skill4agent add android/skills android-intent-securityandroid:exported="true"signatureFLAG_ACTIVITY_SINGLE_TOPonCreateonNewIntentsingleTop<activity><service><receiver><provider>AndroidManifest.xmlandroid:exportedandroid:permissionPackageManagerandroidx.core:core:1.9.0IntentSanitizerPackageManager| Intent Delivery Method | Scope | Recommended Use Case |
|---|---|---|
| Explicit Intent (Internal) | App Private | Launching internal activities/services |
| Implicit Intent | System Wide | Launching system camera, dialer, or sharing |
| Local Broadcasts (LocalBroadcastManager) (DEPRECATED) | App Private | Internal asynchronous event routing. Deprecated: Use in-app observers like Kotlin Flows/SharedFlow, LiveData, or reactive patterns instead. |
| System Broadcasts | System Wide | Receiving system events (NFC, Bluetooth) |
| Flag Name | Mutability | Recommended Use Case |
|---|---|---|
| Immutable | Default for almost all PendingIntents, such as alarms and notifications |
| Mutable | Inline notifications replies, slice actions (requires explicit target intent) |
IntentSanitizersanitizeByThrowing()sanitizeByFiltering()RECEIVER_NOT_EXPORTEDPendingIntent.FLAG_IMMUTABLEIntentPendingIntentandroid:exported="false"android:readPermissionandroid:writePermissionandroid:grantUriPermissions="false"Binder.getCallingUid()PackageManager.getPackagesForUid()IntentIntentEXTRA_NESTED_INTENTSecurityExceptionfun safeIntentRedirectionManual() {
val nestedIntent = IntentCompat.getParcelableExtra(intent, "EXTRA_NESTED_INTENT", Intent::class.java)
if (nestedIntent != null) {
// 1. Check for URI permission grants to prevent URI permission bypass
val hasUriPermissionGrants = (
nestedIntent.flags and (
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
)
) != 0
if (hasUriPermissionGrants) {
throw SecurityException("Nested intent contains forbidden URI permission grant flags!")
}
val pm = packageManager
val target = nestedIntent.resolveActivity(pm)
if (target != null) {
// 2. Verify target is within the same package
if (target.packageName != packageName) {
throw SecurityException("Cross-app intent redirection is forbidden!")
}
try {
// 3. Verify target activity is exported
val info = pm.getActivityInfo(target, 0)
if (!info.exported) {
throw SecurityException("Target activity is private: ${target.className}")
}
// 4. Explicitly set the component to prevent intent interception
nestedIntent.component = target
// Safe to launch
startActivity(nestedIntent)
} catch (e: PackageManager.NameNotFoundException) {
Log.e("Security", "Failed to resolve target activity", e)
}
}
}
}
IntentSanitizerIntentIntentSecurityExceptionsanitizeByThrowing()fun safeIntentRedirectionSanitizer() {
val untrustedIntent = IntentCompat.getParcelableExtra(intent, "EXTRA_NESTED_INTENT", Intent::class.java)
if (untrustedIntent != null) {
// Define the strict boundaries for allowed redirection target
val sanitizer = IntentSanitizer.Builder()
.allowComponent(ComponentName("com.example.app", "com.example.app.SafeTargetActivity")) // Explicitly allowed target
.allowAction(Intent.ACTION_VIEW) // Explicitly allowed actions
.allowDataWithAuthority("com.example.app.provider") // Allowed URI authority
.allowType("text/plain") // Allowed mime type
.allowExtra("user_display_name", String::class.java) // Safe type-enforced extras
// Note: URI permission flags are NOT allowed, so the sanitizer will automatically strip or throw on them
.build()
try {
// Option A: Throws SecurityException if the intent violates policies
val safeIntent = sanitizer.sanitizeByThrowing(untrustedIntent)
startActivity(safeIntent)
} catch (e: SecurityException) {
Log.e("SECURITY_ALERT", "Attempted launch of non-allowlisted intent blocked", e)
}
// Option B: Silently filter and launch only the authorized parts (no exception thrown)
// val filteredIntent = sanitizer.sanitizeByFiltering(untrustedIntent)
// startActivity(filteredIntent)
}
}
<permission
android:name="com.example.snippets.permission.INTERNAL_COMMUNICATION"
android:protectionLevel="signature" />
<activity
android:name=".intents.InternalSharingActivity"
android:exported="true"
android:permission="com.example.snippets.permission.INTERNAL_COMMUNICATION">
<intent-filter>
<action android:name="com.example.snippets.ACTION_SHARE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
onNewIntentnewIntentIntentoverride fun onNewIntent(newIntent: Intent) {
super.onNewIntent(newIntent)
// Set the intent to ensure intent returns the new one
intent = newIntent
// Validate the intent payload
if (validateIntent(newIntent)) {
processIntentPayload(newIntent)
} else {
Log.w("SECURITY_ALERT", "Received invalid or insecure intent during warm boot")
}
}
private fun validateIntent(intent: Intent): Boolean {
return intent.hasExtra("VALID_PAYLOAD_MARKER")
}
PendingIntentPendingIntentfun createPendingIntents(context: Context) {
// 1. Secure Immutable PendingIntent (Default)
val intent = Intent(context, TargetActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// 2. Secure Mutable PendingIntent (e.g., Notification Direct Reply)
val mutableIntent = Intent().apply {
// MUST set explicit target component to prevent redirection hijacking
component = ComponentName(context, ReplyReceiver::class.java)
}
val mutablePendingIntent = PendingIntent.getBroadcast(
context,
0,
mutableIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
}
uriUriprojectionString[]selectionStringselectionArgsString[]?Cursor<provider
android:name=".intents.SecureDataProvider"
android:authorities="com.example.snippets.provider"
android:exported="true"
android:readPermission="com.example.snippets.permission.READ_DATA"
android:writePermission="com.example.snippets.permission.WRITE_DATA"
android:grantUriPermissions="false" />
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? {
val queryBuilder = SQLiteQueryBuilder()
queryBuilder.tables = tableName
// Strict projection map to prevent querying unauthorized columns
queryBuilder.projectionMap = mapOf(
"_id" to "_id",
"display_name" to "display_name"
)
// Enable strict validation (always available since minSdk is 36)
queryBuilder.setStrict(true)
queryBuilder.setStrictColumns(true)
queryBuilder.setStrictGrammar(true)
// MUST parameterize selection criteria; NEVER append selection strings directly
val db = dbHelper.readableDatabase
return queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder)
}
intentIntentIBinderSecurityExceptionclass SecureBoundService : Service() {
companion object {
// Expected SHA-256 hash of the trusted app's signing certificate (Base64 encoded)
private const val TRUSTED_PARTNER_SHA256 = "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V="
}
override fun onBind(intent: Intent): IBinder {
// Return the binder. Do NOT perform signature verification in onBind() because
// the binder connection is cached by Android, which can bypass checks on subsequent binds.
return LocalBinder()
}
private fun enforceTrustedCaller() {
val callingUid = Binder.getCallingUid()
// Allow calls from the same application
if (callingUid == Process.myUid()) {
return
}
val pm = packageManager
val packages = pm.getPackagesForUid(callingUid)
if (packages.isNullOrEmpty() || !verifySignature(pm, packages[0])) {
throw SecurityException("Access Denied: Caller signature is untrusted.")
}
}
private fun verifySignature(pm: PackageManager, packageName: String): Boolean {
try {
val trustedSha256Raw = Base64.decode(TRUSTED_PARTNER_SHA256, Base64.DEFAULT)
// API 28+ handles rotated certificates and avoids manual hashing.
// Since minSdk is 36, this is always available.
return pm.hasSigningCertificate(packageName, trustedSha256Raw, PackageManager.CERT_INPUT_SHA256)
} catch (e: Exception) {
Log.e("SECURITY_ERROR", "Verification failed for package: $packageName", e)
}
return false
}
inner class LocalBinder : Binder() {
fun doSecureWork() {
// Verify caller identity on every transaction method call
enforceTrustedCaller()
// Safe to proceed with sensitive operations
}
}
}
fun safeErrorHandling(callingPackage: String?) {
try {
val payload = intent.getStringExtra("DATA_EXTRA") ?: throw IllegalArgumentException("Payload parameter missing.")
// Create a specific target intent using the validated payload
val targetIntent = Intent(this, TargetActivity::class.java).apply {
putExtra("SECURE_PAYLOAD", payload)
}
startActivity(targetIntent)
} catch (e: SecurityException) {
// MUST log security violations for audit, but NEVER expose exception details to the user.
Log.e("SECURITY_ERROR", "Unauthorized component transition blocked. Calling Package: ${callingPackage ?: "Unknown"}", e)
// MUST provide generic user feedback.
showFeedbackToUser("Process request failed: Access Denied.")
} catch (e: IllegalArgumentException) {
Log.w("INTEGRITY_WARNING", "Missing intent parameter", e)
}
}
// Secure handling of ContentProvider queries on the client side:
try {
val cursor = contentResolver.query(providerUri, projection, selection, selectionArgs, null)
} catch (e: SQLiteException) {
Log.e("PROVIDER_ERROR", "ContentProvider database query failed", e)
// Secure handling: prevent raw query syntax details from leaking to UI
}IntentSanitizer### Best practices and security alignment update: [Security Alignment Area]
* **Improvement Description:** [Brief description of the hardening update and why it's recommended]
* **Priority Level:** [High / Medium / Low]
* **Alignment Action:** [Summary of updates, for example, converted to FLAG_IMMUTABLE]
#### Files modified
* `[Relative path to File 1]`
* `[Relative path to File 2]`
#### Implementation diff
```diff
// Insert Unified Diff hereIntentsendStickyBroadcastonNewIntentonCreatePendingIntentIntentContentProviderBinder.getCallingUidBroadcastReceiver.onReceiveandroid:exported="false"android:protectionLevel="signature"RECEIVER_NOT_EXPORTEDsetIntent(newIntent)onNewIntent()PendingIntent.FLAG_IMMUTABLEPendingIntentContentProvidersreadPermissionwritePermissionContentProviderandroidx.core.content.IntentSanitizer