Prevent exfiltration of system files via avatar picker.
This adds mitigations to prevent system files being exfiltrated
via the settings content provider when a content URI is provided
as a chosen user image.
The mitigations are:
1) Copy the image to a new URI rather than the existing takePictureUri
prior to cropping.
2) Only allow a system handler to respond to the CROP intent.
This is a fixed version of ag/17005706, to address b/239513606.
Bug: 187702830
Test: atest AvatarPhotoControllerTest
Change-Id: I21f1b25154dc00a305bdadb96fdf22edff31d9b8
diff --git a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java
index adfa39e..4ce88ee 100644
--- a/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java
+++ b/packages/SettingsLib/src/com/android/settingslib/users/AvatarPhotoController.java
@@ -21,6 +21,8 @@
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
@@ -46,6 +48,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.util.List;
import java.util.concurrent.ExecutionException;
class AvatarPhotoController {
@@ -57,9 +60,9 @@
void startActivityForResult(Intent intent, int resultCode);
- int getPhotoSize();
+ boolean startSystemActivityForResult(Intent intent, int resultCode);
- boolean canCropPhoto();
+ int getPhotoSize();
}
interface ContextInjector {
@@ -82,6 +85,7 @@
private static final long DELAY_BEFORE_CROP_MILLIS = 150;
private static final String IMAGES_DIR = "multi_user";
+ private static final String PRE_CROP_PICTURE_FILE_NAME = "PreCropEditUserPhoto.jpg";
private static final String CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg";
private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg";
@@ -91,6 +95,7 @@
private final ContextInjector mContextInjector;
private final File mImagesDir;
+ private final Uri mPreCropPictureUri;
private final Uri mCropPictureUri;
private final Uri mTakePictureUri;
@@ -100,6 +105,8 @@
mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR);
mImagesDir.mkdir();
+ mPreCropPictureUri = mContextInjector
+ .createTempImageUri(mImagesDir, PRE_CROP_PICTURE_FILE_NAME, !waiting);
mCropPictureUri =
mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting);
mTakePictureUri =
@@ -131,7 +138,7 @@
return true;
case REQUEST_CODE_TAKE_PHOTO:
if (mTakePictureUri.equals(pictureUri)) {
- cropPhoto();
+ cropPhoto(pictureUri);
} else {
copyAndCropPhoto(pictureUri, false);
}
@@ -160,7 +167,7 @@
ThreadUtils.postOnBackgroundThread(() -> {
final ContentResolver cr = mContextInjector.getContentResolver();
try (InputStream in = cr.openInputStream(pictureUri);
- OutputStream out = cr.openOutputStream(mTakePictureUri)) {
+ OutputStream out = cr.openOutputStream(mPreCropPictureUri)) {
Streams.copy(in, out);
} catch (IOException e) {
Log.w(TAG, "Failed to copy photo", e);
@@ -168,7 +175,7 @@
}
Runnable cropRunnable = () -> {
if (!mAvatarUi.isFinishing()) {
- cropPhoto();
+ cropPhoto(mPreCropPictureUri);
}
};
if (delayBeforeCrop) {
@@ -183,22 +190,21 @@
}
}
- private void cropPhoto() {
- if (mAvatarUi.canCropPhoto()) {
- // TODO: Use a public intent, when there is one.
- Intent intent = new Intent("com.android.camera.action.CROP");
- intent.setDataAndType(mTakePictureUri, "image/*");
- appendOutputExtra(intent, mCropPictureUri);
- appendCropExtras(intent);
- try {
- StrictMode.disableDeathOnFileUriExposure();
- mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CROP_PHOTO);
- } finally {
- StrictMode.enableDeathOnFileUriExposure();
+ private void cropPhoto(final Uri pictureUri) {
+ // TODO: Use a public intent, when there is one.
+ Intent intent = new Intent("com.android.camera.action.CROP");
+ intent.setDataAndType(pictureUri, "image/*");
+ appendOutputExtra(intent, mCropPictureUri);
+ appendCropExtras(intent);
+ try {
+ StrictMode.disableDeathOnFileUriExposure();
+ if (mAvatarUi.startSystemActivityForResult(intent, REQUEST_CODE_CROP_PHOTO)) {
+ return;
}
- } else {
- onPhotoNotCropped(mTakePictureUri);
+ } finally {
+ StrictMode.enableDeathOnFileUriExposure();
}
+ onPhotoNotCropped(pictureUri);
}
private void appendOutputExtra(Intent intent, Uri pictureUri) {
@@ -320,14 +326,22 @@
}
@Override
- public int getPhotoSize() {
- return mActivity.getResources()
- .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
+ public boolean startSystemActivityForResult(Intent intent, int code) {
+ List<ResolveInfo> resolveInfos = mActivity.getPackageManager()
+ .queryIntentActivities(intent, PackageManager.MATCH_SYSTEM_ONLY);
+ if (resolveInfos.isEmpty()) {
+ Log.w(TAG, "No system package activity could be found for code " + code);
+ return false;
+ }
+ intent.setPackage(resolveInfos.get(0).activityInfo.packageName);
+ mActivity.startActivityForResult(intent, code);
+ return true;
}
@Override
- public boolean canCropPhoto() {
- return PhotoCapabilityUtils.canCropPhoto(mActivity);
+ public int getPhotoSize() {
+ return mActivity.getResources()
+ .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
}
}
diff --git a/packages/SettingsLib/tests/integ/src/com/android/settingslib/users/AvatarPhotoControllerTest.java b/packages/SettingsLib/tests/integ/src/com/android/settingslib/users/AvatarPhotoControllerTest.java
index 3dc2fab..d988111 100644
--- a/packages/SettingsLib/tests/integ/src/com/android/settingslib/users/AvatarPhotoControllerTest.java
+++ b/packages/SettingsLib/tests/integ/src/com/android/settingslib/users/AvatarPhotoControllerTest.java
@@ -34,6 +34,7 @@
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
import android.net.Uri;
import android.provider.MediaStore;
@@ -51,6 +52,7 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.io.OutputStream;
@RunWith(AndroidJUnit4.class)
@@ -73,6 +75,7 @@
public void setUp() {
MockitoAnnotations.initMocks(this);
when(mMockAvatarUi.getPhotoSize()).thenReturn(PHOTO_SIZE);
+ when(mMockAvatarUi.startSystemActivityForResult(any(), anyInt())).thenReturn(true);
mImagesDir = new File(
InstrumentationRegistry.getTargetContext().getCacheDir(), "multi_user");
@@ -110,9 +113,7 @@
}
@Test
- public void takePhotoIsFollowedByCropWhenSupported() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(true);
-
+ public void takePhotoIsFollowedByCrop() throws IOException {
new File(mImagesDir, "file.txt").createNewFile();
Intent intent = new Intent();
@@ -121,14 +122,12 @@
mController.onActivityResult(
REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
- verifyStartActivityForResult(
+ verifyStartSystemActivityForResult(
"com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
}
@Test
public void takePhotoIsNotFollowedByCropWhenResultCodeNotOk() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(true);
-
new File(mImagesDir, "file.txt").createNewFile();
Intent intent = new Intent();
@@ -138,12 +137,11 @@
REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_CANCELED, intent);
verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
+ verify(mMockAvatarUi, never()).startSystemActivityForResult(any(), anyInt());
}
@Test
public void takePhotoIsFollowedByCropWhenTakePhotoUriReturned() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(true);
-
new File(mImagesDir, "TakeEditUserPhoto.jpg").createNewFile();
Intent intent = new Intent();
@@ -151,14 +149,12 @@
mController.onActivityResult(
REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
- verifyStartActivityForResult(
+ verifyStartSystemActivityForResult(
"com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
}
@Test
public void choosePhotoIsFollowedByCrop() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(true);
-
new File(mImagesDir, "file.txt").createNewFile();
Intent intent = new Intent();
@@ -167,14 +163,12 @@
mController.onActivityResult(
REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);
- verifyStartActivityForResult(
+ verifyStartSystemActivityForResult(
"com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
}
@Test
public void choosePhotoIsNotFollowedByCropWhenResultCodeNotOk() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(true);
-
new File(mImagesDir, "file.txt").createNewFile();
Intent intent = new Intent();
@@ -184,12 +178,11 @@
REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_CANCELED, intent);
verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
+ verify(mMockAvatarUi, never()).startSystemActivityForResult(any(), anyInt());
}
@Test
public void choosePhotoIsFollowedByCropWhenTakePhotoUriReturned() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(true);
-
new File(mImagesDir, "TakeEditUserPhoto.jpg").createNewFile();
Intent intent = new Intent();
@@ -197,28 +190,11 @@
mController.onActivityResult(
REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);
- verifyStartActivityForResult(
+ verifyStartSystemActivityForResult(
"com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
}
@Test
- public void choosePhotoIsNotFollowedByCropIntentWhenCropNotSupported() throws IOException {
- when(mMockAvatarUi.canCropPhoto()).thenReturn(false);
-
- File file = new File(mImagesDir, "file.txt");
- saveBitmapToFile(file);
-
- Intent intent = new Intent();
- intent.setData(Uri.parse(
- "content://com.android.settingslib.test/my_cache/multi_user/file.txt"));
- mController.onActivityResult(
- REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);
-
- verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
- verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS)).returnUriResult(mCropPhotoUri);
- }
-
- @Test
public void cropPhotoResultIsReturnedIfResultOkAndContent() {
Intent intent = new Intent();
intent.setData(mCropPhotoUri);
@@ -242,11 +218,58 @@
verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS).times(0)).returnUriResult(mCropPhotoUri);
}
- private void verifyStartActivityForResult(String action, int resultCode) {
+ @Test
+ public void cropDoesNotUseTakePhotoUri() throws IOException {
+ new File(mImagesDir, "file.txt").createNewFile();
+
+ Intent intent = new Intent();
+ intent.setData(Uri.parse(
+ "content://com.android.settingslib.test/my_cache/multi_user/file.txt"));
+ mController.onActivityResult(
+ REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
+
+ Intent startIntent = verifyStartSystemActivityForResult(
+ "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
+ assertThat(startIntent.getData()).isNotEqualTo(mTakePhotoUri);
+ }
+
+ @Test
+ public void internalCropUsedIfNoSystemCropperFound() throws IOException {
+ when(mMockAvatarUi.startSystemActivityForResult(any(), anyInt())).thenReturn(false);
+
+ File file = new File(mImagesDir, "file.txt");
+ saveBitmapToFile(file);
+
+ Intent intent = new Intent();
+ intent.setData(Uri.parse(
+ "content://com.android.settingslib.test/my_cache/multi_user/file.txt"));
+ mController.onActivityResult(
+ REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
+
+ verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS)).returnUriResult(mCropPhotoUri);
+
+ InputStream imageStream = mContext.getContentResolver().openInputStream(mCropPhotoUri);
+ Bitmap bitmap = BitmapFactory.decodeStream(imageStream);
+ assertThat(bitmap.getWidth()).isEqualTo(PHOTO_SIZE);
+ assertThat(bitmap.getHeight()).isEqualTo(PHOTO_SIZE);
+ }
+
+ private Intent verifyStartActivityForResult(String action, int resultCode) {
ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS))
.startActivityForResult(captor.capture(), eq(resultCode));
- assertThat(captor.getValue().getAction()).isEqualTo(action);
+ Intent intent = captor.getValue();
+ assertThat(intent.getAction()).isEqualTo(action);
+ return intent;
+ }
+
+ private Intent verifyStartSystemActivityForResult(String action, int resultCode) {
+ ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
+ verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS))
+ .startSystemActivityForResult(captor.capture(), eq(resultCode));
+ Intent intent = captor.getValue();
+ assertThat(intent.getAction()).isEqualTo(action);
+ return intent;
}
private void saveBitmapToFile(File file) throws IOException {