This commit is contained in:
Daniel Lavrushin 2026-08-17 19:17:02 +02:00 committed by Daniel Lavrushin
parent df4796da34
commit ebd6364b26
12 changed files with 592 additions and 18 deletions

View file

@ -1,5 +1,10 @@
# B4 - Bye Bye Big Bro
## [1.78.0] - 2026-08-17
- FIXED: **Traffic that merely used port 53 was read as a name lookup** - a VPN or proxy tunnel hidden on that port had its encrypted contents taken for a domain name and matched against sets.
- FIXED: **The server name read out of a QUIC connection was accepted whatever it contained** - any bytes of any length passed for a hostname, unlike the same name read from an ordinary HTTPS connection, which was checked.
## [1.77.0] - 2026-08-15
- ADDED: **A Customize control on the dashboard: panels are reordered by dragging, widened or narrowed by dragging the right edge across a twelve column grid, and hidden with the eye, remembered per browser** - the order and the widths were fixed in the code, so a panel worth keeping an eye on, such as the MTProto proxy and the devices using it, sat below a domain list hundreds of rows long, there was no way to move it up, give it more room or drop a panel that never gets read, and a short panel beside a tall one left everything after it waiting below the taller one.

View file

@ -1,5 +1,10 @@
# B4 - Bye Bye Big Bro
## [1.78.0] - 2026-08-17
- ИСПРАВЛЕНО: **Трафик, который лишь использовал порт 53, читался как запрос имени** - у VPN- или прокси-туннеля, спрятанного на этом порту, зашифрованное содержимое принималось за доменное имя и сопоставлялось с сетами.
- ИСПРАВЛЕНО: **Имя сервера, прочитанное из QUIC-соединения, принималось с любым содержимым** - за имя хоста проходили любые байты любой длины, в отличие от того же имени из обычного HTTPS-соединения, которое проверялось.
## [1.77.0] - 2026-08-15
- ДОБАВЛЕНО: **Кнопка «Настроить» на главной странице: панели переставляются перетаскиванием, расширяются и сужаются потягиванием за правый край по сетке из двенадцати колонок, ненужные скрываются глазом, раскладка запоминается в браузере** - порядок и ширина были заданы в коде, поэтому панель, за которой хочется следить, например MTProto-прокси и устройства, которые им пользуются, стояла под списком доменов в сотни строк, не было способа поднять её выше, дать ей больше места или убрать панель, которую никто не читает, а низкая панель рядом с высокой заставляла всё, что идёт следом, ждать ниже высокой.

View file

@ -3,12 +3,21 @@ package dns
import (
"encoding/binary"
"net"
"strings"
)
const (
MaxLabelLen = 63
MaxNameLen = 255
)
func ParseQueryDomain(payload []byte) (string, bool) {
if len(payload) < 12 {
return "", false
}
if binary.BigEndian.Uint16(payload[4:6]) == 0 {
return "", false
}
pos := 12
var domain []byte
@ -16,9 +25,19 @@ func ParseQueryDomain(payload []byte) (string, bool) {
for pos < len(payload) {
length := int(payload[pos])
if length == 0 {
break
if len(domain) == 0 || pos+5 > len(payload) {
return "", false
}
return string(domain), true
}
if pos+1+length > len(payload) {
if length > MaxLabelLen || pos+1+length > len(payload) {
return "", false
}
need := length
if len(domain) > 0 {
need++
}
if len(domain)+need > MaxNameLen {
return "", false
}
if len(domain) > 0 {
@ -28,10 +47,41 @@ func ParseQueryDomain(payload []byte) (string, bool) {
pos += 1 + length
}
if len(domain) == 0 {
return "", false
return "", false
}
const hexDigits = "0123456789abcdef"
func nameByteUnsafe(c byte) bool {
return c < 0x20 || c == 0x7f
}
func SafeName(name string) string {
unsafeAt := -1
for i := 0; i < len(name); i++ {
if nameByteUnsafe(name[i]) {
unsafeAt = i
break
}
}
return string(domain), true
if unsafeAt < 0 {
return name
}
var b strings.Builder
b.Grow(len(name) + 16)
b.WriteString(name[:unsafeAt])
for i := unsafeAt; i < len(name); i++ {
c := name[i]
if nameByteUnsafe(c) {
b.WriteString(`\x`)
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
continue
}
b.WriteByte(c)
}
return b.String()
}
func ParseTransactionID(payload []byte) (uint16, bool) {

View file

@ -1,8 +1,11 @@
package dns
import (
"bytes"
"encoding/binary"
"math/rand"
"net"
"strconv"
"strings"
"testing"
)
@ -263,3 +266,130 @@ func TestParseResponseIPs(t *testing.T) {
}
})
}
func dnsHeader(qdCount uint16) []byte {
h := make([]byte, 12)
binary.BigEndian.PutUint16(h[0:2], 0xBEEF)
binary.BigEndian.PutUint16(h[2:4], 0x0100)
binary.BigEndian.PutUint16(h[4:6], qdCount)
return h
}
func walkExact(total int, labelLens ...int) []byte {
msg := dnsHeader(1)
for _, n := range labelLens {
msg = append(msg, byte(n))
msg = append(msg, bytes.Repeat([]byte{0x41}, n)...)
}
if len(msg) != total {
panic("walkExact: built " + strconv.Itoa(len(msg)) + " want " + strconv.Itoa(total))
}
return msg
}
func TestParseQueryDomainAcceptsWellFormed(t *testing.T) {
cases := []string{
"a.co",
"stun.cloudflare.com",
"_acme-challenge.example.org",
strings.Repeat("a", 63) + ".example.com",
}
for _, name := range cases {
got, ok := ParseQueryDomain(buildDNSQuery(0x1111, name, 1))
if !ok || got != name {
t.Errorf("ParseQueryDomain(%q) = %q, %v; want %q, true", name, got, ok, name)
}
}
}
func TestParseQueryDomainRejectsNonDNS(t *testing.T) {
tests := []struct {
name string
payload []byte
}{
{
name: "qdcount zero",
payload: append(dnsHeader(0), encodeDNSName("example.com")...),
},
{
name: "label length above 63",
payload: walkExact(1280, 200, 200, 200, 200, 200, 200, 61),
},
{
name: "walk ends on buffer edge with no root label",
payload: walkExact(21, 3, 4),
},
{
name: "compression pointer in question",
payload: append(dnsHeader(1), 0xC0, 0x0C, 0x00, 0x01, 0x00, 0x01),
},
{
name: "name longer than 255",
payload: append(append(dnsHeader(1),
encodeDNSName(strings.TrimSuffix(strings.Repeat(strings.Repeat("a", 63)+".", 5), "."))...),
0x00, 0x01, 0x00, 0x01),
},
{
name: "root label present but qtype truncated",
payload: append(dnsHeader(1), 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x01),
},
{
name: "root label at first position",
payload: append(dnsHeader(1), 0x00, 0x00, 0x01, 0x00, 0x01),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got, ok := ParseQueryDomain(tc.payload); ok {
t.Errorf("accepted non-DNS payload as %q (%d bytes)", got, len(got))
}
})
}
}
func TestParseQueryDomainBoundsTunnelPayloads(t *testing.T) {
rng := rand.New(rand.NewSource(0x5EED))
accepted := 0
const iterations = 20000
for i := 0; i < iterations; i++ {
payload := make([]byte, 1280)
rng.Read(payload)
name, ok := ParseQueryDomain(payload)
if !ok {
continue
}
accepted++
if len(name) > MaxNameLen {
t.Fatalf("accepted name of %d bytes, cap is %d", len(name), MaxNameLen)
}
if strings.ContainsAny(SafeName(name), "\x00\n\r") {
t.Fatalf("SafeName left a log-breaking byte in %q", SafeName(name))
}
}
if rate := float64(accepted) / iterations; rate > 0.01 {
t.Errorf("accepted %.3f%% of random 1280-byte payloads, want <= 1%%", rate*100)
}
t.Logf("accepted %d/%d random payloads", accepted, iterations)
}
func TestSafeName(t *testing.T) {
tests := []struct {
in string
want string
}{
{"stun.cloudflare.com", "stun.cloudflare.com"},
{"a\nb", `a\x0ab`},
{"a\x00b", `a\x00b`},
{"tail\x7f", `tail\x7f`},
{"\x01\x02", `\x01\x02`},
{"", ""},
}
for _, tc := range tests {
if got := SafeName(tc.in); got != tc.want {
t.Errorf("SafeName(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

115
src/dns/realworld_test.go Normal file
View file

@ -0,0 +1,115 @@
package dns
import (
"encoding/binary"
"strings"
"testing"
)
var realWorldNames = []string{
"0.pool.ntp.org",
"1-2-3.test-host.example.org",
"1.pool.ntp.org",
"192.168.1.1",
"UPPER.Example.COM",
"_dmarc.example.com",
"a.co",
"a961.b.akamai.net",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com",
"ae.iads.unity3d.com",
"afs.ampaeservices.com",
"analytics-ios.rayjump.com",
"api.miwifi.com",
"api16-core-ycru.tiktokv.com",
"assets.msn.com",
"beacons2.gvt2.com",
"cdn-v6.amp-endpoint3.com",
"cdn.activision.com",
"cdn.iads.unity3d.com",
"clck.yandex.net",
"d.applovin.com",
"graph.whatsapp.com",
"ipapi.co",
"kws2-1.web.telegram.org",
"kws2.web.telegram.org",
"kws203.onedaychamp.co.uk",
"localhost.localdomain",
"max.ru",
"mssdk-ru.tiktokv.com",
"p1.trex.media",
"pool.ntp.org",
"prefetch.monetization-sdk.chartboost.com",
"profile.gc.apple.com",
"pubads.g.doubleclick.net",
"quasar.yandex.net",
"sdkeventfnt-eu.dsp-api.moloco.com",
"ssl.gstatic.com",
"stun.cloudflare.com",
"sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.example.com",
"test-gateway.instagram.com",
"unagi-na.amazon.com",
"wps.apple.com",
"www.baidu.com",
"www.googleapis.com",
"x.io",
"xn--80ak6aa92e.com",
"xp.apple.com",
}
func TestParseQueryDomainRealWorldNames(t *testing.T) {
qtypes := []uint16{1, 28, 65, 5, 16, 12, 33, 257}
for _, name := range realWorldNames {
for _, qt := range qtypes {
query := buildDNSQuery(0x4242, name, qt)
if got, ok := ParseQueryDomain(query); !ok || got != name {
t.Errorf("query %q qtype=%d: got (%q,%v), want (%q,true)", name, qt, got, ok, name)
}
block := BuildBlockResponse(query)
if block == nil {
t.Errorf("BuildBlockResponse(%q) = nil", name)
continue
}
if got, ok := ParseQueryDomain(block); !ok || got != name {
t.Errorf("nxdomain response for %q: got (%q,%v)", name, got, ok)
}
servfail := BuildServfailResponse(query)
if servfail == nil {
t.Errorf("BuildServfailResponse(%q) = nil", name)
continue
}
if got, ok := ParseQueryDomain(servfail); !ok || got != name {
t.Errorf("servfail response for %q: got (%q,%v)", name, got, ok)
}
}
built := BuildQuery(name, 0x1234, 1)
want := strings.TrimSuffix(strings.TrimSpace(name), ".")
if got, ok := ParseQueryDomain(built); !ok || got != want {
t.Errorf("BuildQuery round-trip for %q: got (%q,%v), want %q", name, got, ok, want)
}
}
}
func TestParseQueryDomainToleratesExtraSections(t *testing.T) {
name := "cdn.activision.com"
query := buildDNSQuery(0x9999, name, 1)
withOPT := append([]byte(nil), query...)
withOPT = append(withOPT, 0x00, 0x00, 0x29, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
binary.BigEndian.PutUint16(withOPT[10:12], 1)
if got, ok := ParseQueryDomain(withOPT); !ok || got != name {
t.Errorf("EDNS0 OPT query: got (%q,%v), want (%q,true)", got, ok, name)
}
twoQuestions := append([]byte(nil), query...)
twoQuestions = append(twoQuestions, encodeDNSName("second.example.com")...)
twoQuestions = append(twoQuestions, 0x00, 0x01, 0x00, 0x01)
binary.BigEndian.PutUint16(twoQuestions[4:6], 2)
if got, ok := ParseQueryDomain(twoQuestions); !ok || got != name {
t.Errorf("QDCOUNT=2 query: got (%q,%v), want (%q,true)", got, ok, name)
}
}

View file

@ -287,7 +287,7 @@ func logDNSEvent(proto string, set *config.SetConfig, domain string, clientIP, s
if set != nil {
setName = set.Name
}
log.LogConnection(proto, setName, domain, clientIP.String(), clientPort, "", serverIP.String(), 53, srcMac, "", action)
log.LogConnection(proto, setName, dns.SafeName(domain), clientIP.String(), clientPort, "", serverIP.String(), 53, srcMac, "", action)
}
func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dport uint16, payload []byte) int {
@ -302,10 +302,10 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp
matcher := w.getMatcher()
if matchedSet, set := matcher.MatchSNIWithSource(domain, srcMac); matchedSet {
cfg := w.getConfig()
log.Tracef("DNS query: %s matched set %s (src %s)", domain, set.Name, srcMac)
log.Tracef("DNS query: %s matched set %s (src %s)", dns.SafeName(domain), set.Name, srcMac)
if escSet := w.escalatedSetFor(cfg, domain, srcMac); escSet != nil && escSet != set {
log.Tracef("DNS escalation hit for %s: %s -> %s", domain, set.Name, escSet.Name)
log.Tracef("DNS escalation hit for %s: %s -> %s", dns.SafeName(domain), set.Name, escSet.Name)
set = escSet
}
@ -330,7 +330,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp
if !ipv6Disabled {
if resp := dns.BuildBlockResponse(payload); resp != nil {
w.sendDNSResponseToClient(ipVersion, originalDst, clientIP, sport, resp)
log.Tracef("DNS sinkhole: %s -> NXDOMAIN for %s (set: %s)", domain, clientIP, set.Name)
log.Tracef("DNS sinkhole: %s -> NXDOMAIN for %s (set: %s)", dns.SafeName(domain), clientIP, set.Name)
logDNSEvent("UDP", set, domain, clientIP, originalDst, sport, srcMac, dnsActionSinkhole)
metrics.GetMetricsCollector().RecordBlock(domain, srcMac)
vc.drop()
@ -359,7 +359,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp
useDoH := set.DNS.DoHURL != ""
if !(set.DNS.Enabled && (set.DNS.TargetDNS != "" || useDoH)) {
log.Tracef("DNS redirect: %s matched set %s but no redirect target configured, passing through", domain, set.Name)
log.Tracef("DNS redirect: %s matched set %s but no redirect target configured, passing through", dns.SafeName(domain), set.Name)
logDNSEvent("UDP", set, domain, clientIP, originalDst, sport, srcMac, dnsActionPassthrough)
return vc.accept()
}
@ -387,7 +387,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp
if useDoH {
target = set.DNS.DoHURL
}
log.Tracef("DNS redirect: intercepting %s -> %s (set %s)", domain, target, set.Name)
log.Tracef("DNS redirect: intercepting %s -> %s (set %s)", dns.SafeName(domain), target, set.Name)
logDNSEvent("UDP", set, domain, clientIP, originalDst, sport, srcMac, dnsRedirectAction(set))
select {
@ -409,7 +409,7 @@ func (w *Worker) processDnsPacket(vc *verdictCtx, pkt *pktInfo, sport uint16, dp
}(set, cfg)
return 0
} else {
log.Tracef("DNS query: %s matched no set (src %s), forwarding unchanged", domain, srcMac)
log.Tracef("DNS query: %s matched no set (src %s), forwarding unchanged", dns.SafeName(domain), srcMac)
}
}
}

View file

@ -242,13 +242,13 @@ func (s *dnsTCPServer) handle(client net.Conn) {
matched, set := s.worker.getMatcher().MatchSNIWithSource(domain, srcMac)
if !matched {
log.Tracef("DNS TCP: %s matched no set (src %s), forwarding unchanged", domain, srcMac)
log.Tracef("DNS TCP: %s matched no set (src %s), forwarding unchanged", dns.SafeName(domain), srcMac)
s.passthrough(client, origIP, origPort, origErr, query)
return
}
if escSet := s.worker.escalatedSetFor(cfg, domain, srcMac); escSet != nil && escSet != set {
log.Tracef("DNS TCP escalation hit for %s: %s -> %s", domain, set.Name, escSet.Name)
log.Tracef("DNS TCP escalation hit for %s: %s -> %s", dns.SafeName(domain), set.Name, escSet.Name)
set = escSet
}

View file

@ -8,13 +8,14 @@ import (
"github.com/daniellavrushin/b4/config"
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/sni"
"github.com/daniellavrushin/b4/sock"
"github.com/daniellavrushin/b4/utils"
)
const (
MaxTCPPacketSize = 1460 // Standard MTU minus headers
MaxSNILength = 255 // Max SNI length per spec
MaxSNILength = sni.MaxSNINameLen
)
// GREASE values (RFC 8701)

View file

@ -1,6 +1,7 @@
package sni
import (
"github.com/daniellavrushin/b4/log"
"github.com/daniellavrushin/b4/quic"
"golang.org/x/crypto/cryptobyte"
)
@ -20,11 +21,16 @@ func ParseQUICClientHelloSNI(payload []byte) (string, bool) {
return "", false
}
host, err := extractSNIFromQUIC(crypto)
if err != nil || host == nil || len(host) == 0 {
if err != nil || len(host) == 0 {
return "", false
}
name := string(host)
if !validateSNI(name) {
log.Tracef("QUIC: invalid SNI extracted: %q", name)
return "", false
}
quic.ClearDCID(dcid)
return string(host), true
return name, true
}
func assembleSafe(dcid, plain []byte) ([]byte, bool) {

177
src/sni/quic_poison_test.go Normal file
View file

@ -0,0 +1,177 @@
package sni
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"io"
"strings"
"testing"
"golang.org/x/crypto/hkdf"
)
var quicSaltV1 = []byte{0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a}
func qu16(v int) []byte {
b := make([]byte, 2)
binary.BigEndian.PutUint16(b, uint16(v))
return b
}
func qvarint(v int) []byte {
switch {
case v < 64:
return []byte{byte(v)}
case v < 16384:
return []byte{0x40 | byte(v>>8), byte(v)}
default:
return []byte{0x80, byte(v >> 16), byte(v >> 8), byte(v)}
}
}
func qExpandLabel(t *testing.T, secret []byte, label string, n int) []byte {
t.Helper()
full := "tls13 " + label
info := make([]byte, 2+1+len(full)+1)
info[0] = byte(n >> 8)
info[1] = byte(n)
info[2] = byte(len(full))
copy(info[3:], full)
out := make([]byte, n)
if _, err := io.ReadFull(hkdf.Expand(sha256.New, secret, info), out); err != nil {
t.Fatalf("hkdf expand %q: %v", label, err)
}
return out
}
func qBuildClientHello(serverName []byte) []byte {
entry := append([]byte{0x00}, qu16(len(serverName))...)
entry = append(entry, serverName...)
list := append(qu16(len(entry)), entry...)
exts := append(qu16(0), qu16(len(list))...)
exts = append(exts, list...)
ch := []byte{0x03, 0x03}
ch = append(ch, make([]byte, 32)...)
ch = append(ch, 0x00)
ch = append(ch, qu16(2)...)
ch = append(ch, 0x13, 0x01)
ch = append(ch, 0x01, 0x00)
ch = append(ch, qu16(len(exts))...)
ch = append(ch, exts...)
return append([]byte{0x01, byte(len(ch) >> 16), byte(len(ch) >> 8), byte(len(ch))}, ch...)
}
func qBuildInitial(t *testing.T, dcid, crypto []byte) []byte {
t.Helper()
payload := append([]byte{0x06, 0x00}, qvarint(len(crypto))...)
payload = append(payload, crypto...)
if len(payload) < 1000 {
payload = append(payload, make([]byte, 1000-len(payload))...)
}
m := hmac.New(sha256.New, quicSaltV1)
_, _ = m.Write(dcid)
client := qExpandLabel(t, m.Sum(nil), "client in", 32)
blk, err := aes.NewCipher(qExpandLabel(t, client, "quic key", 16))
if err != nil {
t.Fatalf("aes key: %v", err)
}
aead, err := cipher.NewGCM(blk)
if err != nil {
t.Fatalf("gcm: %v", err)
}
hpBlk, err := aes.NewCipher(qExpandLabel(t, client, "quic hp", 16))
if err != nil {
t.Fatalf("aes hp: %v", err)
}
iv := qExpandLabel(t, client, "quic iv", 12)
hdr := []byte{0xC0, 0x00, 0x00, 0x00, 0x01, byte(len(dcid))}
hdr = append(hdr, dcid...)
hdr = append(hdr, 0x00, 0x00)
hdr = append(hdr, qvarint(1+len(payload)+16)...)
pnOff := len(hdr)
pkt := append(hdr, 0x00)
pkt = append(pkt, aead.Seal(nil, iv, payload, pkt)...)
var mask [16]byte
hpBlk.Encrypt(mask[:], pkt[pnOff+4:pnOff+20])
pkt[0] ^= mask[0] & 0x0f
pkt[pnOff] ^= mask[1]
return pkt
}
func TestParseQUICClientHelloSNIAcceptsRealName(t *testing.T) {
pkt := qBuildInitial(t, []byte{0xA0, 1, 2, 3, 4, 5, 6, 7}, qBuildClientHello([]byte("graph.whatsapp.com")))
got, ok := ParseQUICClientHelloSNI(pkt)
if !ok || got != "graph.whatsapp.com" {
t.Fatalf("ParseQUICClientHelloSNI = %q, %v; want graph.whatsapp.com, true", got, ok)
}
}
func TestParseQUICClientHelloSNIAcceptsMaxLengthName(t *testing.T) {
longest := strings.Repeat("a", MaxSNINameLen-4) + ".com"
pkt := qBuildInitial(t, []byte{0xC0, 1, 2, 3, 4, 5, 6, 7}, qBuildClientHello([]byte(longest)))
got, ok := ParseQUICClientHelloSNI(pkt)
if !ok || got != longest {
t.Fatalf("a %d byte hostname should be accepted: ok=%v len=%d", len(longest), ok, len(got))
}
}
func TestParseQUICClientHelloSNIRejectsHostileName(t *testing.T) {
tests := []struct {
name string
dcid []byte
sni []byte
}{
{
name: "newline forges a log line",
dcid: []byte{0xB0, 1, 2, 3, 4, 5, 6, 7},
sni: []byte("evil.com\n2026/08/17 00:00:00.000000 [INFO] forged.example.com"),
},
{
name: "nul byte",
dcid: []byte{0xB1, 1, 2, 3, 4, 5, 6, 7},
sni: []byte("evil.com\x00tail"),
},
{
name: "carriage return",
dcid: []byte{0xB2, 1, 2, 3, 4, 5, 6, 7},
sni: []byte("evil.com\rtail"),
},
{
name: "no dot",
dcid: []byte{0xB3, 1, 2, 3, 4, 5, 6, 7},
sni: []byte("notahostname"),
},
{
name: "over max length",
dcid: []byte{0xB4, 1, 2, 3, 4, 5, 6, 7},
sni: []byte(strings.Repeat("a", MaxSNINameLen-3) + ".com"),
},
{
name: "space",
dcid: []byte{0xB5, 1, 2, 3, 4, 5, 6, 7},
sni: []byte("evil.com and more"),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pkt := qBuildInitial(t, tc.dcid, qBuildClientHello(tc.sni))
if got, ok := ParseQUICClientHelloSNI(pkt); ok {
t.Errorf("accepted hostile SNI %q (%d bytes)", got, len(got))
}
})
}
}

83
src/sni/realworld_test.go Normal file
View file

@ -0,0 +1,83 @@
package sni
import (
"testing"
)
var realWorldSNIs = []string{
"0.pool.ntp.org",
"1-2-3.test-host.example.org",
"1.pool.ntp.org",
"192.168.1.1",
"UPPER.Example.COM",
"_dmarc.example.com",
"a.co",
"a961.b.akamai.net",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com",
"ae.iads.unity3d.com",
"afs.ampaeservices.com",
"analytics-ios.rayjump.com",
"api.miwifi.com",
"api16-core-ycru.tiktokv.com",
"assets.msn.com",
"beacons2.gvt2.com",
"cdn-v6.amp-endpoint3.com",
"cdn.activision.com",
"cdn.iads.unity3d.com",
"clck.yandex.net",
"d.applovin.com",
"graph.whatsapp.com",
"ipapi.co",
"kws2-1.web.telegram.org",
"kws2.web.telegram.org",
"kws203.onedaychamp.co.uk",
"localhost.localdomain",
"max.ru",
"mssdk-ru.tiktokv.com",
"p1.trex.media",
"pool.ntp.org",
"prefetch.monetization-sdk.chartboost.com",
"profile.gc.apple.com",
"pubads.g.doubleclick.net",
"quasar.yandex.net",
"sdkeventfnt-eu.dsp-api.moloco.com",
"ssl.gstatic.com",
"stun.cloudflare.com",
"sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.sub.example.com",
"test-gateway.instagram.com",
"unagi-na.amazon.com",
"wps.apple.com",
"www.baidu.com",
"www.googleapis.com",
"x.io",
"xn--80ak6aa92e.com",
"xp.apple.com",
}
func tlsRecordWithSNI(host string) []byte {
ch := qBuildClientHello([]byte(host))
return append([]byte{0x16, 0x03, 0x01, byte(len(ch) >> 8), byte(len(ch))}, ch...)
}
func TestSNIExtractionRealWorldNames(t *testing.T) {
for i, host := range realWorldSNIs {
dcid := []byte{0xD0, byte(i >> 8), byte(i), 0x11, 0x22, 0x33, 0x44, 0x55}
if got, ok := ParseQUICClientHelloSNI(qBuildInitial(t, dcid, qBuildClientHello([]byte(host)))); !ok || got != host {
t.Errorf("QUIC SNI %q (%d bytes): got (%q,%v)", host, len(host), got, ok)
}
if got, _, ok := ParseTLSClientHelloSNI(tlsRecordWithSNI(host)); !ok || got != host {
t.Errorf("TLS SNI %q (%d bytes): got (%q,%v)", host, len(host), got, ok)
}
ch := qBuildClientHello([]byte(host))
if got, ok := ParseTLSClientHelloBodySNI(ch[4:]); !ok || got != host {
t.Errorf("TLS body SNI %q: got (%q,%v)", host, got, ok)
}
if !IsValidSNI([]byte(host)) {
t.Errorf("IsValidSNI rejected real hostname %q (%d bytes)", host, len(host))
}
}
}

View file

@ -12,6 +12,8 @@ const (
tlsExtServerName uint16 = 0
)
const MaxSNINameLen = 255
type parseErr string
func (e parseErr) Error() string { return string(e) }
@ -32,7 +34,7 @@ func isValidSNIChar(b byte) bool {
}
func validateSNI(sni string) bool {
if len(sni) == 0 {
if len(sni) == 0 || len(sni) > MaxSNINameLen {
return false
}
for i := 0; i < len(sni); i++ {