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.
Summary
Section titled “Summary”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 candidate —
pc.getStats()reportsremote-candidate: []andcandidate-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.
Reproduction
Section titled “Reproduction”The reproduction runs entirely inside one LAN. No NAT, no firewall, no QUIC server colocated on UDP/443.
Server side (any host on the LAN)
Section titled “Server side (any host on the LAN)”# server.py — answers any STUN binding request, prints what arrived.import sockets = 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.pyBrowser 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=0o=- 0 0 IN IP4 0.0.0.0s=-t=0 0a=ice-litem=video 9 UDP/TLS/RTP/SAVPF 96c=IN IP4 0.0.0.0a=candidate:1 1 udp 2130706175 <SERVER_IP> 443 typ hosta=ice-ufrag:abc123a=ice-pwd:0123456789abcdef0123456789abcdefa=ice-options:tricklea=fingerprint:sha-256 <CERT_FINGERPRINT>a=setup:actpassa=mid:0a=sendonlya=rtcp-muxa=rtpmap:96 VP8/90000The 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.Expected behaviour
Section titled “Expected behaviour”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" } ]}Observed behaviour (Chromium)
Section titled “Observed behaviour (Chromium)”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.
Network plane is innocent
Section titled “Network plane is innocent”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).
Code reference
Section titled “Code reference”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.
Workaround — what SYNVirt ships
Section titled “Workaround — what SYNVirt ships”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>,What we’d like Chromium to do
Section titled “What we’d like Chromium to do”- 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
- Surface the filter through
chrome://webrtc-internalswith a visible “candidate rejected: port 443” log line so operators don’t waste days on phantom-bug diagnosis.
See also
Section titled “See also”- discuss-webrtc — Regarding 443 port as Remote host Candidate
- webrtc/3583 — webrtc not start connectivity for a tcp candidate with private ip
- Tenable — Using WebRTC ICE Servers for Port Scanning in Chrome
(related context — Chromium WebRTC has additional restrictions
beyond
kRestrictedPorts)
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.
Operational impact
Section titled “Operational impact”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.
Removal plan
Section titled “Removal plan”Implement peer-reflexive learning in
crates/synvirt-webrtc/src/udp_mux.rs:
-
For incoming UDP packets, dispatch by first byte (RFC 7983):
0x00..=0x03— STUN0x14..=0x3F— DTLS (0x16is the ClientHello in practice)0x80..=0xBF— SRTP / SRTCP
-
For STUN binding requests:
- Parse the
USERNAMEattribute, extract the local ufrag (the part before:). - Find the session by
local_ufrag. - Register
src_addrinsession.addr_map(peer-reflexive). - Pass to str0m with
Receive {source: src_addr,destination: session.advertised_addr,contents: pkt,proto: Protocol::Udp,}
- Parse the
-
For DTLS / SRTP / SRTCP: look up the session by
src_addrinaddr_map. -
For the
ice-candidateREST 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_portfromDaemonConfig. - Remove the secondary candidate emission from
crates/synvirt-webrtc/src/session.rs::Client::create_offerer. - Update operator docs to single-port.
- Mark this
## Current workaroundsection as Resolved and link the resolving commit.