Skip to content

Commit 602528d

Browse files
JAORMXclaude
andcommitted
Cap dynamic egress rule TTL at five minutes
The existing minTTL clamp prevents rule churn from very short DNS TTLs. Add a symmetric maxTTL clamp (default 5 min) so a very long advertised TTL does not leave a dynamically-allowed IP in the rule set for hours or days. Bounds exposure if an upstream zone returns rogue long-lived answers, and keeps the working set of dynamic rules pegged to recent resolutions. Exposed via functional options (WithMinTTL, WithMaxTTL) on NewDNSInterceptor. The constructor signature gains a variadic opts parameter — backwards compatible for existing callers. WithMaxTTL(0) disables the cap; WithMinTTL(0) preserves the default. Test uses a 5ms max to keep the clamp provable in a real-time unit test without making the suite slow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e4307b2 commit 602528d

2 files changed

Lines changed: 72 additions & 4 deletions

File tree

net/egress/interceptor.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ const (
1616
// if the DNS response has a shorter TTL. This prevents excessive
1717
// rule churn from very short TTLs.
1818
defaultMinTTL = 60 * time.Second
19+
20+
// defaultMaxTTL is the maximum TTL applied to dynamic rules, even
21+
// if the DNS response advertises a longer TTL. This bounds how long
22+
// a resolved IP remains allowed — useful if an upstream server
23+
// returns very long TTLs, and as belt-and-suspenders against a
24+
// compromised zone returning long-lived rogue answers.
25+
defaultMaxTTL = 5 * time.Minute
1926
)
2027

2128
// DNSInterceptor intercepts DNS traffic at the relay level to enforce
@@ -25,21 +32,47 @@ type DNSInterceptor struct {
2532
policy *Policy
2633
dynamicRules *firewall.DynamicRules
2734
minTTL time.Duration
35+
maxTTL time.Duration
2836
gatewayIP [4]byte
2937
}
3038

39+
// DNSInterceptorOption customizes a DNSInterceptor.
40+
type DNSInterceptorOption func(*DNSInterceptor)
41+
42+
// WithMinTTL sets the minimum TTL applied to dynamic rules. A zero or
43+
// negative value leaves the default in place.
44+
func WithMinTTL(d time.Duration) DNSInterceptorOption {
45+
return func(i *DNSInterceptor) {
46+
if d > 0 {
47+
i.minTTL = d
48+
}
49+
}
50+
}
51+
52+
// WithMaxTTL sets the maximum TTL applied to dynamic rules. A zero or
53+
// negative value disables the cap (any TTL is accepted).
54+
func WithMaxTTL(d time.Duration) DNSInterceptorOption {
55+
return func(i *DNSInterceptor) { i.maxTTL = d }
56+
}
57+
3158
// NewDNSInterceptor creates an interceptor with the given policy, dynamic
3259
// rule set, and gateway IP. Only DNS responses from the gateway are
3360
// snooped to prevent spoofed responses from creating dynamic rules.
34-
// Dynamic rules created from DNS responses will have at least minTTL
35-
// duration (use 0 for the default of 60 seconds).
36-
func NewDNSInterceptor(policy *Policy, dr *firewall.DynamicRules, gatewayIP [4]byte) *DNSInterceptor {
37-
return &DNSInterceptor{
61+
//
62+
// By default, dynamic rules are clamped to minTTL=60s and maxTTL=5m;
63+
// override via WithMinTTL / WithMaxTTL.
64+
func NewDNSInterceptor(policy *Policy, dr *firewall.DynamicRules, gatewayIP [4]byte, opts ...DNSInterceptorOption) *DNSInterceptor {
65+
i := &DNSInterceptor{
3866
policy: policy,
3967
dynamicRules: dr,
4068
minTTL: defaultMinTTL,
69+
maxTTL: defaultMaxTTL,
4170
gatewayIP: gatewayIP,
4271
}
72+
for _, o := range opts {
73+
o(i)
74+
}
75+
return i
4376
}
4477

4578
// HandleEgress processes an outbound DNS query frame. If the queried
@@ -118,6 +151,9 @@ func (d *DNSInterceptor) HandleIngress(frame []byte, hdr *firewall.PacketHeader)
118151
if ttl < d.minTTL {
119152
ttl = d.minTTL
120153
}
154+
if d.maxTTL > 0 && ttl > d.maxTTL {
155+
ttl = d.maxTTL
156+
}
121157

122158
ports, proto := d.policy.HostPorts(qname)
123159

net/egress/interceptor_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"encoding/binary"
88
"net"
99
"testing"
10+
"time"
1011

1112
mdns "github.com/miekg/dns"
1213
"github.com/stretchr/testify/assert"
@@ -304,6 +305,37 @@ func TestDNSInterceptor_ResponseFromNonGateway_Ignored(t *testing.T) {
304305
"DNS responses from non-gateway sources must not create dynamic rules")
305306
}
306307

308+
func TestDNSInterceptor_ClampsTTLAtMaximum(t *testing.T) {
309+
t.Parallel()
310+
311+
policy := NewPolicy([]HostSpec{{Name: "example.com"}})
312+
dr := firewall.NewDynamicRules()
313+
interceptor := NewDNSInterceptor(policy, dr, testDstIP,
314+
WithMinTTL(1*time.Microsecond),
315+
WithMaxTTL(5*time.Millisecond),
316+
)
317+
318+
// Response advertises TTL = 1 hour; should be clamped to 5 ms.
319+
ips := []net.IP{net.ParseIP("1.2.3.4")}
320+
frame := buildDNSResponseFrame(testDstMAC, testSrcMAC, testDstIP, testSrcIP, 12345, "example.com", ips, 3600)
321+
hdr := firewall.ParseHeaders(frame)
322+
323+
interceptor.HandleIngress(frame, hdr)
324+
325+
probe := &firewall.PacketHeader{
326+
DstIP: [4]byte{1, 2, 3, 4},
327+
Protocol: 6,
328+
DstPort: 443,
329+
}
330+
_, ok := dr.Match(firewall.Egress, probe)
331+
require.True(t, ok, "rule should be live immediately after ingress")
332+
333+
// Give the clamp time to elapse; the rule must no longer match.
334+
time.Sleep(25 * time.Millisecond)
335+
_, ok = dr.Match(firewall.Egress, probe)
336+
assert.False(t, ok, "rule should have expired past maxTTL clamp")
337+
}
338+
307339
func TestDNSInterceptor_ExplicitProtocol_SingleRule(t *testing.T) {
308340
t.Parallel()
309341

0 commit comments

Comments
 (0)