From c3482042353ad50c1c27f3e4bde99866d232f47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:36:09 +0300 Subject: [PATCH 1/2] fix: add rate limiting to AXFR/IXFR and TSIG verification on IXFR->AXFR fallback (#208, #205, #218) --- internal/dns/server/axfr.go | 30 ++++++++++++++++++++++++++++++ internal/dns/server/edge_test.go | 18 ++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/dns/server/axfr.go b/internal/dns/server/axfr.go index 3d94bdc9..d0232fb9 100644 --- a/internal/dns/server/axfr.go +++ b/internal/dns/server/axfr.go @@ -16,6 +16,13 @@ import ( // handleAXFR processes a DNS zone transfer (AXFR) request over TCP. func (s *Server) handleAXFR(ctx context.Context, conn net.Conn, request *packet.DNSPacket, rawData []byte, buffer *packet.BytePacketBuffer) { defer packet.PutBuffer(buffer) + + // Rate limit check — AXFR/IXFR bypass handlePacket where limiter lives + clientIP := extractClientIP(conn.RemoteAddr()) + if !s.limiter.Allow(clientIP) { + s.sendTCPError(conn, request.Header.ID, 2) // SERVFAIL + return + } q := request.Questions[0] if !strings.HasSuffix(q.Name, ".") { q.Name += "." @@ -182,6 +189,13 @@ func (s *Server) sendTCPError(conn net.Conn, id uint16, rcode uint8) { // handleIXFR processes an incremental zone transfer (IXFR) request over TCP. func (s *Server) handleIXFR(ctx context.Context, conn net.Conn, request *packet.DNSPacket, rawData []byte, buffer *packet.BytePacketBuffer) { defer packet.PutBuffer(buffer) + + // Rate limit check — AXFR/IXFR bypass handlePacket where limiter lives + clientIP := extractClientIP(conn.RemoteAddr()) + if !s.limiter.Allow(clientIP) { + s.sendTCPError(conn, request.Header.ID, 2) // SERVFAIL + return + } q := request.Questions[0] if !strings.HasSuffix(q.Name, ".") { q.Name += "." @@ -329,6 +343,22 @@ func (s *Server) handleIXFR(ctx context.Context, conn net.Conn, request *packet. s.Logger.Info("IXFR history gap detected, falling back to AXFR", "zone", zone.Name, "client_serial", clientSerial) + // If original IXFR had TSIG, require TSIG on fallback AXFR too + if request.TSIGStart != -1 { + tsig := request.Resources[len(request.Resources)-1] + key, ok := s.TsigKeys[tsig.Name] + if !ok { + s.Logger.Debug("AXFR fallback failed: unknown TSIG key", "key", tsig.Name, "zone", q.Name) + s.sendTCPError(conn, request.Header.ID, 5) // NotAuth + return + } + if errVerify := request.VerifyTSIG(rawData, request.TSIGStart, key.Secret); errVerify != nil { + s.Logger.Warn("AXFR fallback failed: TSIG verification failed", "error", errVerify, "zone", q.Name) + s.sendTCPError(conn, request.Header.ID, 5) // NotAuth + return + } + } + // RFC 1995: If IXFR is not possible, fall back to AXFR sequence using streaming iter, errIter := s.Repo.ListRecordsForZoneStreaming(ctx, zone.ID, zone.TenantID) if errIter != nil { diff --git a/internal/dns/server/edge_test.go b/internal/dns/server/edge_test.go index 2e113e57..65895000 100644 --- a/internal/dns/server/edge_test.go +++ b/internal/dns/server/edge_test.go @@ -71,10 +71,24 @@ func TestHandleIXFR_NoAuthority(t *testing.T) { type mockConn struct { net.Conn + localAddr net.Addr + remoteAddr net.Addr } -func (m *mockConn) Write(b []byte) (int, error) { return len(b), nil } -func (m *mockConn) Close() error { return nil } +func (m *mockConn) Write(b []byte) (int, error) { return len(b), nil } +func (m *mockConn) Close() error { return nil } +func (m *mockConn) RemoteAddr() net.Addr { + if m.remoteAddr != nil { + return m.remoteAddr + } + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345} +} +func (m *mockConn) LocalAddr() net.Addr { + if m.localAddr != nil { + return m.localAddr + } + return &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 54321} +} // TestHandlePacket_NoQuestions verifies that a DNS packet with no questions // returns a FORMERR response as per RFC standards. From e41912bdc8bbf1b86241472cc2635b2b63b3580d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Poyraz=20K=C3=BC=C3=A7=C3=BCkarslan?= <83272398+PoyrazK@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:37:56 +0300 Subject: [PATCH 2/2] fix: address review findings - TSIG check covers both fallback paths, add rate limit tests --- internal/dns/server/axfr.go | 32 ++++++++--------- internal/dns/server/edge_test.go | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/internal/dns/server/axfr.go b/internal/dns/server/axfr.go index d0232fb9..78127ea3 100644 --- a/internal/dns/server/axfr.go +++ b/internal/dns/server/axfr.go @@ -337,28 +337,28 @@ func (s *Server) handleIXFR(ctx context.Context, conn net.Conn, request *packet. } } + // If original IXFR had TSIG, require TSIG on fallback AXFR too + if request.TSIGStart != -1 { + tsig := request.Resources[len(request.Resources)-1] + key, ok := s.TsigKeys[tsig.Name] + if !ok { + s.Logger.Debug("AXFR fallback failed: unknown TSIG key", "key", tsig.Name, "zone", q.Name) + s.sendTCPError(conn, request.Header.ID, 5) // NotAuth + return + } + if errVerify := request.VerifyTSIG(rawData, request.TSIGStart, key.Secret); errVerify != nil { + s.Logger.Warn("AXFR fallback failed: TSIG verification failed", "error", errVerify, "zone", q.Name) + s.sendTCPError(conn, request.Header.ID, 5) // NotAuth + return + } + } + if err != nil { s.Logger.Warn("IXFR chain query failed, falling back to AXFR", "zone", zone.Name, "error", err) } else if !historyValid { s.Logger.Info("IXFR history gap detected, falling back to AXFR", "zone", zone.Name, "client_serial", clientSerial) - // If original IXFR had TSIG, require TSIG on fallback AXFR too - if request.TSIGStart != -1 { - tsig := request.Resources[len(request.Resources)-1] - key, ok := s.TsigKeys[tsig.Name] - if !ok { - s.Logger.Debug("AXFR fallback failed: unknown TSIG key", "key", tsig.Name, "zone", q.Name) - s.sendTCPError(conn, request.Header.ID, 5) // NotAuth - return - } - if errVerify := request.VerifyTSIG(rawData, request.TSIGStart, key.Secret); errVerify != nil { - s.Logger.Warn("AXFR fallback failed: TSIG verification failed", "error", errVerify, "zone", q.Name) - s.sendTCPError(conn, request.Header.ID, 5) // NotAuth - return - } - } - // RFC 1995: If IXFR is not possible, fall back to AXFR sequence using streaming iter, errIter := s.Repo.ListRecordsForZoneStreaming(ctx, zone.ID, zone.TenantID) if errIter != nil { diff --git a/internal/dns/server/edge_test.go b/internal/dns/server/edge_test.go index 65895000..d155faa7 100644 --- a/internal/dns/server/edge_test.go +++ b/internal/dns/server/edge_test.go @@ -114,3 +114,65 @@ func TestHandlePacket_NoQuestions(t *testing.T) { t.Fatalf("handlePacket failed: %v", err) } } + +// TestHandleAXFR_RateLimited verifies that AXFR returns SERVFAIL when rate limited. +func TestHandleAXFR_RateLimited(t *testing.T) { + repo := &mockServerRepo{ + zones: []domain.Zone{{ID: "z1", Name: "test."}}, + records: []domain.Record{ + {ZoneID: "z1", Name: "test.", Type: domain.TypeSOA, Content: "ns1. ns2. 1 2 3 4 5"}, + }, + } + srv := NewServer(":0", repo, nil) + // Replace limiter with one that denies all + srv.limiter = newRateLimiter(0, 0, 1000) + + req := packet.NewDNSPacket() + req.Header.ID = 1234 + req.Questions = append(req.Questions, packet.DNSQuestion{Name: "test.", QType: packet.AXFR}) + + conn := &mockTCPConn{} + srv.handleAXFR(context.Background(), conn, req, nil, nil) + + if len(conn.captured) != 1 { + t.Fatalf("Expected 1 response, got %d", len(conn.captured)) + } + res := packet.NewDNSPacket() + pb := packet.NewBytePacketBuffer() + pb.Load(conn.captured[0]) + _ = res.FromBuffer(pb) + if res.Header.ResCode != 2 { // SERVFAIL + t.Errorf("Expected SERVFAIL (2), got %d", res.Header.ResCode) + } +} + +// TestHandleIXFR_RateLimited verifies that IXFR returns SERVFAIL when rate limited. +func TestHandleIXFR_RateLimited(t *testing.T) { + repo := &mockServerRepo{ + zones: []domain.Zone{{ID: "z1", Name: "test."}}, + records: []domain.Record{ + {ZoneID: "z1", Name: "test.", Type: domain.TypeSOA, Content: "ns1. ns2. 1 2 3 4 5"}, + }, + } + srv := NewServer(":0", repo, nil) + // Replace limiter with one that denies all + srv.limiter = newRateLimiter(0, 0, 1000) + + req := packet.NewDNSPacket() + req.Header.ID = 1234 + req.Questions = append(req.Questions, packet.DNSQuestion{Name: "test.", QType: packet.IXFR}) + + conn := &mockTCPConn{} + srv.handleIXFR(context.Background(), conn, req, nil, nil) + + if len(conn.captured) != 1 { + t.Fatalf("Expected 1 response, got %d", len(conn.captured)) + } + res := packet.NewDNSPacket() + pb := packet.NewBytePacketBuffer() + pb.Load(conn.captured[0]) + _ = res.FromBuffer(pb) + if res.Header.ResCode != 2 { // SERVFAIL + t.Errorf("Expected SERVFAIL (2), got %d", res.Header.ResCode) + } +}