Hi everyone,
I came across the Firefox preference network.http.http3.sni-slicing, which appears to be related to HTTP/3 and SNI handling. However, I’ve found very little documentation explaining what it actually does.
Could someone with knowledge of Firefox internals or HTTP/3 clarify:
- What exactly does this setting change in the browser’s behavior?
- Does it provide any privacy or security benefits?
I’d appreciate any insights. Thanks!
So I am not entirely sure. I did find the code for it however if you want to take a look.
In Firefox it uses the variable for the neqo library, which is the the Mozilla Firefox implementation of QUIC in Rust.
Line #284: https://github.com/mozilla-firefox/firefox/blob/57e6d88cb3ad7f9777145f2d4fba11d4fc9de369/netwerk/socket/neqo_glue/src/lib.rs#L284
code:
let mut params = ConnectionParameters::default() .versions(quic_version, version_list) .cc_algorithm(cc_algorithm) .max_data(max_data) .max_stream_data(StreamType::BiDi, false, max_stream_data) .grease(static_prefs::pref!("security.tls.grease_http3_enable")) .sni_slicing(static_prefs::pref!("network.http.http3.sni-slicing")) .idle_timeout(Duration::from_secs(idle_timeout.into())) // Disabled on OpenBSD. See <https://bugzilla.mozilla.org/show_bug.cgi?id=1952304>. .pmtud_iface_mtu(cfg!(not(target_os = "openbsd"))) // MLKEM support is configured further below. By default, disable it. .mlkem(false);
In the neqo library it’s used here: https://github.com/mozilla/neqo/blob/9e52e922343609dba5171c0adb869cff7bd8d3a0/neqo-transport/src/crypto.rs#L1594
code:
let written = if sni_slicing && offset == 0 { if let Some(sni) = find_sni(data) { // Cut the crypto data in two at the midpoint of the SNI and swap the chunks. let mid = sni.start + (sni.end - sni.start) / 2; let (left, right) = data.split_at(mid); // Truncate the chunks so we can fit them into roughly evenly-filled packets. let packets_needed = data.len().div_ceil(builder.limit()); let limit = data.len() / packets_needed; let ((left_offset, left), (right_offset, right)) = limit_chunks((offset, left), (offset + mid as u64, right), limit); ( write_chunk(right_offset, right, builder), write_chunk(left_offset, left, builder), ) } else { // No SNI found, write the entire data. (write_chunk(offset, data, builder), None) } } else { // SNI slicing disabled or data not at offset 0, write the entire data. (write_chunk(offset, data, builder), None) };
After a quick look, looks like it tries to split the (unencrypted) hostname into multiple packets, or at least scramble it slightly. I’m not sure how much it helps in practice, but it might help against naïve filtering/scanning, as the hostname is either sent in different packets, or split and sent unordered in the same packet. It probably only helps if encrypted client hello isn’t supported.
TL;DR: If I’ve understood everything correctly, it just moves chunks of the plaintext hostname around & tries to split it into multiple packets.
Note: Mostly based on comments, as it’s late & I’m too tired to parse too much cryptography code.
Full source of the
limit_chunks
function, formatted with Rustfmt:const fn limit_chunks<'a>( left: (u64, &'a [u8]), right: (u64, &'a [u8]), limit: usize, ) -> ((u64, &'a [u8]), (u64, &'a [u8])) { let (left_offset, mut left) = left; let (mut right_offset, mut right) = right; if left.len() + right.len() <= limit { // Nothing to do. Both chunks will fit into one packet, meaning the SNI isn't spread // over multiple packets. But at least it's in two unordered CRYPTO frames. } else if left.len() <= limit { // `left` is short enough to fit into this packet. So send from the *end* // of `right`, so that the second half of the SNI is in another packet. let right_len = right.len() + left.len() - limit; right_offset += right_len as u64; (_, right) = right.split_at(right_len); } else if right.len() <= limit { // `right` is short enough to fit into this packet. So only send a part of `left`. // The SNI begins at the end of `left`, so send the beginnig of it in this packet. (left, _) = left.split_at(limit - right.len()); } else { // Both chunks are too long to fit into one packet. Just send a part of each. (left, _) = left.split_at(limit / 2); (right, _) = right.split_at(limit / 2); } ((left_offset, left), (right_offset, right)) }
Same, but for
write_chunk
:fn write_chunk<B: Buffer>( offset: u64, data: &[u8], builder: &mut packet::Builder<B>, ) -> Option<(u64, usize)> { let mut header_len = 1 + Encoder::varint_len(offset) + 1; // Don't bother if there isn't room for the header and some data. if builder.remaining() < header_len + 1 { return None; } // Calculate length of data based on the minimum of: // - available data // - remaining space, less the header, which counts only one byte for the length at // first to avoid underestimating length let length = min(data.len(), builder.remaining() - header_len); header_len += Encoder::varint_len(u64::try_from(length).expect("usize fits in u64")) - 1; let length = min(data.len(), builder.remaining() - header_len); builder.encode_varint(FrameType::Crypto); builder.encode_varint(offset); builder.encode_vvec(&data[..length]); Some((offset, length)) }
Link to the MIT license file