fix(tlsfragment): the SNI was cut in exactly one label — always the first one

`splits[:len(splits)-strings.Count(serverName.ServerName, ".")]` is identically
`splits[:1]`: labels are always one more than dots, so the subtraction cancels
for EVERY name in existence. One label was ever cut, and it was the leftmost
one. On the provider measured from this router — which blocks by the name in
the handshake, proved by the same address answering for SNI www.google.com and
going silent for www.youtube.com — that is the whole observed table:

    youtube.com     cut inside "youtube"  -> 301
    m.youtube.com   cut inside "m"        -> blocked
    tv.youtube.com  cut inside "tv"       -> blocked
    www.youtube.com cut inside "www"      -> blocked
    music/studio.*  cut inside the label in front -> blocked

The one name that worked is the one whose first label IS the blocked word. The
count subtracted must be the labels of the PUBLIC SUFFIX, not the dots of the
whole name: "com" is one, "co.uk" and "com.br" and "pp.ru" are two.

Second half of the same defect, and the reason the table above shows a cut
"inside m" at all: the offset was `rand.Intn(len(label))`, whose 0 is the
label's own boundary — the label goes out whole in the next segment, which is
not a cut, it is a segment boundary that happens to touch a label. For a
one-byte label 0 is the ONLY value it can take. Offsets are now drawn from
[1, len-1], so a cut always leaves a non-empty piece of the label on both
sides, and a label too short to have an interior offset carries no cut instead
of a fake one. That also closes the 1-in-7 hole in the case that WAS working:
youtube.com drew offset 0 once every seven connections and handed the name over
intact.

Two panics went with it, both reachable from the LAN, because route/conn.go
wraps the outbound with this and the ClientHello it fragments is the client's:
an empty label (SNI ".youtube.com" or the perfectly ordinary FQDN
"youtube.com.", where the suffix list declines to answer and the trailing empty
label survives) reached rand.Intn(0) — "panic: invalid argument to Intn", the
daemon and with it the router's proxying. And a plan with no cuts at all would
have indexed b[:splitIndexes[0]] on an empty slice; Write now writes the
ClientHello unchanged in that case, which is the only honest thing to do for a
name of one byte.

The classification is closed and errs toward MORE cutting: narrowing the label
set needs proof (a public suffix that really is a tail of the name), widening
needs none, so a trailing dot, an unmanaged TLD, a name that IS a public suffix
("com", "co.uk", "localhost") and an IP literal all keep every label rather
than fall silently into "cut nothing". When no label is long enough to cut, the
name itself is cut once — a matcher looking for the whole FQDN still fails
across that split.

Dropped with it: `splits[0] == "..."`, unreachable since strings.Split on "."
cannot produce a token containing a dot. And the plan now runs over the FIRST
entry of the server_name_list (MyServerName.Length) instead of everything left
in the extension, so a second entry cannot be fed to the public suffix list as
if it were part of the name.

Tests (cutplan_test.go, package-internal so the plan itself is visible) are
verified by mutation five ways: the old dot arithmetic, the old rand.Intn
offset, the removed empty-label guard, the removed empty-plan guard, and a
one-byte corruption of a segment. Each fails by name and with the numbers. The
controls: youtube.com — the case that already worked — must still be severed;
the reassembled segments must be byte-identical to the ClientHello in all three
modes (tls_fragment, tls_record_fragment, both), with the record framing
re-parsed rather than assumed; and Write must report len(b).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BHw89tdWddzhjUc4bAH4tS
This commit is contained in:
2026-07-27 16:33:04 +03:00
co-authored by Claude Opus 5
parent 0a34e64c2c
commit 815011dfb0
2 changed files with 598 additions and 18 deletions
+130 -18
View File
@@ -6,6 +6,7 @@ import (
"encoding/binary"
"math/rand"
"net"
"net/netip"
"strings"
"time"
@@ -47,6 +48,27 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}()
serverName := IndexTLSServerName(b)
if serverName != nil {
// The SNI extension carries a LIST of names; MyServerName.Length is
// the length of the FIRST entry while MyServerName.ServerName is
// everything left in the extension. Plan the cuts over the first
// entry only: a second entry would otherwise be handed to the
// public suffix list as if it were part of the name.
name := serverName.ServerName
if serverName.Length >= 0 && serverName.Length < len(name) {
name = name[:serverName.Length]
}
splitIndexes := cutOffsets(name, rand.Intn)
if len(splitIndexes) == 0 {
// Nothing inside this name can be cut — it is empty or a single
// byte, so there is no offset that leaves a non-empty piece on
// both sides. Write the ClientHello as it stands: the loop
// below reads b[:splitIndexes[0]] unconditionally and would
// panic on an empty plan.
return c.Conn.Write(b)
}
for i := range splitIndexes {
splitIndexes[i] += serverName.Index
}
if c.splitPacket {
if c.tcpConn != nil {
err = c.tcpConn.SetNoDelay(true)
@@ -55,24 +77,6 @@ func (c *Conn) Write(b []byte) (n int, err error) {
}
}
}
splits := strings.Split(serverName.ServerName, ".")
currentIndex := serverName.Index
if publicSuffix := publicsuffix.List.PublicSuffix(serverName.ServerName); publicSuffix != "" {
splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
}
if len(splits) > 1 && splits[0] == "..." {
currentIndex += len(splits[0]) + 1
splits = splits[1:]
}
var splitIndexes []int
for i, split := range splits {
splitAt := rand.Intn(len(split))
splitIndexes = append(splitIndexes, currentIndex+splitAt)
currentIndex += len(split)
if i != len(splits)-1 {
currentIndex++
}
}
var buffer bytes.Buffer
for i := 0; i <= len(splitIndexes); i++ {
var payload []byte
@@ -133,6 +137,114 @@ func (c *Conn) Write(b []byte) (n int, err error) {
return c.Conn.Write(b)
}
// labelSpan is the half-open byte range [start, end) of one DNS label inside a
// server name, relative to the first byte of that name.
type labelSpan struct {
start int
end int
}
// cutOffsets plans where the ClientHello must be cut, in byte offsets relative
// to the FIRST BYTE OF THE SERVER NAME. randIntn is math/rand's Intn in
// production; a test hands in its own to make the plan deterministic.
//
// A cut is only a cut if it lands STRICTLY INSIDE a label. Offset 0 of a label
// is that label's own boundary: it leaves the label — the very string the DPI
// box matches on — whole in the following segment. That is not theory. The old
// code drew rand.Intn(len(label)), so a one-byte label could only ever produce
// offset 0, and on the measured provider (blocks by name in the handshake)
// m.youtube.com, tv.youtube.com and www.youtube.com were all blocked with the
// cut sitting uselessly at the start of "m"/"tv"/"www", while the name itself
// travelled intact in one segment. Hence [1, len(label)-1], and hence a label
// shorter than two bytes carries no cut at all.
func cutOffsets(name string, randIntn func(n int) int) []int {
var offsets []int
for _, span := range cutLabels(name) {
width := span.end - span.start
if width < 2 {
continue // no interior offset exists
}
offsets = append(offsets, span.start+1+randIntn(width-1))
}
if len(offsets) == 0 && len(name) >= 2 {
// Every candidate label was too short to cut on its own (a.b.co.uk).
// Cut the name somewhere rather than hand it over in one piece: a
// matcher looking for the whole FQDN still fails across the split, even
// though no single label was severed.
offsets = append(offsets, 1+randIntn(len(name)-1))
}
return offsets
}
// cutLabels returns the labels of name that a cut may land in.
//
// The public suffix is dropped because it is shared by everything under it and
// carries none of the blocked word. Everything else stays: WIDENING the set of
// labels needs no proof, NARROWING it does, so an input the public suffix list
// has no opinion about (a trailing dot, an unmanaged TLD, a name that IS a
// suffix) keeps every label and is cut everywhere. There is no branch here that
// silently ends up cutting nothing — the only empty result is the empty name,
// which has nothing to cut by construction.
func cutLabels(name string) []labelSpan {
spans := labelSpans(name)
suffix := publicsuffix.List.PublicSuffix(name)
switch {
case len(spans) == 0:
// name == "". Nothing to cut; Write sends the ClientHello unchanged.
case isIPLiteral(name):
// An IP literal is not a name (RFC 6066 forbids it in SNI) and its dots
// do not separate labels, so the public suffix list has nothing to say
// about it — it returns the literal itself. Treat the whole literal as
// one token: there is no name for a DPI box to read here, but the
// caller asked for a fragmented handshake and gets one.
spans = []labelSpan{{start: 0, end: len(name)}}
case suffix != "" && len(suffix) < len(name) && strings.HasSuffix(name, "."+suffix):
// The ordinary case, and the one the old arithmetic got wrong: it
// subtracted the number of dots in the WHOLE NAME, which — labels being
// always one more than dots — left exactly one label, the first, for
// every name in existence. Subtract the number of labels in the SUFFIX
// instead: "com" is one ("www.youtube.com" keeps www + youtube),
// "co.uk" is two ("a.b.co.uk" keeps a + b).
if keep := len(spans) - strings.Count(suffix, ".") - 1; keep > 0 {
spans = spans[:keep]
}
// Everything else — suffix == "" (a trailing dot, which the list
// declines to parse), suffix == name (the name IS a public suffix:
// "com", "co.uk", "localhost"), or a suffix that is somehow not a tail
// of the name — keeps every label. Cutting inside a suffix costs one
// extra segment and hides nothing that was not already hidden; NOT
// cutting is the expensive mistake.
}
return spans
}
// labelSpans splits name on '.' and returns the byte range of each label.
// Empty labels (a leading, trailing or doubled dot) come back as zero-width
// spans and are dropped by cutOffsets, which is what keeps a name like
// ".youtube.com" away from rand.Intn(0) — that combination panicked.
func labelSpans(name string) []labelSpan {
if name == "" {
return nil
}
var spans []labelSpan
start := 0
for i := 0; i <= len(name); i++ {
if i == len(name) || name[i] == '.' {
spans = append(spans, labelSpan{start: start, end: i})
start = i + 1
}
}
return spans
}
func isIPLiteral(name string) bool {
_, err := netip.ParseAddr(name)
return err == nil
}
func (c *Conn) ReaderReplaceable() bool {
return true
}
+468
View File
@@ -0,0 +1,468 @@
package tf
// Cut planning: which labels of the SNI get a cut, and where inside them.
//
// WHY THIS FILE EXISTS (2026-07-27)
// Conn.Write used to compute the labels to cut as
//
// splits = splits[:len(splits)-strings.Count(serverName.ServerName, ".")]
//
// which is identically splits[:1] for EVERY name, labels being always one
// more than dots. Exactly one label was ever cut — the first — so on a
// provider that blocks by the name in the handshake, youtube.com passed (its
// first label IS the blocked word) while m./tv./www./music./studio.youtube.com
// were all blocked, the cut sitting uselessly inside "m"/"tv"/"www" while
// "youtube" travelled whole in the next segment. Measured on the router.
//
// The second half of the same defect: the offset came from
// rand.Intn(len(label)), whose 0 is the label's own boundary and severs
// nothing. For a one-byte label that is the ONLY value it can take.
//
// The tests below are the instrument for both halves. Every one of them fails
// on the old expressions — see the mutation log in the task report.
import (
"crypto/tls"
"encoding/binary"
"io"
"math/rand"
"net"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// --- deterministic draws -----------------------------------------------------
// minRand always cuts at the first interior offset of a label, maxRand at the
// last. Between them they pin BOTH ends of the range a cut may take, which is
// the whole point: it is the ends that decide whether the label is severed or
// merely touched.
func minRand(int) int { return 0 }
func maxRand(n int) int { return n - 1 }
func fixedRand(v int) func(int) int {
return func(n int) int {
if v >= n {
return n - 1
}
return v
}
}
// --- which labels are cut, and where ----------------------------------------
func TestCutOffsetsSelectsEveryLabelOutsideThePublicSuffix(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
wantMin []int // offsets with the lowest draw in every label
wantMax []int // offsets with the highest draw in every label
why string
}{
{name: "youtube.com", wantMin: []int{1}, wantMax: []int{6}, why: "one label outside com"},
{name: "www.youtube.com", wantMin: []int{1, 5}, wantMax: []int{2, 10}, why: "THE regression: youtube must be cut, not only www"},
{name: "m.youtube.com", wantMin: []int{3}, wantMax: []int{8}, why: "m is one byte: no interior offset, so youtube carries the cut"},
{name: "tv.youtube.com", wantMin: []int{1, 4}, wantMax: []int{1, 9}, why: "tv has exactly one interior offset"},
{name: "music.youtube.com", wantMin: []int{1, 7}, wantMax: []int{4, 12}, why: ""},
{name: "studio.youtube.com", wantMin: []int{1, 8}, wantMax: []int{5, 13}, why: ""},
{name: "foo.bar.baz.youtube.com", wantMin: []int{1, 5, 9, 13}, wantMax: []int{2, 6, 10, 18}, why: "every label, not just the first"},
{name: "example.co.uk", wantMin: []int{1}, wantMax: []int{6}, why: "co.uk is TWO labels of suffix"},
{name: "a.b.example.co.uk", wantMin: []int{5}, wantMax: []int{10}, why: "a and b are one byte each; example carries the cut"},
{name: "example.com.br", wantMin: []int{1}, wantMax: []int{6}, why: "com.br is two labels of suffix"},
{name: "site.pp.ru", wantMin: []int{1}, wantMax: []int{3}, why: "pp.ru is a private two-label suffix"},
{name: "co.uk", wantMin: []int{1, 4}, wantMax: []int{1, 4}, why: "the name IS the suffix: keep every label rather than cut nothing"},
{name: "com", wantMin: []int{1}, wantMax: []int{2}, why: "same"},
{name: "localhost", wantMin: []int{1}, wantMax: []int{8}, why: "unmanaged TLD: the list returns the whole name"},
{name: "ab", wantMin: []int{1}, wantMax: []int{1}, why: "two bytes have exactly one interior offset"},
{name: "a", wantMin: nil, wantMax: nil, why: "one byte cannot be cut at all"},
{name: "", wantMin: nil, wantMax: nil, why: "no name, no cut"},
{name: ".youtube.com", wantMin: []int{2}, wantMax: []int{7}, why: "leading dot: the empty label is skipped, NOT fed to rand.Intn(0)"},
{name: "youtube.com.", wantMin: []int{1, 9}, wantMax: []int{6, 10}, why: "trailing dot: the list declines to parse it, so every label is cut"},
{name: "a.b.co.uk", wantMin: []int{1}, wantMax: []int{8}, why: "no label is cuttable: fall back to cutting the name itself"},
{name: "1.2.3.4", wantMin: []int{1}, wantMax: []int{6}, why: "IP literal: one opaque token"},
{name: "::1", wantMin: []int{1}, wantMax: []int{2}, why: "IPv6 literal"},
{name: "WWW.YouTube.COM", wantMin: []int{1, 5}, wantMax: []int{2, 10}, why: "the list preserves case"},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.wantMin, cutOffsets(tc.name, minRand), "lowest draw: %s", tc.why)
require.Equal(t, tc.wantMax, cutOffsets(tc.name, maxRand), "highest draw: %s", tc.why)
})
}
}
// TestCutOffsetsSeverTheBlockedLabel is the field measurement turned into an
// instrument. On the measured provider these six names differ only in the label
// in front of "youtube", and five of the six were blocked. What has to hold —
// for every draw, not for most of them — is that the byte range of "youtube"
// ends up straddling a cut.
func TestCutOffsetsSeverTheBlockedLabel(t *testing.T) {
t.Parallel()
for _, name := range []string{
"youtube.com",
"m.youtube.com",
"tv.youtube.com",
"www.youtube.com",
"music.youtube.com",
"studio.youtube.com",
"i9.ytimg.com",
} {
t.Run(name, func(t *testing.T) {
target := "youtube"
if strings.Contains(name, "ytimg") {
target = "ytimg"
}
start := strings.Index(name, target)
require.GreaterOrEqual(t, start, 0)
end := start + len(target)
// Every draw the label can take, not a sample of them: n is small
// enough to enumerate, so there is no "it passed 1000 times" to
// argue with.
for draw := 0; draw < len(name); draw++ {
offsets := cutOffsets(name, fixedRand(draw))
severed := false
for _, o := range offsets {
if o > start && o < end {
severed = true
}
}
require.True(t, severed,
"draw %d: %q got cuts at %v, none of them inside %q [%d,%d)",
draw, name, offsets, target, start, end)
}
})
}
}
// TestCutOffsetsNeverLandOnALabelBoundary states the property the old code
// broke: an offset at a label's own edge is not a cut. It is checked over every
// draw of every label of every name.
func TestCutOffsetsNeverLandOnALabelBoundary(t *testing.T) {
t.Parallel()
for _, name := range []string{
"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com",
"example.co.uk", "a.b.example.co.uk", "foo.bar.baz.youtube.com",
"localhost", "com", "co.uk", ".youtube.com", "youtube.com.",
} {
t.Run(name, func(t *testing.T) {
for draw := 0; draw < 64; draw++ {
offsets := cutOffsets(name, fixedRand(draw))
require.NotEmpty(t, offsets, "draw %d: %q was handed over in one piece", draw, name)
prev := -1
for _, o := range offsets {
require.Greater(t, o, 0, "draw %d: cut at the very start of %q hides nothing", draw, name)
require.Less(t, o, len(name), "draw %d: cut past the end of %q", draw, name)
require.NotEqual(t, byte('.'), name[o], "draw %d: cut at %d sits on a label boundary of %q", draw, o, name)
require.NotEqual(t, byte('.'), name[o-1], "draw %d: cut at %d sits on a label boundary of %q", draw, o, name)
require.Greater(t, o, prev, "draw %d: offsets of %q are not strictly increasing: %v", draw, name, offsets)
prev = o
}
}
})
}
}
// TestCutOffsetsFallbackStaysInBounds covers the names where NO label is
// cuttable and the plan falls back to cutting the name itself. Boundary
// interiority cannot hold here — there is no label to be interior to — so what
// is checked is that the cut exists and can still be applied to a buffer.
func TestCutOffsetsFallbackStaysInBounds(t *testing.T) {
t.Parallel()
for _, name := range []string{"a.b.co.uk", "x.pp.ru", "a.b.c.d", "1.2.3.4"} {
t.Run(name, func(t *testing.T) {
for draw := 0; draw < len(name)+4; draw++ {
offsets := cutOffsets(name, fixedRand(draw))
require.Len(t, offsets, 1, "%q should fall back to exactly one cut", name)
require.Greater(t, offsets[0], 0)
require.Less(t, offsets[0], len(name))
}
})
}
}
// TestCutOffsetsSurviveRealRandomness runs the production draw source over the
// degenerate names that used to reach rand.Intn(0) — an empty label panics it,
// and ".youtube.com" / "youtube.com." are the two ways an ordinary client
// produces one.
func TestCutOffsetsSurviveRealRandomness(t *testing.T) {
t.Parallel()
for _, name := range []string{
"", "a", ".", "..", "...", ".com", "com.", ".youtube.com", "youtube.com.",
".youtube.com.", "a..b.example.com", "-.-.-.-", "xn--p1ai", "test.xn--p1ai",
} {
t.Run(name, func(t *testing.T) {
for i := 0; i < 200; i++ {
offsets := cutOffsets(name, rand.Intn) // must not panic
prev := -1
for _, o := range offsets {
require.Greater(t, o, prev)
require.Greater(t, o, 0)
require.Less(t, o, len(name))
prev = o
}
}
})
}
}
// --- end to end: what actually goes out on the wire -------------------------
// fakeConn records every Write. It is deliberately NOT a *net.TCPConn, which is
// also the common production case (the outbound is usually a proxy stream), so
// Conn.Write takes the sleep-instead-of-ACK path — hence the 1ns fallback delay
// the tests below pass to NewConn.
type fakeConn struct {
writes [][]byte
}
func (c *fakeConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *fakeConn) Close() error { return nil }
func (c *fakeConn) LocalAddr() net.Addr { return &net.TCPAddr{} }
func (c *fakeConn) RemoteAddr() net.Addr { return &net.TCPAddr{} }
func (c *fakeConn) SetDeadline(time.Time) error { return nil }
func (c *fakeConn) SetReadDeadline(time.Time) error { return nil }
func (c *fakeConn) SetWriteDeadline(time.Time) error { return nil }
func (c *fakeConn) Write(b []byte) (int, error) {
c.writes = append(c.writes, append([]byte(nil), b...))
return len(b), nil
}
// clientHelloFor produces a real ClientHello for serverName by letting
// crypto/tls build one and capturing the first write.
func clientHelloFor(t *testing.T, serverName string) []byte {
t.Helper()
rec := &fakeConn{}
_ = tls.Client(rec, &tls.Config{ServerName: serverName, MinVersion: tls.VersionTLS12}).Handshake()
require.NotEmpty(t, rec.writes, "crypto/tls wrote no ClientHello for %q", serverName)
hello := rec.writes[0]
// Control on the instrument: the parser this package ships must find the
// name we asked for, otherwise the assertions below prove nothing.
sni := IndexTLSServerName(hello)
require.NotNil(t, sni, "IndexTLSServerName found no SNI in the generated ClientHello")
require.Equal(t, serverName, sni.ServerName)
return hello
}
// patchSNI rewrites the server name inside a ClientHello in place. from and to
// must be the same length, so every length field in the record stays valid —
// this is how names crypto/tls refuses to emit (a leading or trailing dot, an
// IP literal) get tested against the real parser.
func patchSNI(t *testing.T, hello []byte, from, to string) []byte {
t.Helper()
require.Equal(t, len(from), len(to), "patchSNI cannot change the length")
at := IndexTLSServerName(hello)
require.NotNil(t, at)
require.Equal(t, from, at.ServerName)
out := append([]byte(nil), hello...)
copy(out[at.Index:], to)
sni := IndexTLSServerName(out)
require.NotNil(t, sni)
require.Equal(t, to, sni.ServerName)
return out
}
// segments returns, for one recorded run, the payload of every segment written
// and the absolute offsets in hello at which the cuts fell.
func segments(t *testing.T, hello []byte, writes [][]byte, recordFragment bool) ([][]byte, []int) {
t.Helper()
var payloads [][]byte
for _, w := range writes {
if !recordFragment {
payloads = append(payloads, w)
continue
}
// A record-fragmented write is one or more TLS records: 3 bytes of the
// original header, a 2-byte length, then the payload.
for len(w) > 0 {
require.GreaterOrEqual(t, len(w), recordLayerHeaderLen, "truncated record header")
require.Equal(t, hello[:3], w[:3], "record header is not the ClientHello's own")
n := int(binary.BigEndian.Uint16(w[3:5]))
require.LessOrEqual(t, recordLayerHeaderLen+n, len(w), "record length runs past the write")
payloads = append(payloads, w[recordLayerHeaderLen:recordLayerHeaderLen+n])
w = w[recordLayerHeaderLen+n:]
}
}
require.NotEmpty(t, payloads, "Write returned without putting anything on the wire")
// Cut offsets are the cumulative payload lengths, shifted past the record
// header that the first fragment drops.
offset := 0
if recordFragment {
offset = recordLayerHeaderLen
}
var cuts []int
for _, p := range payloads[:len(payloads)-1] {
offset += len(p)
cuts = append(cuts, offset)
}
return payloads, cuts
}
type writeMode struct {
name string
splitPacket bool
splitRecord bool
recordFraming bool
segmentPerCall bool // one Write call per segment
}
var writeModes = []writeMode{
{name: "tls_fragment", splitPacket: true, splitRecord: false, recordFraming: false, segmentPerCall: true},
{name: "tls_record_fragment", splitPacket: false, splitRecord: true, recordFraming: true, segmentPerCall: false},
{name: "both", splitPacket: true, splitRecord: true, recordFraming: true, segmentPerCall: true},
}
// TestWriteSeversTheBlockedLabelOnTheWire is the end-to-end control: not "the
// planner returned nice numbers" but "the bytes that left the socket have the
// blocked label straddling a segment boundary", for every mode the presets
// expose, over many real random draws.
func TestWriteSeversTheBlockedLabelOnTheWire(t *testing.T) {
t.Parallel()
for _, mode := range writeModes {
for _, serverName := range []string{
"youtube.com", // the ONE name the old code got right
"www.youtube.com", // the regression
"m.youtube.com", // one-byte label in front
"music.youtube.com", //
"a.b.example.co.uk", // two-label public suffix
} {
t.Run(mode.name+"/"+serverName, func(t *testing.T) {
t.Parallel()
hello := clientHelloFor(t, serverName)
sniAt := IndexTLSServerName(hello).Index
target := "youtube"
if strings.Contains(serverName, "example") {
target = "example"
}
start := sniAt + strings.Index(serverName, target)
end := start + len(target)
for i := 0; i < 100; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n, "Write must report the length of the buffer it was given")
_, cuts := segments(t, hello, out.writes, mode.recordFraming)
require.NotEmpty(t, cuts, "the ClientHello went out in one piece")
severed := false
for _, c := range cuts {
if c > start && c < end {
severed = true
}
}
require.True(t, severed,
"run %d: %q left with cuts at %v, none inside %q [%d,%d)",
i, serverName, cuts, target, start, end)
}
})
}
}
}
// TestWriteReassemblesToTheOriginalClientHello: cutting may change how the
// bytes are packaged and nothing else. Byte-for-byte, plus the length Write
// reports, plus the segment count implied by the plan.
func TestWriteReassemblesToTheOriginalClientHello(t *testing.T) {
t.Parallel()
for _, mode := range writeModes {
for _, serverName := range []string{
"www.youtube.com", "youtube.com", "a.b.example.co.uk", "localhost", "a",
} {
t.Run(mode.name+"/"+serverName, func(t *testing.T) {
t.Parallel()
hello := clientHelloFor(t, serverName)
for i := 0; i < 50; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n)
payloads, cuts := segments(t, hello, out.writes, mode.recordFraming)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p, "empty segment: a cut of zero length went out on the wire")
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
// The record header is re-emitted per fragment, so what
// must survive is the handshake body.
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "run %d: the reassembled ClientHello differs from the original", i)
if mode.segmentPerCall {
require.Len(t, out.writes, len(cuts)+1, "one Write call per segment")
} else {
require.Len(t, out.writes, 1, "record fragmentation without packet fragmentation is a single write")
}
}
})
}
}
}
// TestWriteHandlesDegenerateServerNames feeds Conn.Write the names crypto/tls
// refuses to emit but a forwarded ClientHello can carry. Before the fix the
// first two of these reached rand.Intn(0) and took the process down with
// "invalid argument to Intn"; the ClientHello here comes from a LAN client, so
// that was reachable from the LAN.
func TestWriteHandlesDegenerateServerNames(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
from, to string
}{
{from: "xyoutube.com", to: ".youtube.com"},
{from: "youtube.comx", to: "youtube.com."},
{from: "xyoutube.comx", to: ".youtube.com."},
{from: "ax.example.com", to: "a..example.com"},
{from: "1x2x3x4", to: "1.2.3.4"},
{from: "localhost", to: "localhost"},
{from: "ab", to: "ab"},
{from: "a", to: "a"},
} {
t.Run(tc.to, func(t *testing.T) {
t.Parallel()
hello := patchSNI(t, clientHelloFor(t, tc.from), tc.from, tc.to)
for _, mode := range writeModes {
for i := 0; i < 50; i++ {
out := &fakeConn{}
n, err := NewConn(out, t.Context(), mode.splitPacket, mode.splitRecord, time.Nanosecond).Write(hello)
require.NoError(t, err, "%s", mode.name)
require.Equal(t, len(hello), n, "%s", mode.name)
payloads, _ := segments(t, hello, out.writes, mode.recordFraming)
var joined []byte
for _, p := range payloads {
require.NotEmpty(t, p)
joined = append(joined, p...)
}
want := hello
if mode.recordFraming {
want = hello[recordLayerHeaderLen:]
}
require.Equal(t, want, joined, "%s run %d", mode.name, i)
}
}
})
}
}
// TestWriteWithoutSNIIsUntouched: the fast path must stay a straight pass, and
// the second and later writes must never be re-planned.
func TestWriteWithoutSNIIsUntouched(t *testing.T) {
t.Parallel()
payload := []byte("not a tls record at all")
out := &fakeConn{}
conn := NewConn(out, t.Context(), true, true, time.Nanosecond)
n, err := conn.Write(payload)
require.NoError(t, err)
require.Equal(t, len(payload), n)
require.Len(t, out.writes, 1)
require.Equal(t, payload, out.writes[0])
hello := clientHelloFor(t, "www.youtube.com")
n, err = conn.Write(hello)
require.NoError(t, err)
require.Equal(t, len(hello), n)
require.Len(t, out.writes, 2, "a ClientHello after the first write must not be fragmented")
require.Equal(t, hello, out.writes[1])
}