This repository was archived by the owner on Sep 2, 2025. It is now read-only.
forked from zaccone/spf
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathspf_filter.go
More file actions
197 lines (176 loc) · 4.92 KB
/
spf_filter.go
File metadata and controls
197 lines (176 loc) · 4.92 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
package spf
import (
"strings"
"unicode/utf8"
)
// IsSPFCandidate checks if a string matches the pattern: [WS* v] WS* (=|:) WS* spf (case-insensitive)
// where WS* represents zero or more whitespace characters, and the 'v' part is optional.
func IsSPFCandidate(s string) bool {
// States in our tokenizer
const (
StateInit = iota // Initial scanning state
StateV // After whitespace
StateSep // After 'v'
StateSPF // After separator
)
i := 0
state := StateInit
minRequired := 4 // Need at least 4 characters: separator + "spf"
if len(s) < minRequired {
return false
}
length := len(s) - minRequired
for i < len(s) {
switch state {
case StateInit:
// Fast-forward to the next interesting character
next := strings.IndexAny(s[i:], "=:vV \t")
if next == -1 {
// No interesting characters found
return false
}
// Move to the position of the interesting character
i += next
r, size := utf8.DecodeRuneInString(s[i:])
if r == ' ' || r == '\t' {
state = StateV
} else if r == '=' || r == ':' {
state = StateSPF
} else if r == 'v' || r == 'V' {
state = StateSep
}
i += size
case StateV:
i = skipWhitespace(s, i, length)
if i < 0 {
return false
}
r, size := utf8.DecodeRuneInString(s[i:])
if r == '=' || r == ':' {
i += size
state = StateSPF
} else if r == 'v' || r == 'V' {
i += size
state = StateSep
} else {
// Not matching, return to scanning
i += size
state = StateInit
}
case StateSep:
i = skipWhitespace(s, i, length)
if i < 0 {
return false
}
r, size := utf8.DecodeRuneInString(s[i:])
if r == '=' || r == ':' {
i += size
state = StateSPF
} else {
// Not a separator after 'v'
i += size
state = StateInit
}
case StateSPF:
i = skipWhitespace(s, i, length)
if i < 0 {
return false
}
if hasPrefixFold(s[i:], "spf") {
return true
}
// Not "spf", back to scanning
_, size := utf8.DecodeRuneInString(s[i:])
i += size
state = StateInit
}
}
return false
}
// skipWhitespace advances the index past any whitespace characters
// and returns the new index position. If the index exceeds maxIdx,
// returns -1 to signal that there's not enough space left to match.
func skipWhitespace(s string, start, maxIdx int) int {
i := start
maxIdx = min(maxIdx, len(s))
for i < maxIdx && (s[i] == ' ' || s[i] == '\t') {
i++
}
if i > maxIdx {
return -1
}
return i
}
// hasPrefixFold checks if s starts with "spf" (case-insensitive)
func hasPrefixFold(s string, prefix string) bool {
// Simply check if we have enough bytes
if len(s) < len(prefix) {
return false
}
// Compare the first len(prefix) bytes
return strings.EqualFold(s[:len(prefix)], prefix)
}
// HasSPFPrefix checks if a given string represents a valid SPF record according to RFC 7208.
//
// The function verifies that:
// 1. The string begins with exactly "v=spf1" (the SPF version identifier)
// 2. The version identifier is either the entire string or is followed by whitespace
//
// Parameters:
// - s: The string to check, typically a DNS TXT record content
//
// Returns:
// - bool: true if the string is a valid SPF record, false otherwise
//
// Examples:
// - "v=spf1 include:_spf.example.com ~all" -> true (valid SPF record)
// - "v=spf1" -> true (minimal valid SPF record)
// - "v=spf10 include:example.com" -> false (incorrect version)
// - "txt=something" -> false (not an SPF record)
// - "v=spf1something" -> false (no space after version)
func HasSPFPrefix(s string) bool {
const (
v = "v=spf1"
vLen = 6
)
if len(s) < vLen {
return false
}
if len(s) == vLen {
return s == v
}
if s[vLen] != ' ' && s[vLen] != '\t' {
return false
}
return strings.HasPrefix(s, v)
}
// FilterSPFCandidates filters a slice of strings and returns two separate slices:
// 1. candidates - strings that match the SPF pattern but aren't confirmed valid SPF records
// 2. policies - strings that are confirmed valid SPF records per RFC 7208
//
// The function first checks if each line is an SPF candidate using IsSPFCandidate.
// Then it further categorizes the matches:
// - If the line is a valid SPF record (starts with "v=spf1" followed by space or EOL),
// it's added to the policies slice.
// - Otherwise, it's added to the candidates slice, which contains potential SPF records
// that don't strictly follow the RFC format.
//
// Parameters:
// - lines: A slice of strings to filter, typically DNS TXT records
//
// Returns:
// - candidates: Strings that match SPF pattern but aren't strictly valid per RFC 7208
// - policies: Strings that are valid SPF records per RFC 7208
func FilterSPFCandidates(lines []string) (candidates, policies []string) {
for _, line := range lines {
if !IsSPFCandidate(line) {
continue
}
if HasSPFPrefix(line) {
policies = append(policies, line)
} else {
candidates = append(candidates, line)
}
}
return
}