Skip to content

Commit d5b3a63

Browse files
committed
authres: parser rework
Rewrite of the Authentication-Results header parser for a complete RFC 7601 implementation. Ignore any header comments in parenthesis. Allow escape sequences and semi-colons in comments and quoted strings as values. Fixes: #32
1 parent 5a36d97 commit d5b3a63

File tree

2 files changed

+238
-38
lines changed

2 files changed

+238
-38
lines changed

authres/parse.go

+178-38
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package authres
22

33
import (
4+
"bufio"
45
"errors"
6+
"io"
57
"strings"
68
"unicode"
79
)
@@ -222,58 +224,92 @@ var results = map[string]newResultFunc{
222224
// Parse parses the provided Authentication-Results header field. It returns the
223225
// authentication service identifier and authentication results.
224226
func Parse(v string) (identifier string, results []Result, err error) {
225-
parts := strings.Split(v, ";")
227+
p := newParser(v)
226228

227-
identifier = strings.TrimSpace(parts[0])
228-
i := strings.IndexFunc(identifier, unicode.IsSpace)
229-
if i > 0 {
230-
version := strings.TrimSpace(identifier[i:])
231-
if version != "1" {
232-
return "", nil, errors.New("msgauth: unsupported version")
233-
}
229+
identifier, err = p.getIndentifier()
230+
if err != nil {
231+
return identifier, nil, err
232+
}
234233

235-
identifier = identifier[:i]
234+
for {
235+
result, err := p.getResult()
236+
if result == nil {
237+
break
238+
}
239+
results = append(results, result)
240+
if err == io.EOF {
241+
break
242+
} else if err != nil {
243+
return identifier, results, err
244+
}
236245
}
237246

238-
for i := 1; i < len(parts); i++ {
239-
s := strings.TrimSpace(parts[i])
240-
if s == "" {
247+
return identifier, results, nil
248+
}
249+
250+
type parser struct {
251+
r *bufio.Reader
252+
}
253+
254+
func newParser(v string) *parser {
255+
return &parser{r: bufio.NewReader(strings.NewReader(v))}
256+
}
257+
258+
// getIdentifier parses the authserv-id of the authres header and checks the
259+
// version id when present. Ignore header comments in parenthesis.
260+
func (p *parser) getIndentifier() (identifier string, err error) {
261+
for {
262+
c, err := p.r.ReadByte()
263+
if err == io.EOF {
264+
return identifier, nil
265+
} else if err != nil {
266+
return identifier, err
267+
}
268+
if c == '(' {
269+
p.r.UnreadByte()
270+
p.readComment()
241271
continue
242272
}
243-
244-
result, err := parseResult(s)
245-
if err != nil {
246-
return identifier, results, err
273+
if c == ';' {
274+
break
247275
}
248-
if result != nil {
249-
results = append(results, result)
276+
identifier += string(c)
277+
}
278+
279+
fields := strings.Fields(identifier)
280+
if len(fields) > 1 {
281+
version := strings.TrimSpace(fields[1])
282+
if version != "1" {
283+
return "", errors.New("msgauth: unknown version")
250284
}
251285
}
252-
return
286+
return strings.TrimSpace(fields[0]), nil
253287
}
254288

255-
func parseResult(s string) (Result, error) {
256-
// TODO: ignore header comments in parenthesis
257-
258-
parts := strings.Fields(s)
259-
if len(parts) == 0 || parts[0] == "none" {
289+
// getResults parses the authentication part of the authres header and returns
290+
// a Result struct. Ignore header comments in parenthesis.
291+
func (p *parser) getResult() (result Result, err error) {
292+
method, resultvalue, err := p.keyValue()
293+
if method == "none" {
260294
return nil, nil
261295
}
262-
263-
k, v, err := parseParam(parts[0])
264296
if err != nil {
265297
return nil, err
266298
}
267-
method, value := k, ResultValue(strings.ToLower(v))
299+
value := ResultValue(strings.ToLower(resultvalue))
268300

269301
params := make(map[string]string)
270-
for i := 1; i < len(parts); i++ {
271-
k, v, err := parseParam(parts[i])
272-
if err != nil {
273-
continue
302+
var k, v string
303+
for {
304+
k, v, err = p.keyValue()
305+
if k != "" {
306+
params[k] = v
307+
}
308+
if err == io.EOF {
309+
break
310+
} else if err != nil {
311+
return nil, err
274312
}
275-
276-
params[k] = v
277313
}
278314

279315
newResult, ok := results[method]
@@ -293,10 +329,114 @@ func parseResult(s string) (Result, error) {
293329
return r, nil
294330
}
295331

296-
func parseParam(s string) (k string, v string, err error) {
297-
kv := strings.SplitN(s, "=", 2)
298-
if len(kv) != 2 {
299-
return "", "", errors.New("msgauth: malformed authentication method and value")
332+
// keyValue parses a sequence of key=value parameters
333+
func (p *parser) keyValue() (k, v string, err error) {
334+
k, err = p.readKey()
335+
if err != nil {
336+
return
337+
}
338+
v, err = p.readValue()
339+
if err != nil {
340+
return
300341
}
301-
return strings.ToLower(strings.TrimSpace(kv[0])), strings.TrimSpace(kv[1]), nil
342+
return
343+
}
344+
345+
// readKey reads a method, reason or ptype.property as defined in RFC 7601
346+
// Section 2.2. Ignore the method-version of the methodspec. Stop at EOF or the
347+
// equal sign.
348+
func (p *parser) readKey() (k string, err error) {
349+
var c byte
350+
for err != io.EOF {
351+
c, err = p.r.ReadByte()
352+
if err != nil {
353+
break
354+
}
355+
switch c {
356+
case ';':
357+
err = io.EOF
358+
break
359+
case '=':
360+
break
361+
case '(':
362+
p.r.UnreadByte()
363+
_, err = p.readComment()
364+
continue
365+
case '/':
366+
p.r.ReadBytes('=')
367+
p.r.UnreadByte()
368+
default:
369+
if !unicode.IsSpace(rune(c)) {
370+
k += string(c)
371+
}
372+
}
373+
if c == '=' {
374+
break
375+
}
376+
}
377+
k = strings.TrimSpace(strings.ToLower(k))
378+
return
379+
}
380+
381+
// readValue reads a result or value as defined in RFC 7601 Section 2.2. Value
382+
// is defined as either a token or quoted string according to RFC 2045 Section
383+
// 5.1. Stop at EOF, white space or semi-colons.
384+
func (p *parser) readValue() (v string, err error) {
385+
var c byte
386+
for err != io.EOF {
387+
c, err = p.r.ReadByte()
388+
if err != nil {
389+
break
390+
}
391+
switch c {
392+
case ';':
393+
err = io.EOF
394+
break
395+
case '(':
396+
p.r.UnreadByte()
397+
_, err = p.readComment()
398+
continue
399+
case '"':
400+
v, err = p.r.ReadString(c)
401+
v = strings.TrimSuffix(v, string(c))
402+
default:
403+
if !unicode.IsSpace(rune(c)) {
404+
v += string(c)
405+
}
406+
}
407+
if unicode.IsSpace(rune(c)) {
408+
if v != "" {
409+
break
410+
}
411+
}
412+
}
413+
v = strings.TrimSpace(v)
414+
return
415+
}
416+
417+
func (p *parser) readComment() (comment string, err error) {
418+
count := 0
419+
var c byte
420+
for {
421+
c, err = p.r.ReadByte()
422+
if err != nil {
423+
break
424+
}
425+
switch c {
426+
case '\\':
427+
c, _ = p.r.ReadByte()
428+
comment += "\\" + string(c)
429+
case '(':
430+
count++
431+
case ')':
432+
count--
433+
default:
434+
comment += string(c)
435+
}
436+
if count == 0 {
437+
break
438+
}
439+
}
440+
comment = strings.TrimSpace(comment)
441+
return
302442
}

authres/parse_test.go

+60
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ var parseTests = []msgauthTest{
2424
&SPFResult{Value: ResultPass, From: "example.net"},
2525
},
2626
},
27+
{
28+
value: "example.com;" +
29+
"dkim=pass reason=\"good signature\" [email protected];",
30+
identifier: "example.com",
31+
results: []Result{
32+
&DKIMResult{Value: ResultPass, Reason: "good signature", Identifier: "@mail-router.example.net"},
33+
},
34+
},
35+
{
36+
value: "example.com;" +
37+
"dkim=pass reason=\"good; signature\" [email protected];",
38+
identifier: "example.com",
39+
results: []Result{
40+
&DKIMResult{Value: ResultPass, Reason: "good; signature", Identifier: "@mail-router.example.net"},
41+
},
42+
},
2743
{
2844
value: "example.com;" +
2945
" auth=pass (cram-md5) [email protected];",
@@ -32,6 +48,50 @@ var parseTests = []msgauthTest{
3248
&AuthResult{Value: ResultPass, Auth: "[email protected]"},
3349
},
3450
},
51+
{
52+
value: "example.com;" +
53+
" auth=pass (cram-md5) [email protected];" +
54+
" spf=pass smtp.mailfrom=example.net",
55+
identifier: "example.com",
56+
results: []Result{
57+
&AuthResult{Value: ResultPass, Auth: "[email protected]"},
58+
&SPFResult{Value: ResultPass, From: "example.net"},
59+
},
60+
},
61+
{
62+
value: "example.com;" +
63+
" auth=pass (cram-md5 (comment inside comment)) [email protected];",
64+
identifier: "example.com",
65+
results: []Result{
66+
&AuthResult{Value: ResultPass, Auth: "[email protected]"},
67+
},
68+
},
69+
{
70+
value: "example.com;" +
71+
" auth=pass (cram-md5; comment with semicolon) [email protected];",
72+
identifier: "example.com",
73+
results: []Result{
74+
&AuthResult{Value: ResultPass, Auth: "[email protected]"},
75+
},
76+
},
77+
{
78+
value: "example.com;" +
79+
" auth=pass (cram-md5 \\( comment with escaped char) [email protected];",
80+
identifier: "example.com",
81+
results: []Result{
82+
&AuthResult{Value: ResultPass, Auth: "[email protected]"},
83+
},
84+
},
85+
{
86+
value: "foo.example.net (foobar) 1 (baz);" +
87+
" dkim (Because I like it) / 1 (One yay) = (wait for it) fail" +
88+
" policy (A dot can go here) . (like that) expired" +
89+
" (this surprised me) = (as I wasn't expecting it) 1362471462",
90+
identifier: "foo.example.net",
91+
results: []Result{
92+
&DKIMResult{Value: ResultFail, Reason: "", Domain: "", Identifier: ""},
93+
},
94+
},
3595
}
3696

3797
func TestParse(t *testing.T) {

0 commit comments

Comments
 (0)