Skip to content

Chromium silently filters WebRTC ICE host candidates with port=443

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/known-issues/chromium-webrtc-host-candidate-port-443.md. Edit at the source, not here.

Chromium silently filters WebRTC ICE host candidates with port=443

Section titled “Chromium silently filters WebRTC ICE host candidates with port=443”

Status: no specific Chromium / WebRTC tracker issue exists for this exact reproduction. The closest reference is webrtc/3583 (“webrtc not start connectivity for a tcp candidate with private ip”, filed 2014-08-25) and the discuss-webrtc thread Regarding 443 port as Remote host Candidate. Both describe related-but-different symptoms (TCP candidate, srflx in private network) and neither carries a resolution.

This document is the draft we’d file upstream if the SYNVirt team decides to push the bug into Chromium’s tracker. It captures a minimal, reproducible test that isolates the filter.

When a WebRTC peer is offered a remote ICE candidate of type host on UDP port 443 in a same-LAN private-network setup, Chromium silently drops the candidate before any STUN binding request is emitted. The Chromium-side RTCPeerConnection:

  • has the offer/answer applied successfully (signalingState = stable),
  • gathers its own local candidates and finishes ICE gathering (iceGatheringState = complete),
  • does not produce an entry for the offered remote candidatepc.getStats() reports remote-candidate: [] and candidate-pair: [],
  • never transitions iceConnectionState past new.

Firefox accepts the same candidate, attempts the STUN pair, and reaches connected. A raw Python UDP probe to the same host:port arrives at the server, so the network path itself is clean.

The reproduction runs entirely inside one LAN. No NAT, no firewall, no QUIC server colocated on UDP/443.

# server.py — answers any STUN binding request, prints what arrived.
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 443))
print("listening on 0.0.0.0:443")
while True:
data, src = s.recvfrom(2000)
print(f"recv {len(data)} bytes from {src}, first byte=0x{data[0]:02x}")

Run as root so the privileged-port bind succeeds:

sudo python3 server.py

Browser side: an SDP offer with a host candidate on UDP/443

Section titled “Browser side: an SDP offer with a host candidate on UDP/443”

A WebRTC peer (or test page) accepts the following SDP offer (generated by str0m 0.19 with set_ice_lite(true)):

v=0
o=- 0 0 IN IP4 0.0.0.0
s=-
t=0 0
a=ice-lite
m=video 9 UDP/TLS/RTP/SAVPF 96
c=IN IP4 0.0.0.0
a=candidate:1 1 udp 2130706175 <SERVER_IP> 443 typ host
a=ice-ufrag:abc123
a=ice-pwd:0123456789abcdef0123456789abcdef
a=ice-options:trickle
a=fingerprint:sha-256 <CERT_FINGERPRINT>
a=setup:actpass
a=mid:0
a=sendonly
a=rtcp-mux
a=rtpmap:96 VP8/90000

The browser code:

const pc = new RTCPeerConnection({ iceServers: [] });
await pc.setRemoteDescription({ type: "offer", sdp: theSdpAbove });
const ans = await pc.createAnswer();
await pc.setLocalDescription(ans);
// the answer is sent back to the server out-of-band — irrelevant
// for this bug, the bug is observable before any UDP packet leaves
// the browser.

Browser sends STUN binding requests to <SERVER_IP>:443 from each of its gathered local candidates, the server prints them, and the candidate pair eventually reaches state=succeeded.

This is what Firefox 148 does. Output of pc.getStats() after a few hundred ms:

{
"iceConnectionState": "connected",
"candidatePairs": [{ "state": "succeeded", "nominated": true }],
"remoteCandidates": [
{ "address": "<SERVER_IP>", "port": 443, "candidateType": "host" }
]
}

Chromium 142 (Playwright bundle 1217), and Chrome stable on desktop, both behave the same way:

{
"iceConnectionState": "new",
"iceGatheringState": "complete",
"signalingState": "stable",
"candidatePairs": [],
"remoteCandidates": []
}

The Python server prints nothing. Chromium emits no STUN binding request. After ~25 s the iceConnectionState transitions straight to failed / disconnected without going through checking.

If the SDP offer is rebuilt with the same server IP but a different port (e.g. 12345), Chromium accepts the candidate, the Python server receives binding requests, and the pair reaches succeeded. The only variable that changed is the port number.

The issue is purely in the browser’s WebRTC stack:

Sender UDP/443 packet arrives?
python3 -c "socket.sendto(...,443)" from the browser host yes
nc -u <server> 443 yes
Firefox WebRTC (host candidate UDP/443) yes
Chromium WebRTC (host candidate UDP/443) no

iptables, nftables, and any L3 firewall on either side are out of the loop (default ACCEPT, both hosts on the same /24).

The “documented” web-restricted-port list at net/base/port_util.cc::kRestrictedPorts does not include 443. The shipped WebRTC port allocator in p2p/client/basic_port_allocator.cc::IsAllowedByCandidateFilter filters by candidate type (host / srflx / relay), not by port. So the filter we observe lives somewhere between those two layers — most likely a Chromium-side wrapper around WebRTC’s Connection::AddRemoteCandidate or in the platform IPC bridge (blink/renderer/modules/peerconnection/).

We have not identified the exact filter. Locating it is part of what this bug report is asking the Chromium team to do.

We avoid the filter by binding the WebRTC media UDP socket on a non-443 port and advertising that port in the SDP host candidate. The dashboard’s TCP/HTTPS listener stays on whatever port the operator configured (usually 443).

In crates/daemon/src/config/mod.rs:

/// UDP port for WebRTC media (DTLS+SRTP). Defaults to
/// `https_port` so the operator manages one number end-to-end —
/// TCP and UDP share the listener number. The escape hatch
/// exists for one specific quirk: Chromium's WebRTC stack
/// silently filters host ICE candidates with `port 443` (Firefox
/// connects to UDP/443 host candidates without complaint). When
/// most users will be on Chromium, set this to a non-443 port
/// to dodge that filter; otherwise leave unset.
///
/// See `docs/known-issues/chromium-webrtc-host-candidate-port-443.md`.
#[serde(default)]
pub webrtc_udp_port: Option<u16>,
  1. Either lift the silent filter (since UDP/443 is otherwise a perfectly valid peer-connection target — there’s no security reason the same browser process can’t dial UDP/443 outside its QUIC stack), or
  2. Surface the filter through chrome://webrtc-internals with a visible “candidate rejected: port 443” log line so operators don’t waste days on phantom-bug diagnosis.

Current workaround (as of 2026-05-10): dual-port

Section titled “Current workaround (as of 2026-05-10): dual-port”

Until peer-reflexive learning is implemented in str0m’s UDP mux layer, the daemon advertises two host candidates in the SDP offer: the primary on https_port (default 443) and a secondary on webrtc_udp_secondary_port (default 12345). Both ports listen on the same daemon process. Browsers pick the candidate that works for them. Chromium connects via the secondary; Firefox connects via either.

A correction to earlier write-ups in this file: tcpdump on 10.10.26.52 confirms UDP/443 flows bidirectionally with Chrome too — there is no network-level filter. The real ICE blocker is that Chrome ships its host candidate with an mDNS <uuid>.local hostname (RFC 8828, privacy by default). str0m + ICE-lite cannot resolve mDNS names, so the trickle candidate is dropped at the add_remote_candidate parser. Without a remote candidate registered, ICE has no pair to nominate even though STUN binding requests are arriving on the wire. Peer-reflexive learning fixes this; dual-port sidesteps it.

Operators must open BOTH UDP ports in their firewall:

  • TCP+UDP/<https_port> (typically 443)
  • UDP/<webrtc_udp_secondary_port> (default 12345)

This violates the single-port-two-protocols architecture that is otherwise the product contract.

Implement peer-reflexive learning in crates/synvirt-webrtc/src/udp_mux.rs:

  1. For incoming UDP packets, dispatch by first byte (RFC 7983):

    • 0x00..=0x03 — STUN
    • 0x14..=0x3F — DTLS (0x16 is the ClientHello in practice)
    • 0x80..=0xBF — SRTP / SRTCP
  2. For STUN binding requests:

    • Parse the USERNAME attribute, extract the local ufrag (the part before :).
    • Find the session by local_ufrag.
    • Register src_addr in session.addr_map (peer-reflexive).
    • Pass to str0m with
      Receive {
      source: src_addr,
      destination: session.advertised_addr,
      contents: pkt,
      proto: Protocol::Udp,
      }
  3. For DTLS / SRTP / SRTCP: look up the session by src_addr in addr_map.

  4. For the ice-candidate REST handler: If the candidate hostname ends with .local, accept silently without passing to str0m — peer-reflexive will resolve it from the first STUN binding request.

Once implemented and validated with Chrome, Firefox, Edge:

  • Remove webrtc_udp_secondary_port from DaemonConfig.
  • Remove the secondary candidate emission from crates/synvirt-webrtc/src/session.rs::Client::create_offerer.
  • Update operator docs to single-port.
  • Mark this ## Current workaround section as Resolved and link the resolving commit.