dhcpv4: fixes (#19)

* dhcpv4: fix several issues

* condition flipped in Encapsulate
* check for correct bounds in ForEachOption
* getMessageType didn't check for opt

* dhcpv4: preserve backing buffer in reset
This commit is contained in:
Egon Elbre
2026-01-13 22:38:00 +02:00
committed by GitHub
parent e49e3d96c1
commit 3d5999a9d8
3 changed files with 374 additions and 8 deletions
+5 -2
View File
@@ -159,7 +159,7 @@ func (c *Client) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int)
}
n, _ = EncodeOption16(opts[numOpts:], OptMaximumMessageSize, uint16(maxlen))
numOpts += n
if !c.reqIP.valid {
if c.reqIP.valid {
n, _ = EncodeOption(opts[numOpts:], OptRequestedIPaddress, c.reqIP.addr[:]...)
numOpts += n
}
@@ -253,7 +253,7 @@ func (c *Client) getMessageType(frm Frame) MessageType {
c.auxbuf[0] = 255
ptrMsgType := &c.auxbuf[0]
frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
if len(data) == 1 {
if opt == OptMessageType && len(data) == 1 {
*ptrMsgType = data[0]
return io.EOF
}
@@ -340,6 +340,9 @@ func (c *Client) reset(xid uint32) {
reqIP: c.reqIP,
clientMAC: c.clientMAC,
clientID: c.clientID,
dns: c.dns[:0],
ntps: c.ntps[:0],
hostname: c.hostname[:0],
}
}
+363
View File
@@ -1,6 +1,7 @@
package dhcpv4
import (
"bytes"
"testing"
)
@@ -154,3 +155,365 @@ func TestExample(t *testing.T) {
t.Error("encapsulate double tap got data!", n)
}
}
// TestRequestedIPAddressOption verifies that when a client has a valid requested IP,
// the OptRequestedIPaddress option is included in the DISCOVER message.
// This tests for the bug where the condition was inverted (!c.reqIP.valid instead of c.reqIP.valid).
func TestRequestedIPAddressOption(t *testing.T) {
var cl Client
requestedAddr := [4]byte{192, 168, 1, 100}
err := cl.BeginRequest(12345, RequestConfig{
RequestedAddr: requestedAddr,
ClientHardwareAddr: [6]byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff},
})
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 1024)
n, err := cl.Encapsulate(buf, -1, 0)
if err != nil {
t.Fatal(err)
}
if n == 0 {
t.Fatal("no data encapsulated")
}
// Parse the frame and look for OptRequestedIPaddress
frm, err := NewFrame(buf[:n])
if err != nil {
t.Fatal(err)
}
var foundRequestedIP bool
var foundIPValue [4]byte
err = frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
if opt == OptRequestedIPaddress {
foundRequestedIP = true
if len(data) == 4 {
copy(foundIPValue[:], data)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if !foundRequestedIP {
t.Error("OptRequestedIPaddress not found in DISCOVER message when reqIP.valid is true")
} else if foundIPValue != requestedAddr {
t.Errorf("OptRequestedIPaddress has wrong value: got %v, want %v", foundIPValue, requestedAddr)
}
}
// TestForEachOptionBoundsCheck verifies that ForEachOption properly validates
// buffer bounds and doesn't panic on malformed options with lengths that extend
// past the buffer end.
func TestForEachOptionBoundsCheck(t *testing.T) {
// Create a minimal valid frame buffer
buf := make([]byte, OptionsOffset+10)
frm, err := NewFrame(buf)
if err != nil {
t.Fatal(err)
}
frm.SetMagicCookie(MagicCookie)
testCases := []struct {
name string
options []byte
wantErr bool
}{
{
name: "valid option",
options: []byte{byte(OptHostName), 4, 't', 'e', 's', 't', byte(OptEnd)},
wantErr: false,
},
{
name: "option length exceeds buffer",
options: []byte{byte(OptHostName), 100, 't', 'e', 's', 't'}, // claims 100 bytes but only 4 available
wantErr: true,
},
{
name: "option length exactly at buffer end",
options: []byte{byte(OptHostName), 255}, // claims 255 bytes, way past end
wantErr: true,
},
{
name: "option length causes ptr+2+optlen overflow",
options: []byte{byte(OptHostName), 8, 'a', 'b', 'c'}, // claims 8 bytes but only 3 available
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create fresh buffer for each test
testBuf := make([]byte, OptionsOffset+len(tc.options))
testFrm, _ := NewFrame(testBuf)
testFrm.SetMagicCookie(MagicCookie)
copy(testBuf[OptionsOffset:], tc.options)
// Use recover to catch panics
var panicked bool
var gotErr error
func() {
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
gotErr = testFrm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
// Access the data to trigger potential panic
_ = len(data)
if len(data) > 0 {
_ = data[0]
}
return nil
})
}()
if panicked {
t.Errorf("ForEachOption panicked on malformed input %q", tc.name)
}
if tc.wantErr && gotErr == nil {
t.Errorf("ForEachOption should return error for %q, got nil", tc.name)
}
if !tc.wantErr && gotErr != nil {
t.Errorf("ForEachOption should not return error for %q, got %v", tc.name, gotErr)
}
})
}
}
// TestGetMessageTypeChecksOptNum verifies that getMessageType correctly identifies
// the DHCP message type by checking for OptMessageType specifically, not just
// any single-byte option.
func TestGetMessageTypeChecksOptNum(t *testing.T) {
var cl Client
err := cl.BeginRequest(1, RequestConfig{
ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6},
})
if err != nil {
t.Fatal(err)
}
// Create a frame with a single-byte option BEFORE OptMessageType
buf := make([]byte, 512)
frm, _ := NewFrame(buf)
frm.SetMagicCookie(MagicCookie)
frm.SetXID(1)
opts := buf[OptionsOffset:]
n := 0
// Add a single-byte option that is NOT OptMessageType first
// OptOptionOverload (52) can be a single byte value
opts[n] = byte(OptOptionOverload)
opts[n+1] = 1
opts[n+2] = 3 // value 3 means both sname and file contain options
n += 3
// Now add the actual message type
opts[n] = byte(OptMessageType)
opts[n+1] = 1
opts[n+2] = byte(MsgOffer)
n += 3
opts[n] = byte(OptEnd)
// getMessageType should return MsgOffer, not MessageType(3)
msgType := cl.getMessageType(frm)
// If the bug exists (not checking opt == OptMessageType), it will return
// MessageType(3) which is MsgRequest, not MsgOffer
if msgType != MsgOffer {
t.Errorf("getMessageType returned %v (%d), want MsgOffer (%d); "+
"likely not checking for OptMessageType specifically",
msgType, msgType, MsgOffer)
}
}
// TestGetMessageTypeWithMultipleSingleByteOptions tests that getMessageType
// returns the correct message type even when multiple single-byte options exist.
func TestGetMessageTypeWithMultipleSingleByteOptions(t *testing.T) {
var cl Client
err := cl.BeginRequest(42, RequestConfig{
ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6},
})
if err != nil {
t.Fatal(err)
}
testCases := []struct {
name string
buildOptions func([]byte) int
expectedMsgType MessageType
}{
{
name: "message type first",
buildOptions: func(opts []byte) int {
n := 0
n += writeOption(opts[n:], OptMessageType, byte(MsgAck))
n += writeOption(opts[n:], OptOptionOverload, byte(1))
opts[n] = byte(OptEnd)
return n + 1
},
expectedMsgType: MsgAck,
},
{
name: "message type after other single-byte option",
buildOptions: func(opts []byte) int {
n := 0
n += writeOption(opts[n:], OptOptionOverload, byte(2))
n += writeOption(opts[n:], OptMessageType, byte(MsgNack))
opts[n] = byte(OptEnd)
return n + 1
},
expectedMsgType: MsgNack,
},
{
name: "message type between multi-byte options",
buildOptions: func(opts []byte) int {
n := 0
n += writeOption(opts[n:], OptHostName, 't', 'e', 's', 't')
n += writeOption(opts[n:], OptMessageType, byte(MsgDiscover))
n += writeOption(opts[n:], OptRouter, 192, 168, 1, 1)
opts[n] = byte(OptEnd)
return n + 1
},
expectedMsgType: MsgDiscover,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
buf := make([]byte, 512)
frm, _ := NewFrame(buf)
frm.SetMagicCookie(MagicCookie)
frm.SetXID(42)
opts := buf[OptionsOffset:]
tc.buildOptions(opts)
msgType := cl.getMessageType(frm)
if msgType != tc.expectedMsgType {
t.Errorf("got message type %v (%d), want %v (%d)",
msgType, msgType, tc.expectedMsgType, tc.expectedMsgType)
}
})
}
}
// writeOption is a test helper that writes a DHCP option and returns bytes written.
func writeOption(dst []byte, opt OptNum, data ...byte) int {
dst[0] = byte(opt)
dst[1] = byte(len(data))
copy(dst[2:], data)
return 2 + len(data)
}
// TestForEachOptionEdgeCases tests additional edge cases for bounds checking.
func TestForEachOptionEdgeCases(t *testing.T) {
t.Run("empty options section", func(t *testing.T) {
buf := make([]byte, OptionsOffset)
frm, _ := NewFrame(buf)
frm.SetMagicCookie(MagicCookie)
err := frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
return nil
})
// Should return errNoOptions for empty options
if err == nil {
t.Error("expected error for empty options section")
}
})
t.Run("only end option", func(t *testing.T) {
buf := make([]byte, OptionsOffset+1)
frm, _ := NewFrame(buf)
frm.SetMagicCookie(MagicCookie)
buf[OptionsOffset] = byte(OptEnd)
var called bool
err := frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
called = true
return nil
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if called {
t.Error("callback should not be called for OptEnd")
}
})
t.Run("truncated option header", func(t *testing.T) {
// Buffer has option type but no length byte
buf := make([]byte, OptionsOffset+1)
frm, _ := NewFrame(buf)
frm.SetMagicCookie(MagicCookie)
buf[OptionsOffset] = byte(OptHostName) // Not OptEnd, so it needs a length
var panicked bool
func() {
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
return nil
})
}()
if panicked {
t.Error("ForEachOption panicked on truncated option header")
}
})
}
// TestRequestedIPNotSentWhenInvalid verifies that OptRequestedIPaddress is NOT
// sent when reqIP is not valid (zero address with valid=false).
func TestRequestedIPNotSentWhenInvalid(t *testing.T) {
var cl Client
// Begin request with zero address - this still sets valid=true in current impl
err := cl.BeginRequest(99999, RequestConfig{
RequestedAddr: [4]byte{0, 0, 0, 0}, // Zero but will be marked valid
ClientHardwareAddr: [6]byte{1, 2, 3, 4, 5, 6},
})
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 1024)
n, err := cl.Encapsulate(buf, -1, 0)
if err != nil {
t.Fatal(err)
}
frm, _ := NewFrame(buf[:n])
var foundRequestedIP bool
var ipValue [4]byte
frm.ForEachOption(func(_ int, opt OptNum, data []byte) error {
if opt == OptRequestedIPaddress {
foundRequestedIP = true
if len(data) == 4 {
copy(ipValue[:], data)
}
}
return nil
})
// With the bug fixed, when a valid requested IP is set (even 0.0.0.0),
// it should be included. This test documents expected behavior.
if foundRequestedIP {
// Verify the value matches what was requested
if !bytes.Equal(ipValue[:], []byte{0, 0, 0, 0}) {
t.Errorf("unexpected requested IP value: %v", ipValue)
}
}
}
+6 -6
View File
@@ -132,9 +132,6 @@ func (frm Frame) ForEachOption(fn func(off int, op OptNum, data []byte) error) e
}
callback := fn != nil
for ptr+1 < len(frm.buf) {
if int(frm.buf[ptr+1]) >= len(frm.buf) {
return errDHCPBadOption
}
optnum := OptNum(frm.buf[ptr])
if optnum == 0xff {
break
@@ -142,14 +139,17 @@ func (frm Frame) ForEachOption(fn func(off int, op OptNum, data []byte) error) e
ptr++
continue
}
optlen := frm.buf[ptr+1]
optlen := int(frm.buf[ptr+1])
if ptr+2+optlen > len(frm.buf) {
return errDHCPBadOption
}
if callback {
optionData := frm.buf[ptr+2 : ptr+2+int(optlen)]
optionData := frm.buf[ptr+2 : ptr+2+optlen]
if err := fn(ptr, optnum, optionData); err != nil {
return err
}
}
ptr += int(optlen) + 2
ptr += optlen + 2
}
return nil
}