-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathprocess_filters.go
More file actions
490 lines (431 loc) · 18.5 KB
/
process_filters.go
File metadata and controls
490 lines (431 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
/*
File: process_filters.go
Version: 1.7.0
Updated: 21-Apr-2026 13:25 CEST
Description:
Verbose Deep Packet Filtering operations for Target-Names and IP Addresses.
Changes:
1.7.0 - [PERF] Updated filter pipelines to utilize `clientAddr netip.Addr`
directly, eliminating redundant allocations during `CheckParental` calls.
*/
package main
import (
"fmt"
"log"
"net"
"net/netip"
"github.com/miekg/dns"
)
// ---------------------------------------------------------------------------
// DNS QNAME Validation
// ---------------------------------------------------------------------------
// validQNAMEChars acts as an O(1) lock-free lookup table to enforce RFC 1035/1123
// character compliance efficiently without using RegEx or expensive switch branching.
// Characters permitted: [A-Z, a-z, 0-9, '-', '_'].
var validQNAMEChars = [256]bool{
'-': true, '_': true,
'0': true, '1': true, '2': true, '3': true, '4': true, '5': true, '6': true, '7': true, '8': true, '9': true,
'a': true, 'b': true, 'c': true, 'd': true, 'e': true, 'f': true, 'g': true, 'h': true, 'i': true, 'j': true, 'k': true, 'l': true, 'm': true, 'n': true, 'o': true, 'p': true, 'q': true, 'r': true, 's': true, 't': true, 'u': true, 'v': true, 'w': true, 'x': true, 'y': true, 'z': true,
'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true, 'G': true, 'H': true, 'I': true, 'J': true, 'K': true, 'L': true, 'M': true, 'N': true, 'O': true, 'P': true, 'Q': true, 'R': true, 'S': true, 'T': true, 'U': true, 'V': true, 'W': true, 'X': true, 'Y': true, 'Z': true,
}
// isValidQNAME performs strict syntactic, zero-allocation validation on DNS query names.
// It enforces RFC 1035/1123 lengths and character sets, mitigating malformed
// payload injection and buffer exhaustions before CPU cycles are spent routing.
func isValidQNAME(name string) bool {
l := len(name)
// RFC 1035: Maximum string representation length is 253 characters
// (equates to 255 octets on the wire inclusive of length boundaries).
if l == 0 || l > 253 {
return false
}
if name == "." {
return true // Valid root query
}
labelLen := 0
var lastChar byte
for i := 0; i < l; i++ {
c := name[i]
if c == '.' {
// RFC 1035: Label length must be between 1 and 63 octets.
if labelLen == 0 || labelLen > 63 {
return false // Empty label (e.g. consecutive dots or leading dot) or too long
}
// RFC 1035/1123: Labels cannot end with a hyphen.
if lastChar == '-' {
return false
}
labelLen = 0
lastChar = c
continue
}
// RFC 1035/1123: Labels cannot start with a hyphen.
if labelLen == 0 && c == '-' {
return false
}
// O(1) Instantaneous Lookup for Character compliance
if !validQNAMEChars[c] {
return false // Unrecognized, potentially dangerous character detected
}
labelLen++
lastChar = c
}
// If the name does not end with a trailing dot (i.e., not fully qualified),
// ensure the final label adheres to length and hyphen constraints.
if labelLen > 0 {
if labelLen > 63 || lastChar == '-' {
return false
}
}
return true
}
// bogonPrefixes holds the compiled list of restricted/private/special-purpose IPs.
// It is iterated over during DNS Rebinding Protection checks.
var bogonPrefixes []netip.Prefix
// init dynamically parses the hardcoded bogon strings into optimized netip.Prefix
// structs at startup to eliminate runtime string-parsing overhead.
func init() {
// Detailed RFC-backed Bogon and Private Address definitions.
// This list drops prefixes that should never be returned by a legitimate public
// internet server to a local client, thus thwarting Server-Side Request Forgery
// (SSRF) and DNS Rebinding exploitation attempts.
prefixes := []string{
// -------------------------------------------------------------------
// IPv4 BOGON & PRIVATE ADDRESS SPACE
// -------------------------------------------------------------------
"0.0.0.0/8", // RFC 1122: "This host on this network". Often used as a null-route sinkhole.
"10.0.0.0/8", // RFC 1918: Private-Use. The largest standard local network block.
"100.64.0.0/10", // RFC 6598: Shared Address Space (Carrier-Grade NAT / CGNAT).
"127.0.0.0/8", // RFC 1122: Loopback. Crucial for SSRF prevention.
"169.254.0.0/16", // RFC 3927: Link-Local. Used for auto-configuration (APIPA).
"172.16.0.0/12", // RFC 1918: Private-Use.
"192.0.0.0/24", // RFC 6890: IETF Protocol Assignments (includes dummy, PCP Anycast, NAT64). Not publicly routable.
"192.0.2.0/24", // RFC 5737: TEST-NET-1 (Documentation).
"192.31.196.0/24", // RFC 7535: AS112-v4. Used for blackhole routing.
"192.52.193.0/24", // RFC 7450: AMT (Automatic Multicast Tunneling).
"192.88.99.0/24", // RFC 3068: 6to4 Relay Anycast (Deprecated by RFC 7526, safe to treat as bogon).
"192.168.0.0/16", // RFC 1918: Private-Use. Most common consumer LAN block.
"192.175.48.0/24", // RFC 7534: Direct Delegation AS112 Service.
"198.18.0.0/15", // RFC 2544: Benchmarking. Interconnect tests.
"198.51.100.0/24", // RFC 5737: TEST-NET-2 (Documentation).
"203.0.113.0/24", // RFC 5737: TEST-NET-3 (Documentation).
"224.0.0.0/4", // RFC 1112: Multicast. Should not be resolved in standard unicast A records.
"240.0.0.0/4", // RFC 1112: Reserved (Class E). Future/experimental use, never routed.
// -------------------------------------------------------------------
// IPv6 BOGON & PRIVATE ADDRESS SPACE
// -------------------------------------------------------------------
"::/128", // RFC 4291: Unspecified Address.
"::1/128", // RFC 4291: Loopback Address. Crucial for IPv6 SSRF prevention.
"::/96", // RFC 4291: IPv4-compatible IPv6 (Deprecated).
"::ffff:0:0/96", // RFC 4291: IPv4-mapped IPv6 Address.
"0100::/64", // RFC 6666: Discard-Only Address Block. Blackhole routing.
"64:ff9b::/96", // RFC 6052: IPv4/IPv6 Translation (WKA for NAT64).
"64:ff9b:1::/48", // RFC 8215: Local-Use IPv4/IPv6 Translation.
"2001:2::/48", // RFC 5180: Benchmarking.
"2001:3::/32", // RFC 7450: AMT (Automatic Multicast Tunneling).
"2001:4:112::/48", // RFC 7535: AS112-v6.
"2001:10::/28", // RFC 4843: ORCHID (Deprecated cryptographic hashes).
"2001:20::/28", // RFC 7343: ORCHIDv2.
"2001:db8::/32", // RFC 3849: Documentation.
"3ffe::/16", // RFC 3701: 6bone (Historic). Officially returned to IANA.
"fc00::/7", // RFC 4193: Unique Local Address (ULA). IPv6 equivalent of RFC1918.
"fe80::/10", // RFC 4291: Link-Local Unicast. Highly dangerous if leaked to the WAN.
"fec0::/10", // RFC 3879: Site-Local (Deprecated).
"ff00::/8", // RFC 4291: Multicast. (Note: This entirely subsumes ffff:.../128).
}
for _, p := range prefixes {
bogonPrefixes = append(bogonPrefixes, netip.MustParsePrefix(p))
}
}
// ---------------------------------------------------------------------------
// Target Name Policy Check
// ---------------------------------------------------------------------------
// checkTargetNames performs deep packet inspection on the Answer section of an
// upstream response. It searches for embedded hostnames (Targets) within records
// such as CNAMEs, MX, SRV, SVCB, HTTPS, etc.
// If the server configuration mandates target name enforcement (cfg.Server.TargetName),
// these extracted domains are passed through the standard Domain Policy and Parental
// Control mapping mechanisms just like a primary query.
// Returns true if the response was fully blocked and handled, false if clean.
func checkTargetNames(w dns.ResponseWriter, r *dns.Msg, resp *dns.Msg, clientMAC, clientIP string, clientAddr netip.Addr, clientName, protocol, sni, path string) bool {
// Fast exit: feature disabled to conserve CPU cycles.
if !cfg.Server.TargetName {
return false
}
// Iterate over the response payload to identify redirect or service targets.
for _, rr := range resp.Answer {
var target string
// Type-switch extracting the concrete FQDN depending on the underlying RR.
switch rec := rr.(type) {
case *dns.CNAME:
target = rec.Target
case *dns.MX:
target = rec.Mx
case *dns.NS:
target = rec.Ns
case *dns.PTR:
target = rec.Ptr
case *dns.SRV:
target = rec.Target
case *dns.SOA:
target = rec.Ns // The primary nameserver
case *dns.SVCB:
target = rec.Target
case *dns.HTTPS:
target = rec.Target
default:
continue // Unrecognized or unapplicable record, skip securely.
}
// Validate extracted target; roots or empty targets are safely ignored.
if target == "" || target == "." {
continue
}
// Normalize domain casing and strip trailing dot for map lookups.
target = lowerTrimDot(target)
// -------------------------------------------------------------------
// 1. Evaluate against Global Domain Policies
// -------------------------------------------------------------------
if hasDomainPolicy {
policyAction, policyBlocked, policyMatched, _, _, _ := walkDomainMaps(target)
if policyBlocked {
// The target matches a hardcoded global block. Record metrics.
IncrBlockedDomain(target, "Domain Policy Target ("+policyMatched+")")
recordRecentBlock(clientIP, target, "Domain Policy Target ("+policyMatched+")")
// Write the customized policy exit logic (DROP, NULL-IP, or NXDOMAIN).
dropped := writePolicyAction(w, r, policyAction)
// Log the event with verbosity if query logging is enabled.
if logQueries {
var actionLogStr string
switch policyAction {
case PolicyActionDrop:
actionLogStr = "DROP"
case PolicyActionBlock:
if r.Question[0].Qtype == dns.TypeA || r.Question[0].Qtype == dns.TypeAAAA {
actionLogStr = "NOERROR (NULL-IP)"
} else {
actionLogStr = "NXDOMAIN"
}
default:
actionLogStr = dns.RcodeToString[policyAction]
if actionLogStr == "" {
actionLogStr = fmt.Sprintf("RCODE:%d", policyAction)
}
}
statusMark := "POLICY BLOCK TARGET"
if dropped { statusMark = "POLICY DROP TARGET" }
log.Printf("[DNS] [%s] %s -> %s %s | %s (Domain Policy (%s)) | %s",
protocol, buildClientID(clientIP, clientName, clientAddr),
r.Question[0].Name, dns.TypeToString[r.Question[0].Qtype], statusMark, policyMatched, actionLogStr)
}
return true
}
}
// -------------------------------------------------------------------
// 2. Evaluate against Parental Control Profiles
// -------------------------------------------------------------------
if hasParental && (clientMAC != "" || clientIP != "" || clientName != "" || sni != "" || path != "") {
// Trigger standard parental checking logic using the discovered target.
blocked, blockTTL, _, reason, cat, matchedApex := CheckParental(clientMAC, clientIP, clientAddr, clientName, sni, path, target, true)
if blocked {
IncrParentalBlock()
IncrBlockedDomain(target, reason+" (Target)")
recordRecentBlock(clientIP, target, reason+" (Target)")
// Synthesize the localized block response representing the parent's desires.
blockResp := new(dns.Msg)
blockResp.SetReply(r)
q := r.Question[0]
if q.Qtype == dns.TypeA {
// Return 0.0.0.0 to securely sinkhole A records.
blockResp.Answer = append(blockResp.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: blockTTL},
A: net.IPv4zero,
})
} else {
// Return NXDOMAIN to securely sinkhole AAAA and all other records.
blockResp.SetRcode(r, dns.RcodeNameError)
}
_ = w.WriteMsg(blockResp)
if logQueries {
rcodeStr := "NXDOMAIN"
if q.Qtype == dns.TypeA {
rcodeStr = "NOERROR (NULL-IP)"
}
catStr := ""
if cat != "" {
catStr = fmt.Sprintf(" (Category: %s, apex: %s)", cat, matchedApex)
}
log.Printf("[DNS] [%s] %s -> %s %s | PARENTAL BLOCK TARGET%s | %s",
protocol, buildClientID(clientIP, clientName, clientAddr),
r.Question[0].Name, dns.TypeToString[r.Question[0].Qtype], catStr, rcodeStr)
}
return true
}
}
}
return false
}
// ---------------------------------------------------------------------------
// IP Filter Policy Check
// ---------------------------------------------------------------------------
// filterResponseIPs inspects A and AAAA answers, evaluating the returned IP
// addresses against active parental control policies. If an IP resolves to a
// restricted category (e.g. a known bad IP list, or a category strictly denied),
// it strips that specific IP from the response. If ALL IPs are stripped, the
// query itself morphs into a blocked query.
// Returns true if the entire response was nullified and handled.
func filterResponseIPs(w dns.ResponseWriter, r *dns.Msg, resp *dns.Msg, clientMAC, clientIP string, clientAddr netip.Addr, clientName, protocol, sni, path string) bool {
// Quick bypass: If filtering is disabled or no answers exist, exit.
if !cfg.Server.FilterIPs || len(resp.Answer) == 0 {
return false
}
var keptAnswers []dns.RR
var filteredCount int
var totalIPs int
// Persist the block information in case ALL records are filtered and we need to respond.
var blockCat string
var blockApex string
var blockBlockTTL uint32
for _, rr := range resp.Answer {
var ipStr string
switch rec := rr.(type) {
case *dns.A:
ipStr = rec.A.String()
case *dns.AAAA:
ipStr = rec.AAAA.String()
}
// If the record isn't an A or AAAA, we pass it along safely.
if ipStr == "" {
keptAnswers = append(keptAnswers, rr)
continue
}
totalIPs++
// Execute Parental check utilizing the physical IP string rather than the requested domain.
blocked, blockTTL, _, reason, cat, matchedApex := CheckParental(clientMAC, clientIP, clientAddr, clientName, sni, path, ipStr, true)
if blocked {
filteredCount++
blockCat = cat
blockApex = matchedApex
blockBlockTTL = blockTTL
IncrFilteredIP(ipStr, lowerTrimDot(r.Question[0].Name))
IncrBlockedDomain(lowerTrimDot(r.Question[0].Name), reason+" (IP Filter: "+ipStr+" rule: "+matchedApex+")")
recordRecentBlock(clientIP, lowerTrimDot(r.Question[0].Name), reason+" (IP Filter: "+ipStr+" rule: "+matchedApex+")")
if logQueries {
catStr := ""
if cat != "" {
catStr = fmt.Sprintf(" (Category: %s, rule: %s)", cat, matchedApex)
}
log.Printf("[DNS] [%s] %s -> %s %s | PARENTAL IP FILTERED%s | Removed %s",
protocol, buildClientID(clientIP, clientName, clientAddr),
r.Question[0].Name, dns.TypeToString[r.Question[0].Qtype], catStr, ipStr)
}
} else {
// IP is completely clean, retain it in the slice.
keptAnswers = append(keptAnswers, rr)
}
}
// If we filtered out records, AND those records constituted the entire valid IP pool,
// we must transition to a full DNS Block protocol to properly sinkhole the query.
if filteredCount > 0 && totalIPs > 0 && filteredCount == totalIPs {
IncrParentalBlock()
blockResp := new(dns.Msg)
blockResp.SetReply(r)
q := r.Question[0]
if q.Qtype == dns.TypeA {
blockResp.Answer = append(blockResp.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: blockBlockTTL},
A: net.IPv4zero,
})
} else {
blockResp.SetRcode(r, dns.RcodeNameError)
}
_ = w.WriteMsg(blockResp)
if logQueries {
rcodeStr := "NXDOMAIN"
if q.Qtype == dns.TypeA {
rcodeStr = "NOERROR (NULL-IP)"
}
catStr := ""
if blockCat != "" {
catStr = fmt.Sprintf(" (Category: %s, rule: %s)", blockCat, blockApex)
}
log.Printf("[DNS] [%s] %s -> %s %s | PARENTAL BLOCK IP FILTER%s | %s",
protocol, buildClientID(clientIP, clientName, clientAddr),
r.Question[0].Name, dns.TypeToString[r.Question[0].Qtype], catStr, rcodeStr)
}
return true
}
// Assign the culled down slice back to the response payload to be delivered.
resp.Answer = keptAnswers
return false
}
// ---------------------------------------------------------------------------
// DNS Rebinding Protection (SSRF Prevention)
// ---------------------------------------------------------------------------
// filterRebinding scans A and AAAA records inside an upstream response to ensure
// they do not contain private, loopback, or otherwise non-routable IP addresses (Bogons).
// This hardens local environments against DNS Rebinding attacks, where an attacker
// utilizes a domain they control to resolve back to an internal IP (like 127.0.0.1 or 192.168.1.1),
// exploiting the browser's SOP (Same-Origin Policy).
// Returns true if a bogon was detected and the response was safely neutered into an NXDOMAIN.
func filterRebinding(w dns.ResponseWriter, r *dns.Msg, resp *dns.Msg, clientIP, clientName, protocol string) bool {
// Quick bypass if the security feature is disabled or no payload is present.
if !hasRebindingProtection || len(resp.Answer) == 0 {
return false
}
bogonDetected := false
var blockedIP string
for _, rr := range resp.Answer {
var addr netip.Addr
switch rec := rr.(type) {
case *dns.A:
a, ok := netip.AddrFromSlice(rec.A)
if ok {
addr = a.Unmap()
}
case *dns.AAAA:
a, ok := netip.AddrFromSlice(rec.AAAA)
if ok {
addr = a.Unmap()
}
}
if addr.IsValid() {
// Iterate over our globally compiled netip.Prefix bogon slice.
// This covers everything from Loopbacks to CGNAT and IPv6 Unique Local Addressing.
for _, p := range bogonPrefixes {
if p.Contains(addr) {
bogonDetected = true
blockedIP = addr.String()
break
}
}
}
if bogonDetected {
break
}
}
if bogonDetected {
// Increment specialized rebinding metric counters.
IncrRebindingBlock()
qName := lowerTrimDot(r.Question[0].Name)
IncrBlockedDomain(qName, "DNS Rebinding Protection")
recordRecentBlock(clientIP, qName, "DNS Rebinding Protection")
// Craft the intercept payload. DNS Rebinding must always result in an NXDOMAIN
// to immediately close down browser connections attempting to reach internal targets.
blockResp := new(dns.Msg)
blockResp.SetReply(r)
blockResp.SetRcode(r, dns.RcodeNameError)
_ = w.WriteMsg(blockResp)
// Create a clean clientID using the raw IP and name, as this function is not explicitly passed clientAddr.
// Performance trade-off is negligible since rebinding blocks are exceptionally rare.
var cAddr netip.Addr
if a, err := netip.ParseAddr(clientIP); err == nil {
cAddr = a.Unmap()
}
if logQueries {
log.Printf("[DNS] [%s] %s -> %s %s | BLOCKED (DNS Rebinding/Bogon IP: %s) | NXDOMAIN",
protocol, buildClientID(clientIP, clientName, cAddr),
r.Question[0].Name, dns.TypeToString[r.Question[0].Qtype], blockedIP)
}
return true
}
return false
}