Skip to content

Commit b3ec8e0

Browse files
authored
fix(sec): randomString bias (#2492)
* fix(sec): `randomString` bias when using bytes vs int64 * use pooled buffed random reader
1 parent 626f13e commit b3ec8e0

File tree

2 files changed

+63
-11
lines changed

2 files changed

+63
-11
lines changed

middleware/util.go

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package middleware
22

33
import (
4+
"bufio"
45
"crypto/rand"
5-
"fmt"
6+
"io"
67
"strings"
8+
"sync"
79
)
810

911
func matchScheme(domain, pattern string) bool {
@@ -55,17 +57,38 @@ func matchSubdomain(domain, pattern string) bool {
5557
return false
5658
}
5759

60+
// https://tip.golang.org/doc/go1.19#:~:text=Read%20no%20longer%20buffers%20random%20data%20obtained%20from%20the%20operating%20system%20between%20calls
61+
var randomReaderPool = sync.Pool{New: func() interface{} {
62+
return bufio.NewReader(rand.Reader)
63+
}}
64+
65+
const randomStringCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
66+
const randomStringCharsetLen = 52 // len(randomStringCharset)
67+
const randomStringMaxByte = 255 - (256 % randomStringCharsetLen)
68+
5869
func randomString(length uint8) string {
59-
charset := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
70+
reader := randomReaderPool.Get().(*bufio.Reader)
71+
defer randomReaderPool.Put(reader)
6072

61-
bytes := make([]byte, length)
62-
_, err := rand.Read(bytes)
63-
if err != nil {
64-
// we are out of random. let the request fail
65-
panic(fmt.Errorf("echo randomString failed to read random bytes: %w", err))
66-
}
67-
for i, b := range bytes {
68-
bytes[i] = charset[b%byte(len(charset))]
73+
b := make([]byte, length)
74+
r := make([]byte, length+(length/4)) // perf: avoid read from rand.Reader many times
75+
var i uint8 = 0
76+
77+
for {
78+
_, err := io.ReadFull(reader, r)
79+
if err != nil {
80+
panic("unexpected error happened when reading from bufio.NewReader(crypto/rand.Reader)")
81+
}
82+
for _, rb := range r {
83+
if rb > randomStringMaxByte {
84+
// Skip this number to avoid bias.
85+
continue
86+
}
87+
b[i] = randomStringCharset[rb%randomStringCharsetLen]
88+
i++
89+
if i == length {
90+
return string(b)
91+
}
92+
}
6993
}
70-
return string(bytes)
7194
}

middleware/util_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"testing"
55

66
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
78
)
89

910
func Test_matchScheme(t *testing.T) {
@@ -117,3 +118,31 @@ func TestRandomString(t *testing.T) {
117118
})
118119
}
119120
}
121+
122+
func TestRandomStringBias(t *testing.T) {
123+
t.Parallel()
124+
const slen = 33
125+
const loop = 100000
126+
127+
counts := make(map[rune]int)
128+
var count int64
129+
130+
for i := 0; i < loop; i++ {
131+
s := randomString(slen)
132+
require.Equal(t, slen, len(s))
133+
for _, b := range s {
134+
counts[b]++
135+
count++
136+
}
137+
}
138+
139+
require.Equal(t, randomStringCharsetLen, len(counts))
140+
141+
avg := float64(count) / float64(len(counts))
142+
for k, n := range counts {
143+
diff := float64(n) / avg
144+
if diff < 0.95 || diff > 1.05 {
145+
t.Errorf("Bias on '%c': expected average %f, got %d", k, avg, n)
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)