Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1325574d authored by Oli Lan's avatar Oli Lan
Browse files

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.

Bug: 187702830
Test: atest AvatarPhotoControllerTest
Change-Id: Idf1ab60878d619ee30505d71e8afe31d8b0c0ebe
parent e8e79f89
Loading
Loading
Loading
Loading
+37 −24
Original line number Original line Diff line number Diff line
@@ -21,6 +21,8 @@ import android.content.ClipData;
import android.content.ContentResolver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Context;
import android.content.Intent;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Canvas;
@@ -57,9 +59,9 @@ class AvatarPhotoController {


        void startActivityForResult(Intent intent, int resultCode);
        void startActivityForResult(Intent intent, int resultCode);


        int getPhotoSize();
        boolean startSystemActivityForResult(Intent intent, int resultCode);


        boolean canCropPhoto();
        int getPhotoSize();
    }
    }


    interface ContextInjector {
    interface ContextInjector {
@@ -77,6 +79,7 @@ class AvatarPhotoController {
    static final int REQUEST_CODE_CROP_PHOTO = 1003;
    static final int REQUEST_CODE_CROP_PHOTO = 1003;


    private static final String IMAGES_DIR = "multi_user";
    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 CROP_PICTURE_FILE_NAME = "CropEditUserPhoto.jpg";
    private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg";
    private static final String TAKE_PICTURE_FILE_NAME = "TakeEditUserPhoto.jpg";


@@ -86,6 +89,7 @@ class AvatarPhotoController {
    private final ContextInjector mContextInjector;
    private final ContextInjector mContextInjector;


    private final File mImagesDir;
    private final File mImagesDir;
    private final Uri mPreCropPictureUri;
    private final Uri mCropPictureUri;
    private final Uri mCropPictureUri;
    private final Uri mTakePictureUri;
    private final Uri mTakePictureUri;


@@ -95,6 +99,8 @@ class AvatarPhotoController {


        mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR);
        mImagesDir = new File(mContextInjector.getCacheDir(), IMAGES_DIR);
        mImagesDir.mkdir();
        mImagesDir.mkdir();
        mPreCropPictureUri = mContextInjector
                .createTempImageUri(mImagesDir, PRE_CROP_PICTURE_FILE_NAME, !waiting);
        mCropPictureUri =
        mCropPictureUri =
                mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting);
                mContextInjector.createTempImageUri(mImagesDir, CROP_PICTURE_FILE_NAME, !waiting);
        mTakePictureUri =
        mTakePictureUri =
@@ -127,7 +133,7 @@ class AvatarPhotoController {
            case REQUEST_CODE_TAKE_PHOTO:
            case REQUEST_CODE_TAKE_PHOTO:
            case REQUEST_CODE_CHOOSE_PHOTO:
            case REQUEST_CODE_CHOOSE_PHOTO:
                if (mTakePictureUri.equals(pictureUri)) {
                if (mTakePictureUri.equals(pictureUri)) {
                    cropPhoto();
                    cropPhoto(pictureUri);
                } else {
                } else {
                    copyAndCropPhoto(pictureUri);
                    copyAndCropPhoto(pictureUri);
                }
                }
@@ -153,7 +159,7 @@ class AvatarPhotoController {
            ThreadUtils.postOnBackgroundThread(() -> {
            ThreadUtils.postOnBackgroundThread(() -> {
                final ContentResolver cr = mContextInjector.getContentResolver();
                final ContentResolver cr = mContextInjector.getContentResolver();
                try (InputStream in = cr.openInputStream(pictureUri);
                try (InputStream in = cr.openInputStream(pictureUri);
                     OutputStream out = cr.openOutputStream(mTakePictureUri)) {
                        OutputStream out = cr.openOutputStream(mPreCropPictureUri)) {
                    Streams.copy(in, out);
                    Streams.copy(in, out);
                } catch (IOException e) {
                } catch (IOException e) {
                    Log.w(TAG, "Failed to copy photo", e);
                    Log.w(TAG, "Failed to copy photo", e);
@@ -161,7 +167,7 @@ class AvatarPhotoController {
                }
                }
                ThreadUtils.postOnMainThread(() -> {
                ThreadUtils.postOnMainThread(() -> {
                    if (!mAvatarUi.isFinishing()) {
                    if (!mAvatarUi.isFinishing()) {
                        cropPhoto();
                        cropPhoto(mPreCropPictureUri);
                    }
                    }
                });
                });
            }).get();
            }).get();
@@ -170,22 +176,21 @@ class AvatarPhotoController {
        }
        }
    }
    }


    private void cropPhoto() {
    private void cropPhoto(final Uri pictureUri) {
        if (mAvatarUi.canCropPhoto()) {
        // TODO: Use a public intent, when there is one.
        // TODO: Use a public intent, when there is one.
        Intent intent = new Intent("com.android.camera.action.CROP");
        Intent intent = new Intent("com.android.camera.action.CROP");
            intent.setDataAndType(mTakePictureUri, "image/*");
        intent.setDataAndType(pictureUri, "image/*");
        appendOutputExtra(intent, mCropPictureUri);
        appendOutputExtra(intent, mCropPictureUri);
        appendCropExtras(intent);
        appendCropExtras(intent);
        try {
        try {
            StrictMode.disableDeathOnFileUriExposure();
            StrictMode.disableDeathOnFileUriExposure();
                mAvatarUi.startActivityForResult(intent, REQUEST_CODE_CROP_PHOTO);
            if (mAvatarUi.startSystemActivityForResult(intent, REQUEST_CODE_CROP_PHOTO)) {
                return;
            }
        } finally {
        } finally {
            StrictMode.enableDeathOnFileUriExposure();
            StrictMode.enableDeathOnFileUriExposure();
        }
        }
        } else {
        onPhotoNotCropped(pictureUri);
            onPhotoNotCropped(mTakePictureUri);
        }
    }
    }


    private void appendOutputExtra(Intent intent, Uri pictureUri) {
    private void appendOutputExtra(Intent intent, Uri pictureUri) {
@@ -307,14 +312,22 @@ class AvatarPhotoController {
        }
        }


        @Override
        @Override
        public int getPhotoSize() {
        public boolean startSystemActivityForResult(Intent intent, int code) {
            return mActivity.getResources()
            ActivityInfo info = intent.resolveActivityInfo(mActivity.getPackageManager(),
                    .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
                    PackageManager.MATCH_SYSTEM_ONLY);
            if (info == null) {
                Log.w(TAG, "No system package activity could be found for code " + code);
                return false;
            }
            intent.setPackage(info.packageName);
            mActivity.startActivityForResult(intent, code);
            return true;
        }
        }


        @Override
        @Override
        public boolean canCropPhoto() {
        public int getPhotoSize() {
            return PhotoCapabilityUtils.canCropPhoto(mActivity);
            return mActivity.getResources()
                    .getDimensionPixelSize(com.android.internal.R.dimen.user_icon_size);
        }
        }
    }
    }


+63 −59
Original line number Original line Diff line number Diff line
@@ -75,6 +75,7 @@ public class AvatarPhotoControllerTest {
    public void setUp() {
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        MockitoAnnotations.initMocks(this);
        when(mMockAvatarUi.getPhotoSize()).thenReturn(PHOTO_SIZE);
        when(mMockAvatarUi.getPhotoSize()).thenReturn(PHOTO_SIZE);
        when(mMockAvatarUi.startSystemActivityForResult(any(), anyInt())).thenReturn(true);


        mImagesDir = new File(
        mImagesDir = new File(
                InstrumentationRegistry.getTargetContext().getCacheDir(), "multi_user");
                InstrumentationRegistry.getTargetContext().getCacheDir(), "multi_user");
@@ -108,9 +109,7 @@ public class AvatarPhotoControllerTest {
    }
    }


    @Test
    @Test
    public void takePhotoIsFollowedByCropWhenSupported() throws IOException {
    public void takePhotoIsFollowedByCrop() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(true);

        new File(mImagesDir, "file.txt").createNewFile();
        new File(mImagesDir, "file.txt").createNewFile();


        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -119,14 +118,12 @@ public class AvatarPhotoControllerTest {
        mController.onActivityResult(
        mController.onActivityResult(
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);


        verifyStartActivityForResult(
        verifyStartSystemActivityForResult(
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
    }
    }


    @Test
    @Test
    public void takePhotoIsNotFollowedByCropWhenResultCodeNotOk() throws IOException {
    public void takePhotoIsNotFollowedByCropWhenResultCodeNotOk() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(true);

        new File(mImagesDir, "file.txt").createNewFile();
        new File(mImagesDir, "file.txt").createNewFile();


        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -136,12 +133,11 @@ public class AvatarPhotoControllerTest {
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_CANCELED, intent);
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_CANCELED, intent);


        verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
        verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
        verify(mMockAvatarUi, never()).startSystemActivityForResult(any(), anyInt());
    }
    }


    @Test
    @Test
    public void takePhotoIsFollowedByCropWhenTakePhotoUriReturned() throws IOException {
    public void takePhotoIsFollowedByCropWhenTakePhotoUriReturned() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(true);

        new File(mImagesDir, "TakeEditUserPhoto.jpg").createNewFile();
        new File(mImagesDir, "TakeEditUserPhoto.jpg").createNewFile();


        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -149,13 +145,13 @@ public class AvatarPhotoControllerTest {
        mController.onActivityResult(
        mController.onActivityResult(
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);


        verifyStartActivityForResult(
        verifyStartSystemActivityForResult(
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
    }
    }


    @Test
    @Test
    public void takePhotoIsNotFollowedByCropIntentWhenCropNotSupported() throws IOException {
    public void takePhotoIsNotFollowedByCropIntentWhenCropNotSupported() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(false);
        when(mMockAvatarUi.startSystemActivityForResult(any(), anyInt())).thenReturn(false);


        File file = new File(mImagesDir, "file.txt");
        File file = new File(mImagesDir, "file.txt");
        saveBitmapToFile(file);
        saveBitmapToFile(file);
@@ -167,33 +163,11 @@ public class AvatarPhotoControllerTest {
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);
                REQUEST_CODE_TAKE_PHOTO, Activity.RESULT_OK, intent);


        verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
        verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
        verify(mMockAvatarUi, never()).startSystemActivityForResult(any(), anyInt());
    }
    }


    @Test
    @Test
    public void takePhotoIsFollowedByInternalCropWhenCropNotSupported() throws IOException {
    public void choosePhotoIsFollowedByCrop() 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_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);
    }

    @Test
    public void choosePhotoIsFollowedByCropWhenSupported() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(true);

        new File(mImagesDir, "file.txt").createNewFile();
        new File(mImagesDir, "file.txt").createNewFile();


        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -202,14 +176,12 @@ public class AvatarPhotoControllerTest {
        mController.onActivityResult(
        mController.onActivityResult(
                REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);
                REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);


        verifyStartActivityForResult(
        verifyStartSystemActivityForResult(
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
    }
    }


    @Test
    @Test
    public void choosePhotoIsNotFollowedByCropWhenResultCodeNotOk() throws IOException {
    public void choosePhotoIsNotFollowedByCropWhenResultCodeNotOk() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(true);

        new File(mImagesDir, "file.txt").createNewFile();
        new File(mImagesDir, "file.txt").createNewFile();


        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -219,12 +191,11 @@ public class AvatarPhotoControllerTest {
                REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_CANCELED, intent);
                REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_CANCELED, intent);


        verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
        verify(mMockAvatarUi, never()).startActivityForResult(any(), anyInt());
        verify(mMockAvatarUi, never()).startSystemActivityForResult(any(), anyInt());
    }
    }


    @Test
    @Test
    public void choosePhotoIsFollowedByCropWhenTakePhotoUriReturned() throws IOException {
    public void choosePhotoIsFollowedByCropWhenTakePhotoUriReturned() throws IOException {
        when(mMockAvatarUi.canCropPhoto()).thenReturn(true);

        new File(mImagesDir, "TakeEditUserPhoto.jpg").createNewFile();
        new File(mImagesDir, "TakeEditUserPhoto.jpg").createNewFile();


        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -232,27 +203,10 @@ public class AvatarPhotoControllerTest {
        mController.onActivityResult(
        mController.onActivityResult(
                REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);
                REQUEST_CODE_CHOOSE_PHOTO, Activity.RESULT_OK, intent);


        verifyStartActivityForResult(
        verifyStartSystemActivityForResult(
                "com.android.camera.action.CROP", REQUEST_CODE_CROP_PHOTO);
                "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
    @Test
    public void cropPhotoResultIsReturnedIfResultOkAndContent() {
    public void cropPhotoResultIsReturnedIfResultOkAndContent() {
        Intent intent = new Intent();
        Intent intent = new Intent();
@@ -277,11 +231,61 @@ public class AvatarPhotoControllerTest {
        verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS).times(0)).returnUriResult(mCropPhotoUri);
        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);

        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);

        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);
        ArgumentCaptor<Intent> captor = ArgumentCaptor.forClass(Intent.class);
        verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS))
        verify(mMockAvatarUi, timeout(TIMEOUT_MILLIS))
                .startActivityForResult(captor.capture(), eq(resultCode));
                .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 {
    private void saveBitmapToFile(File file) throws IOException {