Skip to content

Commit

Permalink
Bug 1528042 - Limit enumerateDevices() fingerprinting vector ahead of…
Browse files Browse the repository at this point in the history
… (and after) getUserMedia success, to spec. r=karlt

Updates enumerateDevices() to limit exposure of privacy sensitive
information ahead of actual camera or microphone use.

It also implements the "creating a device info object" algorithm correctly
after getUserMedia success, which only exposes information on cameras or
microphones (but not both) if only one or the other kind has successfully
been used.

Includes the latest privacy improvements to the spec:
- w3c/mediacapture-main#632
- w3c/mediacapture-main#641
- w3c/mediacapture-main#773

This also fixes media.navigator.permission.disabled leaking labels.

Differential Revision: https://phabricator.services.mozilla.com/D100378
  • Loading branch information
jan-ivar committed May 16, 2023
1 parent d30948d commit 8ede1ad
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 76 deletions.
82 changes: 35 additions & 47 deletions dom/media/MediaDevices.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,17 @@ RefPtr<MediaDeviceSetRefCnt> MediaDevices::FilterExposedDevices(
if (mCanExposeMicrophoneInfo) {
exposedMicrophoneGroupIds.Insert(device->mRawGroupID);
}
// Reducing to one mic or cam device when not mCanExposeMicrophoneInfo
// or not mCanExposeCameraInfo is bug 1528042.
if (!DeviceInformationCanBeExposed()) {
dropMics = true;
}
break;
case MediaDeviceKind::Videoinput:
if (dropCams) {
continue;
}
if (!DeviceInformationCanBeExposed()) {
dropCams = true;
}
break;
case MediaDeviceKind::Audiooutput:
if (dropSpeakers ||
Expand Down Expand Up @@ -301,6 +305,23 @@ RefPtr<MediaDeviceSetRefCnt> MediaDevices::FilterExposedDevices(
return exposed;
}

bool MediaDevices::CanExposeInfo(MediaDeviceKind aKind) const {
switch (aKind) {
case MediaDeviceKind::Audioinput:
return mCanExposeMicrophoneInfo;
case MediaDeviceKind::Videoinput:
return mCanExposeCameraInfo;
case MediaDeviceKind::Audiooutput:
// Assumes caller has used FilterExposedDevices()
return true;
case MediaDeviceKind::EndGuard_:
MOZ_ASSERT_UNREACHABLE("unexpected MediaDeviceKind");
return false;
// Avoid `default:` so that `-Wswitch` catches missing enumerators at
// compile time.
}
};

bool MediaDevices::ShouldQueueDeviceChange(
const MediaDeviceSet& aExposedDevices) const {
if (!mLastPhysicalDevices) { // SetupDeviceChangeListener not complete
Expand All @@ -317,22 +338,6 @@ bool MediaDevices::ShouldQueueDeviceChange(
// exposed by enumerateDevices() (but multiple devices are currently exposed
// - bug 1528042). "devicechange" events are not queued when the number
// of such devices changes but remains non-zero.
auto CanExposeNonZeroChanges = [this](MediaDeviceKind aKind) {
switch (aKind) {
case MediaDeviceKind::Audioinput:
return mCanExposeMicrophoneInfo;
case MediaDeviceKind::Videoinput:
return mCanExposeCameraInfo;
case MediaDeviceKind::Audiooutput:
return true;
case MediaDeviceKind::EndGuard_:
break;
// Avoid `default:` so that `-Wswitch` catches missing enumerators at
// compile time.
}
MOZ_ASSERT_UNREACHABLE("unexpected MediaDeviceKind");
return false;
};
while (exposed < exposedEnd && last < lastEnd) {
// First determine whether there is at least one device of the same kind
// in both `aExposedDevices` and `lastExposedDevices`.
Expand All @@ -344,7 +349,7 @@ bool MediaDevices::ShouldQueueDeviceChange(
return true;
}
// `exposed` and `last` have matching kind.
if (CanExposeNonZeroChanges(kind)) {
if (CanExposeInfo(kind)) {
// Queue "devicechange" if there has been any change in devices of this
// exposed kind. ID and kind uniquely identify a device.
if ((*exposed)->mRawID != (*last)->mRawID) {
Expand Down Expand Up @@ -399,37 +404,16 @@ void MediaDevices::ResumeEnumerateDevices(

void MediaDevices::ResolveEnumerateDevicesPromise(
Promise* aPromise, const LocalMediaDeviceSet& aDevices) const {
nsCOMPtr<nsPIDOMWindowInner> window = GetOwner();
auto windowId = window->WindowID();
nsTArray<RefPtr<MediaDeviceInfo>> infos;
bool allowLabel =
aDevices.Length() == 0 ||
MediaManager::Get()->IsActivelyCapturingOrHasAPermission(windowId);

for (const RefPtr<LocalMediaDevice>& device : aDevices) {
nsString label;
MOZ_ASSERT(device->Kind() < MediaDeviceKind::EndGuard_);
switch (device->Kind()) {
case MediaDeviceKind::Audioinput:
case MediaDeviceKind::Videoinput:
// Include name only if page currently has a gUM stream
// active or persistent permissions (audio or video) have
// been granted. See bug 1528042 for using
// mCanExposeMicrophoneInfo.
if (allowLabel || Preferences::GetBool(
"media.navigator.permission.disabled", false)) {
label = device->mName;
}
break;
case MediaDeviceKind::Audiooutput:
label = device->mName;
break;
case MediaDeviceKind::EndGuard_:
break;
// Avoid `default:` so that `-Wswitch` catches missing
// enumerators at compile time.
}
infos.AppendElement(MakeRefPtr<MediaDeviceInfo>(device->mID, device->Kind(),
label, device->mGroupID));
bool canExposeInfo = CanExposeInfo(device->Kind());

infos.AppendElement(MakeRefPtr<MediaDeviceInfo>(
canExposeInfo ? device->mID : u""_ns, device->Kind(),
canExposeInfo ? device->mName : u""_ns,
canExposeInfo ? device->mGroupID : u""_ns));
}
aPromise->MaybeResolve(std::move(infos));
}
Expand Down Expand Up @@ -784,6 +768,10 @@ void MediaDevices::EventListenerAdded(nsAtom* aType) {
SetupDeviceChangeListener();
}

bool MediaDevices::DeviceInformationCanBeExposed() const {
return mCanExposeCameraInfo || mCanExposeMicrophoneInfo;
}

JSObject* MediaDevices::WrapObject(JSContext* aCx,
JS::Handle<JSObject*> aGivenProto) {
return MediaDevices_Binding::Wrap(aCx, this, aGivenProto);
Expand Down
5 changes: 5 additions & 0 deletions dom/media/MediaDevices.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include "mozilla/DOMEventTargetHelper.h"
#include "mozilla/UseCounter.h"
#include "mozilla/dom/BindingDeclarations.h"
#include "mozilla/dom/MediaDeviceInfoBinding.h"
#include "nsCOMPtr.h"
#include "nsID.h"
#include "nsISupports.h"
Expand Down Expand Up @@ -103,10 +104,14 @@ class MediaDevices final : public DOMEventTargetHelper {
RefPtr<const MediaDeviceSetRefCnt> aExposedDevices) const;
RefPtr<MediaDeviceSetRefCnt> FilterExposedDevices(
const MediaDeviceSet& aDevices) const;
bool CanExposeInfo(MediaDeviceKind aKind) const;
bool ShouldQueueDeviceChange(const MediaDeviceSet& aExposedDevices) const;
void ResolveEnumerateDevicesPromise(
Promise* aPromise, const LocalMediaDeviceSet& aDevices) const;

// See https://www.w3.org/TR/mediacapture-streams/#device-information-exposure
bool DeviceInformationCanBeExposed() const;

nsTHashSet<nsString> mExplicitlyGrantedAudioOutputRawIds;
nsTArray<RefPtr<Promise>> mPendingEnumerateDevicesPromises;
// Set only once, if and when required.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,7 @@
// `fake:true` means that getUserMedia() resolves without any permission
// check, and so this should not be sufficient to expose real device info.
const stream = await devices.getUserMedia({ audio: true, fake: true });
// permission.disabled exposes labels - bug 1528042
const list = await withPrefs(
[["media.navigator.permission.disabled", false]],
async () => devices.enumerateDevices());
const list = await devices.enumerateDevices();
stream.getTracks()[0].stop();
const labeledDevices = list.filter(({label}) => label != "");
is(labeledDevices.length, 0, "number of labeled devices after fake gUM");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,3 @@
if (os == "android") and fission: [OK, TIMEOUT]
[InputDeviceInfo is supported]
expected: FAIL

[mediaDevices.enumerateDevices() is present and working - before capture]
expected: FAIL
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,71 @@ <h1 class="instructions">Description</h1>

promise_test(async () => {
assert_not_equals(navigator.mediaDevices.enumerateDevices, undefined, "navigator.mediaDevices.enumerateDevices exists");
const deviceList = await navigator.mediaDevices.enumerateDevices();
for (const mediaInfo of deviceList) {
assert_not_equals(mediaInfo.kind, undefined, "mediaInfo's kind should exist.");
assert_equals(mediaInfo.deviceId, "", "mediaInfo's deviceId should exist and be empty if getUserMedia was never called successfully.");
assert_equals(mediaInfo.label, "", "mediaInfo's label should exist and be empty if getUserMedia was never called successfully.");
assert_equals(mediaInfo.groupId, "", "mediaInfo's groupId should exist and be empty if getUserMedia was never called successfully.");
assert_in_array(mediaInfo.kind, ["videoinput", "audioinput", "audiooutput"]);
const devices = await navigator.mediaDevices.enumerateDevices();
for (const {kind, deviceId, label, groupId} of devices) {
assert_in_array(kind, ["videoinput", "audioinput", "audiooutput"]);
assert_equals(deviceId, "", "deviceId should be empty string if getUserMedia was never called successfully.");
assert_equals(label, "", "label should be empty string if getUserMedia was never called successfully.");
assert_equals(groupId, "", "groupId should be empty string if getUserMedia was never called successfully.");
}
assert_less_than_equal(deviceList.filter((item) => { return item.kind == "audioinput"; }).length, 1, "there should be zero or one audio input device ");
assert_less_than_equal(deviceList.filter((item) => { return item.kind == "videoinput"; }).length, 1, "there should be zero or one video input device ");

assert_less_than_equal(devices.filter(({kind}) => kind == "audioinput").length,
1, "there should be zero or one audio input device.");
assert_less_than_equal(devices.filter(({kind}) => kind == "videoinput").length,
1, "there should be zero or one video input device.");
}, "mediaDevices.enumerateDevices() is present and working - before capture");

promise_test(async () => {
await setMediaPermission("granted", ["microphone"]);
await navigator.mediaDevices.getUserMedia({ audio : true });
const deviceList = await navigator.mediaDevices.enumerateDevices();
for (const mediaInfo of deviceList) {
assert_not_equals(mediaInfo.kind, undefined, "mediaInfo's kind should exist.");
assert_not_equals(mediaInfo.deviceId, "", "mediaInfo's deviceId should exist and not be empty.");
assert_in_array(mediaInfo.kind, ["videoinput", "audioinput", "audiooutput"]);
promise_test(async t => {
await setMediaPermission("granted");
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks()[0].stop();

const devices = await navigator.mediaDevices.enumerateDevices();
const kinds = ["audioinput", "videoinput"];
for (const {kind, deviceId} of devices) {
assert_in_array(kind, kinds, "camera doesn't expose audiooutput");
assert_equals(typeof deviceId, "string", "deviceId is a string.");
switch (kind) {
case "videoinput":
assert_greater_than(deviceId.length, 0, "video deviceId should not be empty.");
break;
case "audioinput":
assert_equals(deviceId.length, 0, "audio deviceId should be empty.");
break;
}
}
}, "mediaDevices.enumerateDevices() is working - after video capture");

// This test is designed to come after its video counterpart directly above
promise_test(async t => {
const devices1 = await navigator.mediaDevices.enumerateDevices();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks()[0].stop();
const devices = await navigator.mediaDevices.enumerateDevices();
assert_equals(devices.filter(({kind}) => kind != "audiooutput").length,
devices1.filter(({kind}) => kind != "audiooutput").length,
"same number of input devices");
const kinds = ["audioinput", "videoinput", "audiooutput"];
for (const {kind, deviceId} of devices) {
assert_in_array(kind, kinds, "expected kind");
assert_equals(typeof deviceId, "string", "deviceId is a string.");
switch (kind) {
case "videoinput":
assert_greater_than(deviceId.length, 0, "video deviceId should not be empty.");
break;
case "audioinput":
assert_greater_than(deviceId.length, 0, "audio deviceId should not be empty.");
break;
}
}
}, "mediaDevices.enumerateDevices() is present and working - after capture");
}, "mediaDevices.enumerateDevices() is working - after video then audio capture");

promise_test(async () => {
const deviceList = await navigator.mediaDevices.enumerateDevices();
for (const mediaInfo of deviceList) {
const devices = await navigator.mediaDevices.enumerateDevices();
for (const mediaInfo of devices) {
if (mediaInfo.kind == "audioinput" || mediaInfo.kind == "videoinput") {
assert_true("InputDeviceInfo" in window, "InputDeviceInfo exists");
assert_true(mediaInfo instanceof InputDeviceInfo);
} else if ( mediaInfo.kind == "audiooutput" ) {
} else if (mediaInfo.kind == "audiooutput") {
assert_true(mediaInfo instanceof MediaDeviceInfo);
} else {
assert_unreached("mediaInfo.kind should be one of 'audioinput', 'videoinput', or 'audiooutput'.")
Expand Down

0 comments on commit 8ede1ad

Please sign in to comment.