Firefox · Core
CVE-2026-109771
Logic Error in Core
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
fortesting/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html |
modified | |
iftesting/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html |
modified |
Files Changed
testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.initesting/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.htmltesting/web-platform/tests/webrtc/RTCIceTransport-peer-reflexive-remote.htmltesting/web-platform/tests/webrtc/RTCPeerConnection-helper.js
Patch
diff --git a/testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.ini b/testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.ini
new file mode 100644
index 00000000000..6216a3e4b61
--- /dev/null
+++ b/testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.ini
@@ -0,0 +1,3 @@
+[mdns-candidate-obfuscation.html]
+ disabled:
+ if (os == "mac") and (processor == "aarch64"): https://bugzilla.mozilla.org/show_bug.cgi?id=2041035
diff --git a/testing/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html b/testing/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html
new file mode 100644
index 00000000000..d1dfe49be85
--- /dev/null
+++ b/testing/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html
@@ -0,0 +1,343 @@
+<!doctype html>
+<meta charset=utf-8>
+<meta name="timeout" content="long">
+<title>mDNS obfuscation tests</title>
+<script src=/resources/testharness.js></script>
+<script src=/resources/testharnessreport.js></script>
+<script src="/webrtc/RTCPeerConnection-helper.js"></script>
+<script>
+'use strict';
+
+const OBFUSCATE_PREF = 'media.peerconnection.ice.obfuscate_host_addresses';
+
+async function withPrefs(prefs, func) {
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+ try {
+ return await func();
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ }
+}
+const withObfuscation = func => withPrefs([[OBFUSCATE_PREF, true]], func);
+const withoutObfuscation = func => withPrefs([[OBFUSCATE_PREF, false]], func);
+
+// Per-token and anchored, so a bare RTCIceCandidate.address is caught just as
+// well as an address embedded in a candidate-attribute string. Loose on
+// purpose; the sanity test below proves these fire on real addresses.
+const ipv4Token = /^(\d{1,3}\.){3}\d{1,3}$/;
+// At least two colons, nothing but hex, colons and dots (v4-mapped forms).
+const ipv6Token = /^(?=(?:[^:]*:){2})[0-9a-fA-F:.]+$/;
+// Our resolver only queries names with exactly one dot (see
+// PeerConnectionImpl::AddIceCandidate), so hold ourselves to producing those.
+const mdnsName = /^[^.\s]+\.local$/;
+
+const tokensOf = str => String(str ?? '').trim().split(/\s+/);
+const containsIP = str =>
+ tokensOf(str).some(tok => ipv4Token.test(tok) || ipv6Token.test(tok));
+
+async function connect(pc1, pc2) {
+ await exchangeOfferAnswer(pc1, pc2);
+ await Promise.all([listenToIceConnected(pc1), listenToIceConnected(pc2)]);
+}
+
+function iceTransportOf(pc, who) {
+ const [sender] = pc.getSenders();
+ assert_not_equals(sender?.transport, null, `${who} has a DTLS transport`);
+ return sender.transport.iceTransport;
+}
+
+function selectedPairOf(pc, who) {
+ const pair = iceTransportOf(pc, who).getSelectedCandidatePair();
+ assert_not_equals(pair, null, `${who} has a selected candidate pair`);
+ return pair;
+}
+
+function assertNoIPIn(cand, who) {
+ assert_not_equals(cand, null, `${who} exists`);
+ for (const field of ['candidate', 'address', 'relatedAddress']) {
+ assert_false(containsIP(cand[field]),
+ `${who}.${field} must not contain an IP address (got "${cand[field]}")`);
+ }
+ if (cand.type == 'host' && cand.address !== null) {
+ assert_regexp_match(cand.address, mdnsName,
+ `${who}.address is an mDNS name`);
+ }
+}
+
+// webrtc-pc's exposure rules for peer-reflexive remote candidates: the
+// candidate string is empty and the addresses are null.
+function assertHiddenPrflx(cand, who) {
+ assert_equals(cand.type, 'prflx', `${who} is peer-reflexive`);
+ assert_equals(cand.candidate, '', `${who}.candidate is hidden`);
+ assert_equals(cand.address, null, `${who}.address is hidden`);
+ assert_equals(cand.relatedAddress, null, `${who}.relatedAddress is hidden`);
+}
+
+// A remote candidate we surface, unless peer-reflexive, must be one of the
+// candidates content handed to addIceCandidate, carrying the address content
+// sent rather than whatever we resolved it to.
+function assertIsSignaled(cand, delivered, who) {
+ if (cand.type == 'prflx') {
+ assertHiddenPrflx(cand, who);
+ return;
+ }
+ assert_in_array(cand.candidate, delivered.map(c => c.candidate),
+ `${who} is a candidate signaled via addIceCandidate`);
+}
+
+const candidateStats = report =>
+ [...report.values()].filter(
+ ({type}) => type == 'local-candidate' || type == 'remote-candidate');
+
+async function checkStats(pc, who) {
+ const report = await pc.getStats();
+ const cands = candidateStats(report);
+ for (const type of ['local-candidate', 'remote-candidate']) {
+ assert_true(cands.some(c => c.type == type), `${who} has ${type} stats`);
+ }
+ for (const c of cands) {
+ const label = `${who} ${c.type} ${c.candidateType} ${c.id}`;
+ assert_false(containsIP(c.address),
+ `${label} address must not be an IP address (got "${c.address}")`);
+ if (c.type == 'remote-candidate' && c.candidateType == 'prflx') {
+ // webrtc-stats: address MUST be left null unless the address was learned
+ // through addIceCandidate.
+ assert_equals(c.address, null,
+ `${label} address is not exposed (got "${c.address}")`);
+ } else if (c.candidateType == 'host' && c.address) {
+ assert_regexp_match(c.address, mdnsName, `${label} address is an mDNS name`);
+ }
+ }
+ return report;
+}
+
+// Every way `pc` can show a candidate to content. Callers must have awaited
+// the exchange's complete() so gathered() and delivered() are final.
+async function checkEverything(pc, exchange, who) {
+ const gathered = exchange.from(pc).gathered();
+ const delivered = exchange.to(pc).delivered();
+ assert_greater_than(gathered.length, 0, `${who} gathered candidates`);
+ gathered.forEach((c, i) => assertNoIPIn(c, `${who} gathered[${i}]`));
+
+ const pair = selectedPairOf(pc, who);
+ assertNoIPIn(pair.local, `${who} selected local`);
+ assertNoIPIn(pair.remote, `${who} selected remote`);
+ assertIsSignaled(pair.remote, delivered, `${who} selected remote`);
+
+ await checkStats(pc, who);
+}
+
+promise_test(t => withoutObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ const hosts = exchange.from(pc1).gathered().filter(({type}) => type == 'host');
+ assert_greater_than(hosts.length, 0, 'offerer gathered host candidates');
+ for (const c of hosts) {
+ assert_true(containsIP(c.candidate), `detector fires on "${c.candidate}"`);
+ assert_true(containsIP(c.address), `detector fires on bare "${c.address}"`);
+ }
+
+ // The offerer often connects through peer-reflexive candidates before the
+ // answerer's trickle reaches it, and nICEr drops candidates that arrive
+ // after that, so the answerer is the side that reliably knows signaled
+ // remote candidates.
+ const {local, remote} = selectedPairOf(pc2, 'answerer');
+ assert_true(containsIP(local.address), 'unobfuscated local address is an IP');
+ assert_equals(remote.type, 'host', 'answerer selected the signaled remote');
+ assert_true(containsIP(remote.address),
+ 'a remote address learned through addIceCandidate may be shown');
+
+ const report = await pc2.getStats();
+ const signaled = candidateStats(report).filter(
+ c => c.type == 'remote-candidate' && c.candidateType == 'host');
+ assert_greater_than(signaled.length, 0, 'answerer has signaled remotes');
+ for (const c of signaled) {
+ assert_true(containsIP(c.address),
+ `a remote address learned through addIceCandidate may be shown (${c.id})`);
+ }
+ assert_true(candidateStats(report).some(
+ c => c.type == 'local-candidate' && containsIP(c.address)),
+ 'detector fires on local candidate stats');
+}), 'Control: with obfuscation off, host candidates are plain IP addresses, ' +
+ 'which we should detect');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+ await checkEverything(pc1, exchange, 'offerer');
+ await checkEverything(pc2, exchange, 'answerer');
+}), 'Trickle both ways: nothing exposed to content contains an IP address');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ // Holding the answerer's candidates back means the offerer cannot send a
+ // check until the answerer has resolved the offerer's mDNS names and paired
+ // them, so the answerer's selected remote has to be the signaled candidate
+ // rather than a peer-reflexive one. This is the path that used to surface
+ // the resolved address.
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ exchange.from(pc2).hold();
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assert_equals(pc2pair.remote.type, 'host', 'answerer selected the signaled remote');
+ assert_regexp_match(pc2pair.remote.address, mdnsName,
+ 'the signaled remote keeps its mDNS name');
+ await checkEverything(pc2, exchange, 'answerer');
+
+ const pc1pair = selectedPairOf(pc1, 'offerer');
+ assertHiddenPrflx(pc1pair.remote, 'offerer selected remote');
+ await checkEverything(pc1, exchange, 'offerer');
+}), 'A resolved mDNS remote candidate is surfaced under its name, never its ' +
+ 'resolved address');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ exchange.from(pc1).hold();
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ // The answerer never heard about the offerer's candidates, so it can only
+ // have connected through a peer-reflexive remote candidate.
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assertHiddenPrflx(pc2pair.remote, 'answerer selected remote');
+ assertNoIPIn(pc2pair.local, 'answerer selected local');
+ const report = await checkStats(pc2, 'answerer');
+ const remotes = [...report.values()].filter(s => s.type == 'remote-candidate');
+ assert_greater_than(remotes.length, 0, 'answerer has remote candidate stats');
+ assert_true(remotes.every(s => s.candidateType == 'prflx'),
+ 'answerer knows only peer-reflexive remote candidates');
+
+ // The offerer was told everything, as usual.
+ await checkEverything(pc1, exchange, 'offerer');
+
+ // Now the held candidates arrive. The addresses the answerer already knows
+ // as peer-reflexive get signaled under mDNS names. nICEr drops trickled
+ // candidates once the stream is connected, so they will not show up in
+ // stats; the remote description still mirrors them, and learning the names
+ // must not unhide the prflx entries that resolve to the same addresses.
+ await exchange.from(pc1).release();
+ const delivered = exchange.to(pc2).delivered();
+ assert_greater_than(delivered.length, 0, 'the held candidates were delivered');
+
+ const pairAfter = selectedPairOf(pc2, 'answerer (late)');
+ assertNoIPIn(pairAfter.local, 'answerer (late) selected local');
+ assertNoIPIn(pairAfter.remote, 'answerer (late) selected remote');
+ assertIsSignaled(pairAfter.remote, delivered, 'answerer (late) selected remote');
+ await checkStats(pc2, 'answerer (late)');
+}), 'One-way trickle: a side connected through prflx never sees the peer ' +
+ 'address, before or after the candidates arrive late');
+
+promise_test(t => withoutObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ exchangeIceCandidates(pc1, pc2).from(pc1).hold();
+ await connect(pc1, pc2);
+
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assertHiddenPrflx(pc2pair.remote, 'answerer selected remote');
+ const report = await pc2.getStats();
+ const remotes = [...report.values()].filter(s => s.type == 'remote-candidate');
+ assert_greater_than(remotes.length, 0, 'answerer has remote candidate stats');
+ for (const s of remotes) {
+ assert_equals(s.candidateType, 'prflx', `${s.id} is peer-reflexive`);
+ assert_equals(s.address, null,
+ `prflx remote-candidate ${s.id} address is not exposed (got "${s.address}")`);
+ }
+}), 'Peer-reflexive remote candidates are hidden even when we are not ' +
+ 'obfuscating our own addresses');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+ const gatheredBefore = exchange.from(pc1).gathered().length;
+
+ const pairChanged = Promise.all([
+ waitUntilEvent(iceTransportOf(pc1, 'offerer'), 'selectedcandidatepairchange'),
+ waitUntilEvent(iceTransportOf(pc2, 'answerer'), 'selectedcandidatepairchange'),
+ ]);
+ pc1.restartIce();
+ await Promise.all([exchange.complete(), exchangeOfferAnswer(pc1, pc2)]);
+ await pairChanged;
+ await Promise.all([listenToIceConnected(pc1), listenToIceConnected(pc2)]);
+
+ assert_greater_than(exchange.from(pc1).gathered().length, gatheredBefore,
+ 'the restart gathered a new generation of candidates');
+ await checkEverything(pc1, exchange, 'offerer');
+ await checkEverything(pc2, exchange, 'answerer');
+}), 'ICE restart: the new generation is obfuscated too');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ await pc1.setLocalDescription();
+ await waitForIceGatheringState(pc1, ['complete']);
+
+ const {sdp} = pc1.localDescription;
+ const candidateLines = sdp.split('\r\n').filter(l => l.startsWith('a=candidate:'));
+ assert_greater_than(candidateLines.length, 0, 'local description has candidates');
+ for (const line of candidateLines) {
+ assert_false(containsIP(line), `no IP address in "${line}"`);
+ }
+ // The default candidates are hidden as well. Before the answer settles
+ // rtcp-mux, the offer also carries a default RTCP candidate.
+ assert_regexp_match(sdp, /\r\nm=audio 9 /, 'm= port is the placeholder');
+ assert_regexp_match(sdp, /\r\nc=IN IP4 0\.0\.0\.0\r\n/,
+ 'c= line is the placeholder address');
+ const rtcpLines = sdp.split('\r\n').filter(l => l.startsWith('a=rtcp:'));
+ assert_greater_than(rtcpLines.length, 0, 'offer has an a=rtcp line');
+ for (const line of rtcpLines) {
+ assert_equals(line, 'a=rtcp:9 IN IP4 0.0.0.0',
+ 'a=rtcp line is the placeholder address');
+ }
+}), 'Local description: candidate lines and the default candidates carry no ' +
+ 'IP address');
+
+// The NAT simulator is configured per connection, when its ICE context is
+// created. Putting only the offerer behind it means the answerer sees the
+// offerer at a mapped port, so the offerer learns a local peer-reflexive
+// candidate: its own host address, as some peer saw it. That peer could be
+// another RTCPeerConnection in the same document, so it must be hidden like
+// everything else.
+const NAT_PREFS = [
+ ['media.peerconnection.nat_simulator.filtering_type', 'ENDPOINT_INDEPENDENT'],
+ ['media.peerconnection.nat_simulator.mapping_type', 'ENDPOINT_INDEPENDENT'],
+];
+
+// The ICE context is created asynchronously in another process. A stats
+// request is answered by that context, so once one resolves the context
+// exists and has read its configuration.
+const waitForIceContext = pc => pc.getStats();
+
+promise_test(t => withObfuscation(async () => {
+ const pc2 = createPeerConnectionWithCleanup(t);
+ await waitForIceContext(pc2);
+ const pc1 = await withPrefs(NAT_PREFS, async () => {
+ const pc = createPeerConnectionWithCleanup(t);
+ pc.addTransceiver('audio');
+ await waitForIceContext(pc);
+ return pc;
+ });
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ const pc1pair = selectedPairOf(pc1, 'offerer');
+ assert_equals(pc1pair.local.type, 'prflx',
+ 'offerer connected through a local peer-reflexive candidate');
+ await checkEverything(pc1, exchange, 'offerer');
+ const localPrflx = candidateStats(await pc1.getStats()).filter(
+ c => c.type == 'local-candidate' && c.candidateType == 'prflx');
+ assert_greater_than(localPrflx.length, 0, 'offerer has local prflx stats');
+ for (const c of localPrflx) {
+ assert_equals(c.address, null,
+ `local prflx ${c.id} address is not exposed (got "${c.address}")`);
+ }
+
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assertHiddenPrflx(pc2pair.remote, 'answerer selected remote');
+ await checkEverything(pc2, exchange, 'answerer');
+}), 'Behind a NAT: a local peer-reflexive candidate does not expose our ' +
+ 'address either');
+</script>
diff --git a/testing/web-platform/tests/webrtc/RTCIceTransport-peer-reflexive-remote.html b/testing/web-platform/tests/webrtc/RTCIceTransport-peer-reflexive-remote.html
new file mode 100644
index 00000000000..57c10cb389f
--- /dev/null
+++ b/testing/web-platform/tests/webrtc/RTCIceTransport-peer-reflexive-remote.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<meta charset=utf-8>
+<meta name="timeout" content="long">
+<title>Peer-reflexive remote candidates do not expose their address</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="RTCPeerConnection-helper.js"></script>
+<script>
+'use strict';
+
+// A side that is never told the other side's candidates can only connect
+// through a peer-reflexive remote candidate, whose address it learned from the
+// network rather than from the application. webrtc-pc and webrtc-stats both
+// say that address must not be exposed. Holding the offerer's candidates back
+// puts the answerer in exactly that position.
+async function connectAnswererThroughPrflx(t) {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ exchangeIceCandidates(pc1, pc2).from(pc1).hold();
+ await exchangeOfferAnswer(pc1, pc2);
+ await Promise.all([listenToIceConnected(pc1), listenToIceConnected(pc2)]);
+ return pc2;
+}
+
+promise_test(async t => {
+ const pc2 = await connectAnswererThroughPrflx(t);
+ const pair = pc2.getSenders()[0].transport.iceTransport.getSelectedCandidatePair();
+ assert_not_equals(pair, null, 'answerer has a selected candidate pair');
+ const {remote} = pair;
+ assert_equals(remote.type, 'prflx',
+ 'answerer only knows the offerer through its connectivity checks');
+ assert_equals(remote.candidate, '',
+ 'candidate is the empty string for a peer-reflexive remote candidate');
+ assert_equals(remote.address, null,
+ 'address is null for a peer-reflexive remote candidate');
+}, 'getSelectedCandidatePair() hides the address of a peer-reflexive remote ' +
+ 'candidate');
... (file diff truncated)
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.ini b/testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.ini
new file mode 100644
index 00000000000..6216a3e4b61
--- /dev/null
+++ b/testing/web-platform/mozilla/meta/webrtc/mdns-candidate-obfuscation.html.ini
@@ -0,0 +1,3 @@
+[mdns-candidate-obfuscation.html]
+ disabled:
+ if (os == "mac") and (processor == "aarch64"): https://bugzilla.mozilla.org/show_bug.cgi?id=2041035
diff --git a/testing/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html b/testing/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html
new file mode 100644
index 00000000000..d1dfe49be85
--- /dev/null
+++ b/testing/web-platform/mozilla/tests/webrtc/mdns-candidate-obfuscation.html
@@ -0,0 +1,343 @@
+<!doctype html>
+<meta charset=utf-8>
+<meta name="timeout" content="long">
+<title>mDNS obfuscation tests</title>
+<script src=/resources/testharness.js></script>
+<script src=/resources/testharnessreport.js></script>
+<script src="/webrtc/RTCPeerConnection-helper.js"></script>
+<script>
+'use strict';
+
+const OBFUSCATE_PREF = 'media.peerconnection.ice.obfuscate_host_addresses';
+
+async function withPrefs(prefs, func) {
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+ try {
+ return await func();
+ } finally {
+ await SpecialPowers.popPrefEnv();
+ }
+}
+const withObfuscation = func => withPrefs([[OBFUSCATE_PREF, true]], func);
+const withoutObfuscation = func => withPrefs([[OBFUSCATE_PREF, false]], func);
+
+// Per-token and anchored, so a bare RTCIceCandidate.address is caught just as
+// well as an address embedded in a candidate-attribute string. Loose on
+// purpose; the sanity test below proves these fire on real addresses.
+const ipv4Token = /^(\d{1,3}\.){3}\d{1,3}$/;
+// At least two colons, nothing but hex, colons and dots (v4-mapped forms).
+const ipv6Token = /^(?=(?:[^:]*:){2})[0-9a-fA-F:.]+$/;
+// Our resolver only queries names with exactly one dot (see
+// PeerConnectionImpl::AddIceCandidate), so hold ourselves to producing those.
+const mdnsName = /^[^.\s]+\.local$/;
+
+const tokensOf = str => String(str ?? '').trim().split(/\s+/);
+const containsIP = str =>
+ tokensOf(str).some(tok => ipv4Token.test(tok) || ipv6Token.test(tok));
+
+async function connect(pc1, pc2) {
+ await exchangeOfferAnswer(pc1, pc2);
+ await Promise.all([listenToIceConnected(pc1), listenToIceConnected(pc2)]);
+}
+
+function iceTransportOf(pc, who) {
+ const [sender] = pc.getSenders();
+ assert_not_equals(sender?.transport, null, `${who} has a DTLS transport`);
+ return sender.transport.iceTransport;
+}
+
+function selectedPairOf(pc, who) {
+ const pair = iceTransportOf(pc, who).getSelectedCandidatePair();
+ assert_not_equals(pair, null, `${who} has a selected candidate pair`);
+ return pair;
+}
+
+function assertNoIPIn(cand, who) {
+ assert_not_equals(cand, null, `${who} exists`);
+ for (const field of ['candidate', 'address', 'relatedAddress']) {
+ assert_false(containsIP(cand[field]),
+ `${who}.${field} must not contain an IP address (got "${cand[field]}")`);
+ }
+ if (cand.type == 'host' && cand.address !== null) {
+ assert_regexp_match(cand.address, mdnsName,
+ `${who}.address is an mDNS name`);
+ }
+}
+
+// webrtc-pc's exposure rules for peer-reflexive remote candidates: the
+// candidate string is empty and the addresses are null.
+function assertHiddenPrflx(cand, who) {
+ assert_equals(cand.type, 'prflx', `${who} is peer-reflexive`);
+ assert_equals(cand.candidate, '', `${who}.candidate is hidden`);
+ assert_equals(cand.address, null, `${who}.address is hidden`);
+ assert_equals(cand.relatedAddress, null, `${who}.relatedAddress is hidden`);
+}
+
+// A remote candidate we surface, unless peer-reflexive, must be one of the
+// candidates content handed to addIceCandidate, carrying the address content
+// sent rather than whatever we resolved it to.
+function assertIsSignaled(cand, delivered, who) {
+ if (cand.type == 'prflx') {
+ assertHiddenPrflx(cand, who);
+ return;
+ }
+ assert_in_array(cand.candidate, delivered.map(c => c.candidate),
+ `${who} is a candidate signaled via addIceCandidate`);
+}
+
+const candidateStats = report =>
+ [...report.values()].filter(
+ ({type}) => type == 'local-candidate' || type == 'remote-candidate');
+
+async function checkStats(pc, who) {
+ const report = await pc.getStats();
+ const cands = candidateStats(report);
+ for (const type of ['local-candidate', 'remote-candidate']) {
+ assert_true(cands.some(c => c.type == type), `${who} has ${type} stats`);
+ }
+ for (const c of cands) {
+ const label = `${who} ${c.type} ${c.candidateType} ${c.id}`;
+ assert_false(containsIP(c.address),
+ `${label} address must not be an IP address (got "${c.address}")`);
+ if (c.type == 'remote-candidate' && c.candidateType == 'prflx') {
+ // webrtc-stats: address MUST be left null unless the address was learned
+ // through addIceCandidate.
+ assert_equals(c.address, null,
+ `${label} address is not exposed (got "${c.address}")`);
+ } else if (c.candidateType == 'host' && c.address) {
+ assert_regexp_match(c.address, mdnsName, `${label} address is an mDNS name`);
+ }
+ }
+ return report;
+}
+
+// Every way `pc` can show a candidate to content. Callers must have awaited
+// the exchange's complete() so gathered() and delivered() are final.
+async function checkEverything(pc, exchange, who) {
+ const gathered = exchange.from(pc).gathered();
+ const delivered = exchange.to(pc).delivered();
+ assert_greater_than(gathered.length, 0, `${who} gathered candidates`);
+ gathered.forEach((c, i) => assertNoIPIn(c, `${who} gathered[${i}]`));
+
+ const pair = selectedPairOf(pc, who);
+ assertNoIPIn(pair.local, `${who} selected local`);
+ assertNoIPIn(pair.remote, `${who} selected remote`);
+ assertIsSignaled(pair.remote, delivered, `${who} selected remote`);
+
+ await checkStats(pc, who);
+}
+
+promise_test(t => withoutObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ const hosts = exchange.from(pc1).gathered().filter(({type}) => type == 'host');
+ assert_greater_than(hosts.length, 0, 'offerer gathered host candidates');
+ for (const c of hosts) {
+ assert_true(containsIP(c.candidate), `detector fires on "${c.candidate}"`);
+ assert_true(containsIP(c.address), `detector fires on bare "${c.address}"`);
+ }
+
+ // The offerer often connects through peer-reflexive candidates before the
+ // answerer's trickle reaches it, and nICEr drops candidates that arrive
+ // after that, so the answerer is the side that reliably knows signaled
+ // remote candidates.
+ const {local, remote} = selectedPairOf(pc2, 'answerer');
+ assert_true(containsIP(local.address), 'unobfuscated local address is an IP');
+ assert_equals(remote.type, 'host', 'answerer selected the signaled remote');
+ assert_true(containsIP(remote.address),
+ 'a remote address learned through addIceCandidate may be shown');
+
+ const report = await pc2.getStats();
+ const signaled = candidateStats(report).filter(
+ c => c.type == 'remote-candidate' && c.candidateType == 'host');
+ assert_greater_than(signaled.length, 0, 'answerer has signaled remotes');
+ for (const c of signaled) {
+ assert_true(containsIP(c.address),
+ `a remote address learned through addIceCandidate may be shown (${c.id})`);
+ }
+ assert_true(candidateStats(report).some(
+ c => c.type == 'local-candidate' && containsIP(c.address)),
+ 'detector fires on local candidate stats');
+}), 'Control: with obfuscation off, host candidates are plain IP addresses, ' +
+ 'which we should detect');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+ await checkEverything(pc1, exchange, 'offerer');
+ await checkEverything(pc2, exchange, 'answerer');
+}), 'Trickle both ways: nothing exposed to content contains an IP address');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ // Holding the answerer's candidates back means the offerer cannot send a
+ // check until the answerer has resolved the offerer's mDNS names and paired
+ // them, so the answerer's selected remote has to be the signaled candidate
+ // rather than a peer-reflexive one. This is the path that used to surface
+ // the resolved address.
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ exchange.from(pc2).hold();
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assert_equals(pc2pair.remote.type, 'host', 'answerer selected the signaled remote');
+ assert_regexp_match(pc2pair.remote.address, mdnsName,
+ 'the signaled remote keeps its mDNS name');
+ await checkEverything(pc2, exchange, 'answerer');
+
+ const pc1pair = selectedPairOf(pc1, 'offerer');
+ assertHiddenPrflx(pc1pair.remote, 'offerer selected remote');
+ await checkEverything(pc1, exchange, 'offerer');
+}), 'A resolved mDNS remote candidate is surfaced under its name, never its ' +
+ 'resolved address');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ exchange.from(pc1).hold();
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+
+ // The answerer never heard about the offerer's candidates, so it can only
+ // have connected through a peer-reflexive remote candidate.
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assertHiddenPrflx(pc2pair.remote, 'answerer selected remote');
+ assertNoIPIn(pc2pair.local, 'answerer selected local');
+ const report = await checkStats(pc2, 'answerer');
+ const remotes = [...report.values()].filter(s => s.type == 'remote-candidate');
+ assert_greater_than(remotes.length, 0, 'answerer has remote candidate stats');
+ assert_true(remotes.every(s => s.candidateType == 'prflx'),
+ 'answerer knows only peer-reflexive remote candidates');
+
+ // The offerer was told everything, as usual.
+ await checkEverything(pc1, exchange, 'offerer');
+
+ // Now the held candidates arrive. The addresses the answerer already knows
+ // as peer-reflexive get signaled under mDNS names. nICEr drops trickled
+ // candidates once the stream is connected, so they will not show up in
+ // stats; the remote description still mirrors them, and learning the names
+ // must not unhide the prflx entries that resolve to the same addresses.
+ await exchange.from(pc1).release();
+ const delivered = exchange.to(pc2).delivered();
+ assert_greater_than(delivered.length, 0, 'the held candidates were delivered');
+
+ const pairAfter = selectedPairOf(pc2, 'answerer (late)');
+ assertNoIPIn(pairAfter.local, 'answerer (late) selected local');
+ assertNoIPIn(pairAfter.remote, 'answerer (late) selected remote');
+ assertIsSignaled(pairAfter.remote, delivered, 'answerer (late) selected remote');
+ await checkStats(pc2, 'answerer (late)');
+}), 'One-way trickle: a side connected through prflx never sees the peer ' +
+ 'address, before or after the candidates arrive late');
+
+promise_test(t => withoutObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ exchangeIceCandidates(pc1, pc2).from(pc1).hold();
+ await connect(pc1, pc2);
+
+ const pc2pair = selectedPairOf(pc2, 'answerer');
+ assertHiddenPrflx(pc2pair.remote, 'answerer selected remote');
+ const report = await pc2.getStats();
+ const remotes = [...report.values()].filter(s => s.type == 'remote-candidate');
+ assert_greater_than(remotes.length, 0, 'answerer has remote candidate stats');
+ for (const s of remotes) {
+ assert_equals(s.candidateType, 'prflx', `${s.id} is peer-reflexive`);
+ assert_equals(s.address, null,
+ `prflx remote-candidate ${s.id} address is not exposed (got "${s.address}")`);
+ }
+}), 'Peer-reflexive remote candidates are hidden even when we are not ' +
+ 'obfuscating our own addresses');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1, pc2] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ const exchange = exchangeIceCandidates(pc1, pc2);
+ await Promise.all([exchange.complete(), connect(pc1, pc2)]);
+ const gatheredBefore = exchange.from(pc1).gathered().length;
+
+ const pairChanged = Promise.all([
+ waitUntilEvent(iceTransportOf(pc1, 'offerer'), 'selectedcandidatepairchange'),
+ waitUntilEvent(iceTransportOf(pc2, 'answerer'), 'selectedcandidatepairchange'),
+ ]);
+ pc1.restartIce();
+ await Promise.all([exchange.complete(), exchangeOfferAnswer(pc1, pc2)]);
+ await pairChanged;
+ await Promise.all([listenToIceConnected(pc1), listenToIceConnected(pc2)]);
+
+ assert_greater_than(exchange.from(pc1).gathered().length, gatheredBefore,
+ 'the restart gathered a new generation of candidates');
+ await checkEverything(pc1, exchange, 'offerer');
+ await checkEverything(pc2, exchange, 'answerer');
+}), 'ICE restart: the new generation is obfuscated too');
+
+promise_test(t => withObfuscation(async () => {
+ const [pc1] = createPeerConnectionPairWithCleanup(t, ['audio']);
+ await pc1.setLocalDescription();
+ await waitForIceGatheringState(pc1, ['complete']);
+
+ const {sdp} = pc1.localDescription;
+ const candidateLines = sdp.split('\r\n').filter(l => l.startsWith('a=candidate:'));
+ assert_greater_than(candidateLines.length, 0, 'local description has candidates');
+ for (const line of candidateLines) {
+ assert_false(containsIP(line), `no IP address in "${line}"`);
+ }
+ // The default candidates are hidden as well. Before the answer settles
+ // rtcp-mux, the offer also carries a default RTCP candidate.
... (truncated)
Loading diff…
References
On This Page