feat(canary): mysql wire protocol (handshake + auth + ERR packets)
Phase 8 first commit. Pure encoding/decoding layer for the MySQL
wire protocol — no I/O orchestration, no DB lookups, no business
logic. Three primary functions plus utility helpers, all driven by
the byte-exact spec §9.7 lines 1363-1403:
- BuildHandshakeV10(connID, authData[20]) []byte
Server's initial greeting packet (0x0a protocol version,
"5.7.40-canary" server version, random auth-plugin-data split
into 8+12 byte parts, capability flags 0xf7ff / 0x81ff,
utf8mb4_unicode_ci charset, mysql_native_password plugin name).
Wraps in 3-byte LE length + 1-byte sequence ID (0x00).
- ReadClientAuth(r io.Reader) (*ClientAuth, error)
Parses HandshakeResponse41. Returns the username (null-terminated,
extracted starting at byte offset 32 after the 4+4+1+23-byte
fixed header) plus the client's capabilities, max packet size,
and charset for forensic richness. Defensive bounds checking
against ErrInvalidPayload / ErrUsernameMissing / ErrShortPacket /
ErrPacketTooLarge sentinels. 64KB max packet size cap.
- BuildAccessDeniedErr(username, sourceHost string) []byte
Standard MySQL ERR_Packet 1045 with SQL state "28000" and the
exact "Access denied for user '%s'@'%s' (using password: YES)"
message kubectl and the mysql client both display verbatim.
Sequence ID 0x02 (after handshake=0 and client auth=1).
Plus crypto/rand-backed helpers NewRandomAuthData (20 bytes for the
challenge) and NewRandomConnectionID (uint32). Consistent with the
crypto/rand discipline from Phase 0 supplement, Phase 5 (pdf), Phase 7
(envfile).
Tests (~24 cases): byte-exact assertions on packet headers, payload
layout, sequence IDs; round-trip Build/Read for 5 username variants;
error paths (short packet, missing username terminator, empty reader,
oversize packet rejected mid-stream); randomness distinctness (50
calls → near-50 unique values).
Handler + server land in next commit; generator + registry in the
one after. Trigger interface conformance for the mysql Generator will
be a sentinel-error no-op (mysql triggers over TCP, not HTTP — Phase
9 router doesn't mount any HTTP path to mysql.Trigger).
go test -race -timeout=60s ./internal/token/generators/mysql/...
passes, golangci-lint clean.
This commit is contained in:
parent
3d9b97da21
commit
712202abc0
|
|
@ -0,0 +1,220 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// protocol.go
|
||||||
|
|
||||||
|
package mysql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
packetHeaderSize = 4
|
||||||
|
protocolVersion = 0x0a
|
||||||
|
serverVersionString = "5.7.40-canary"
|
||||||
|
|
||||||
|
capabilityFlagsLower = uint16(0xf7ff)
|
||||||
|
capabilityFlagsUpper = uint16(0x81ff)
|
||||||
|
|
||||||
|
characterSetUtf8MB4 = 0x21
|
||||||
|
statusFlags = uint16(0x0002)
|
||||||
|
authPluginDataLen = byte(0x15)
|
||||||
|
|
||||||
|
authPluginName = "mysql_native_password"
|
||||||
|
|
||||||
|
errPacketHeader = 0xff
|
||||||
|
errCodeAccessDenied = uint16(1045)
|
||||||
|
sqlStateMarker = '#'
|
||||||
|
sqlStateAccessDenied = "28000"
|
||||||
|
|
||||||
|
seqIDServerHandshake byte = 0x00
|
||||||
|
seqIDServerErr byte = 0x02
|
||||||
|
|
||||||
|
maxPacketSize = 0x00ffffff
|
||||||
|
handshakeResponseMaxBytes = 64 * 1024
|
||||||
|
|
||||||
|
handshakeResponseFillerLen = 23
|
||||||
|
handshakeResponseHeaderBytes = 4 + 4 + 1 + handshakeResponseFillerLen
|
||||||
|
|
||||||
|
authPluginDataTotalLen = 20
|
||||||
|
authPluginDataPart1Len = 8
|
||||||
|
authPluginDataPart2Len = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrShortPacket = errors.New("mysql: short packet header")
|
||||||
|
ErrPacketTooLarge = errors.New("mysql: packet exceeds max size")
|
||||||
|
ErrInvalidPayload = errors.New("mysql: invalid payload structure")
|
||||||
|
ErrUsernameMissing = errors.New(
|
||||||
|
"mysql: username missing or unterminated",
|
||||||
|
)
|
||||||
|
ErrHTTPTriggerNotSupported = errors.New(
|
||||||
|
"mysql: Trigger via HTTP is not applicable (use the TCP listener)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientAuth struct {
|
||||||
|
Capabilities uint32
|
||||||
|
MaxPacketSize uint32
|
||||||
|
Charset uint8
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildHandshakeV10(
|
||||||
|
connID uint32,
|
||||||
|
authData [authPluginDataTotalLen]byte,
|
||||||
|
) ([]byte, error) {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
payload.WriteByte(protocolVersion)
|
||||||
|
payload.WriteString(serverVersionString)
|
||||||
|
payload.WriteByte(0x00)
|
||||||
|
|
||||||
|
var connIDBytes [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(connIDBytes[:], connID)
|
||||||
|
payload.Write(connIDBytes[:])
|
||||||
|
|
||||||
|
payload.Write(authData[:authPluginDataPart1Len])
|
||||||
|
payload.WriteByte(0x00)
|
||||||
|
|
||||||
|
var capLower [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(capLower[:], capabilityFlagsLower)
|
||||||
|
payload.Write(capLower[:])
|
||||||
|
|
||||||
|
payload.WriteByte(characterSetUtf8MB4)
|
||||||
|
|
||||||
|
var statusBytes [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(statusBytes[:], statusFlags)
|
||||||
|
payload.Write(statusBytes[:])
|
||||||
|
|
||||||
|
var capUpper [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(capUpper[:], capabilityFlagsUpper)
|
||||||
|
payload.Write(capUpper[:])
|
||||||
|
|
||||||
|
payload.WriteByte(authPluginDataLen)
|
||||||
|
|
||||||
|
var reserved [10]byte
|
||||||
|
payload.Write(reserved[:])
|
||||||
|
|
||||||
|
payload.Write(authData[authPluginDataPart1Len:])
|
||||||
|
payload.WriteByte(0x00)
|
||||||
|
|
||||||
|
payload.WriteString(authPluginName)
|
||||||
|
payload.WriteByte(0x00)
|
||||||
|
|
||||||
|
return wrapPacket(payload.Bytes(), seqIDServerHandshake)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadClientAuth(r io.Reader) (*ClientAuth, error) {
|
||||||
|
payload, err := readPacket(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(payload) < handshakeResponseHeaderBytes+1 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: have %d bytes, need at least %d",
|
||||||
|
ErrInvalidPayload,
|
||||||
|
len(payload),
|
||||||
|
handshakeResponseHeaderBytes+1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
auth := &ClientAuth{
|
||||||
|
Capabilities: binary.LittleEndian.Uint32(payload[0:4]),
|
||||||
|
MaxPacketSize: binary.LittleEndian.Uint32(payload[4:8]),
|
||||||
|
Charset: payload[8],
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameStart := handshakeResponseHeaderBytes
|
||||||
|
rel := bytes.IndexByte(payload[usernameStart:], 0x00)
|
||||||
|
if rel < 0 {
|
||||||
|
return nil, ErrUsernameMissing
|
||||||
|
}
|
||||||
|
auth.Username = string(payload[usernameStart : usernameStart+rel])
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildAccessDeniedErr(username, sourceHost string) ([]byte, error) {
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
`Access denied for user '%s'@'%s' (using password: YES)`,
|
||||||
|
username,
|
||||||
|
sourceHost,
|
||||||
|
)
|
||||||
|
|
||||||
|
var payload bytes.Buffer
|
||||||
|
payload.WriteByte(errPacketHeader)
|
||||||
|
|
||||||
|
var code [2]byte
|
||||||
|
binary.LittleEndian.PutUint16(code[:], errCodeAccessDenied)
|
||||||
|
payload.Write(code[:])
|
||||||
|
|
||||||
|
payload.WriteByte(sqlStateMarker)
|
||||||
|
payload.WriteString(sqlStateAccessDenied)
|
||||||
|
payload.WriteString(msg)
|
||||||
|
|
||||||
|
return wrapPacket(payload.Bytes(), seqIDServerErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapPacket(payload []byte, seqID byte) ([]byte, error) {
|
||||||
|
if len(payload) > maxPacketSize {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: payload %d > max %d",
|
||||||
|
ErrPacketTooLarge,
|
||||||
|
len(payload),
|
||||||
|
maxPacketSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out := make([]byte, packetHeaderSize+len(payload))
|
||||||
|
n := len(payload)
|
||||||
|
out[0] = byte(n & 0xff)
|
||||||
|
out[1] = byte((n >> 8) & 0xff)
|
||||||
|
out[2] = byte((n >> 16) & 0xff)
|
||||||
|
out[3] = seqID
|
||||||
|
copy(out[packetHeaderSize:], payload)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPacket(r io.Reader) ([]byte, error) {
|
||||||
|
var hdr [packetHeaderSize]byte
|
||||||
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %w", ErrShortPacket, err)
|
||||||
|
}
|
||||||
|
payloadLen := uint32(hdr[0]) |
|
||||||
|
uint32(hdr[1])<<8 |
|
||||||
|
uint32(hdr[2])<<16
|
||||||
|
if payloadLen > handshakeResponseMaxBytes {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"%w: payload %d > limit %d",
|
||||||
|
ErrPacketTooLarge,
|
||||||
|
payloadLen,
|
||||||
|
handshakeResponseMaxBytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if payloadLen == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
payload := make([]byte, payloadLen)
|
||||||
|
if _, err := io.ReadFull(r, payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("mysql: read payload: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRandomAuthData() ([authPluginDataTotalLen]byte, error) {
|
||||||
|
var data [authPluginDataTotalLen]byte
|
||||||
|
if _, err := rand.Read(data[:]); err != nil {
|
||||||
|
return data, fmt.Errorf("mysql: random auth data: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRandomConnectionID() (uint32, error) {
|
||||||
|
var b [4]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return 0, fmt.Errorf("mysql: random connection id: %w", err)
|
||||||
|
}
|
||||||
|
return binary.LittleEndian.Uint32(b[:]), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
// ©AngelaMos | 2026
|
||||||
|
// protocol_test.go
|
||||||
|
|
||||||
|
package mysql_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/token/generators/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
packetHeaderSize = 4
|
||||||
|
expectedProtoVersion = 0x0a
|
||||||
|
expectedCharset = 0x21
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildHandshakeResponse41(t *testing.T, username string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var payload bytes.Buffer
|
||||||
|
|
||||||
|
var caps [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(caps[:], 0x0001a285)
|
||||||
|
payload.Write(caps[:])
|
||||||
|
|
||||||
|
var maxPkt [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(maxPkt[:], 0x01000000)
|
||||||
|
payload.Write(maxPkt[:])
|
||||||
|
|
||||||
|
payload.WriteByte(expectedCharset)
|
||||||
|
|
||||||
|
var filler [23]byte
|
||||||
|
payload.Write(filler[:])
|
||||||
|
|
||||||
|
payload.WriteString(username)
|
||||||
|
payload.WriteByte(0x00)
|
||||||
|
|
||||||
|
body := payload.Bytes()
|
||||||
|
out := make([]byte, packetHeaderSize+len(body))
|
||||||
|
n := len(body)
|
||||||
|
out[0] = byte(n & 0xff)
|
||||||
|
out[1] = byte((n >> 8) & 0xff)
|
||||||
|
out[2] = byte((n >> 16) & 0xff)
|
||||||
|
out[3] = 0x01
|
||||||
|
copy(out[packetHeaderSize:], body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHandshakeV10_HasCorrectPacketHeader(t *testing.T) {
|
||||||
|
auth := [20]byte{
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
|
||||||
|
11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
|
||||||
|
}
|
||||||
|
pkt, err := mysql.BuildHandshakeV10(0xdeadbeef, auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, len(pkt), packetHeaderSize)
|
||||||
|
|
||||||
|
payloadLen := int(pkt[0]) |
|
||||||
|
int(pkt[1])<<8 |
|
||||||
|
int(pkt[2])<<16
|
||||||
|
require.Equal(t, len(pkt)-packetHeaderSize, payloadLen)
|
||||||
|
require.Equal(t, byte(0x00), pkt[3], "server handshake sequence ID is 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHandshakeV10_PayloadStartsWithProtocolVersion(t *testing.T) {
|
||||||
|
var auth [20]byte
|
||||||
|
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, byte(expectedProtoVersion), pkt[packetHeaderSize])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHandshakeV10_ContainsServerVersionString(t *testing.T) {
|
||||||
|
var auth [20]byte
|
||||||
|
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
string(pkt),
|
||||||
|
"5.7.40-canary",
|
||||||
|
"handshake must advertise the canary-server version string for verisimilitude",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHandshakeV10_AdvertisesMySQLNativePassword(t *testing.T) {
|
||||||
|
var auth [20]byte
|
||||||
|
pkt, err := mysql.BuildHandshakeV10(0x1234, auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(pkt), "mysql_native_password")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHandshakeV10_EmbedsConnectionIDLittleEndian(t *testing.T) {
|
||||||
|
var auth [20]byte
|
||||||
|
connID := uint32(0xdeadbeef)
|
||||||
|
pkt, err := mysql.BuildHandshakeV10(connID, auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
versionEnd := bytes.IndexByte(
|
||||||
|
pkt[packetHeaderSize+1:],
|
||||||
|
0x00,
|
||||||
|
) + packetHeaderSize + 1
|
||||||
|
require.Greater(t, versionEnd, packetHeaderSize+1)
|
||||||
|
connIDStart := versionEnd + 1
|
||||||
|
got := binary.LittleEndian.Uint32(pkt[connIDStart : connIDStart+4])
|
||||||
|
require.Equal(t, connID, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHandshakeV10_EmbedsAuthDataInTwoParts(t *testing.T) {
|
||||||
|
auth := [20]byte{
|
||||||
|
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||||
|
20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
|
||||||
|
}
|
||||||
|
pkt, err := mysql.BuildHandshakeV10(0xcafef00d, auth)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
string(pkt),
|
||||||
|
string(auth[:8]),
|
||||||
|
"first 8 bytes of auth data must appear in handshake",
|
||||||
|
)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
string(pkt),
|
||||||
|
string(auth[8:]),
|
||||||
|
"last 12 bytes of auth data must appear in handshake (after part-1 + filler + caps + ...)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_ExtractsUsername(t *testing.T) {
|
||||||
|
pkt := buildHandshakeResponse41(t, "canary_abc123def456")
|
||||||
|
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, auth)
|
||||||
|
require.Equal(t, "canary_abc123def456", auth.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_CapturesCapabilities(t *testing.T) {
|
||||||
|
pkt := buildHandshakeResponse41(t, "anyone")
|
||||||
|
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uint32(0x0001a285), auth.Capabilities)
|
||||||
|
require.Equal(t, uint32(0x01000000), auth.MaxPacketSize)
|
||||||
|
require.Equal(t, uint8(expectedCharset), auth.Charset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_EmptyUsernameStillParses(t *testing.T) {
|
||||||
|
pkt := buildHandshakeResponse41(t, "")
|
||||||
|
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, auth.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_ShortPayloadReturnsError(t *testing.T) {
|
||||||
|
short := make([]byte, packetHeaderSize+5)
|
||||||
|
short[0] = 0x05
|
||||||
|
_, err := mysql.ReadClientAuth(bytes.NewReader(short))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, mysql.ErrInvalidPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_NoUsernameTerminatorReturnsError(t *testing.T) {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
var caps [4]byte
|
||||||
|
payload.Write(caps[:])
|
||||||
|
var maxPkt [4]byte
|
||||||
|
payload.Write(maxPkt[:])
|
||||||
|
payload.WriteByte(expectedCharset)
|
||||||
|
var filler [23]byte
|
||||||
|
payload.Write(filler[:])
|
||||||
|
payload.WriteString("nocan_terminator_here_no_null")
|
||||||
|
body := payload.Bytes()
|
||||||
|
|
||||||
|
out := make([]byte, packetHeaderSize+len(body))
|
||||||
|
n := len(body)
|
||||||
|
out[0] = byte(n & 0xff)
|
||||||
|
out[1] = byte((n >> 8) & 0xff)
|
||||||
|
out[2] = byte((n >> 16) & 0xff)
|
||||||
|
out[3] = 0x01
|
||||||
|
copy(out[packetHeaderSize:], body)
|
||||||
|
|
||||||
|
_, err := mysql.ReadClientAuth(bytes.NewReader(out))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, mysql.ErrUsernameMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_EmptyReaderReturnsShortPacket(t *testing.T) {
|
||||||
|
_, err := mysql.ReadClientAuth(bytes.NewReader(nil))
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, mysql.ErrShortPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadClientAuth_OversizePacketRejected(t *testing.T) {
|
||||||
|
var hdr [packetHeaderSize]byte
|
||||||
|
hdr[0] = 0x01
|
||||||
|
hdr[1] = 0x00
|
||||||
|
hdr[2] = 0x10
|
||||||
|
hdr[3] = 0x01
|
||||||
|
|
||||||
|
r := io.MultiReader(
|
||||||
|
bytes.NewReader(hdr[:]),
|
||||||
|
strings.NewReader(strings.Repeat("X", 70000)),
|
||||||
|
)
|
||||||
|
_, err := mysql.ReadClientAuth(r)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorIs(t, err, mysql.ErrPacketTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAccessDeniedErr_HasCorrectMarkerAndCode(t *testing.T) {
|
||||||
|
pkt, err := mysql.BuildAccessDeniedErr("canary_xyz", "203.0.113.50")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, len(pkt), packetHeaderSize)
|
||||||
|
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
byte(0xff),
|
||||||
|
pkt[packetHeaderSize],
|
||||||
|
"ERR packet header byte",
|
||||||
|
)
|
||||||
|
code := binary.LittleEndian.Uint16(
|
||||||
|
pkt[packetHeaderSize+1 : packetHeaderSize+3],
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
uint16(1045),
|
||||||
|
code,
|
||||||
|
"MySQL error code 1045 (access denied)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAccessDeniedErr_HasSQLStateMarkerAnd28000(t *testing.T) {
|
||||||
|
pkt, err := mysql.BuildAccessDeniedErr("canary_xyz", "203.0.113.50")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
byte('#'),
|
||||||
|
pkt[packetHeaderSize+3],
|
||||||
|
"SQL state marker must be '#'",
|
||||||
|
)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
"28000",
|
||||||
|
string(pkt[packetHeaderSize+4:packetHeaderSize+9]),
|
||||||
|
"SQL state 28000 (invalid authorization)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAccessDeniedErr_MessageContainsUserAndHost(t *testing.T) {
|
||||||
|
pkt, err := mysql.BuildAccessDeniedErr("canary_abc", "203.0.113.7")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(
|
||||||
|
t,
|
||||||
|
string(pkt),
|
||||||
|
`Access denied for user 'canary_abc'@'203.0.113.7' (using password: YES)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAccessDeniedErr_SequenceIDIs2(t *testing.T) {
|
||||||
|
pkt, err := mysql.BuildAccessDeniedErr("u", "h")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(
|
||||||
|
t,
|
||||||
|
byte(0x02),
|
||||||
|
pkt[3],
|
||||||
|
"ERR packet sequence ID is 2 (after handshake=0 and client auth=1)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip_BuildAndParseUsername(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"canary_abcdef0123",
|
||||||
|
"canary_xyz",
|
||||||
|
"root",
|
||||||
|
"",
|
||||||
|
"app_user",
|
||||||
|
}
|
||||||
|
for _, name := range cases {
|
||||||
|
name := name
|
||||||
|
t.Run("username="+name, func(t *testing.T) {
|
||||||
|
pkt := buildHandshakeResponse41(t, name)
|
||||||
|
auth, err := mysql.ReadClientAuth(bytes.NewReader(pkt))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, name, auth.Username)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRandomAuthData_ReturnsTwentyBytes(t *testing.T) {
|
||||||
|
d, err := mysql.NewRandomAuthData()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, d, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRandomAuthData_DistinctCallsProduceDistinctOutputs(t *testing.T) {
|
||||||
|
seen := make(map[[20]byte]struct{})
|
||||||
|
for range 50 {
|
||||||
|
d, err := mysql.NewRandomAuthData()
|
||||||
|
require.NoError(t, err)
|
||||||
|
seen[d] = struct{}{}
|
||||||
|
}
|
||||||
|
require.Greater(
|
||||||
|
t,
|
||||||
|
len(seen),
|
||||||
|
45,
|
||||||
|
"50 calls to NewRandomAuthData should produce near-50 unique values",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRandomConnectionID_DistinctCallsProduceDistinctOutputs(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
seen := make(map[uint32]struct{})
|
||||||
|
for range 50 {
|
||||||
|
id, err := mysql.NewRandomConnectionID()
|
||||||
|
require.NoError(t, err)
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
require.Greater(t, len(seen), 45)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrors_AreDistinctSentinels(t *testing.T) {
|
||||||
|
require.NotErrorIs(t, mysql.ErrShortPacket, mysql.ErrPacketTooLarge)
|
||||||
|
require.NotErrorIs(t, mysql.ErrPacketTooLarge, mysql.ErrInvalidPayload)
|
||||||
|
require.NotErrorIs(t, mysql.ErrInvalidPayload, mysql.ErrUsernameMissing)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue