Skip to content
This repository was archived by the owner on Jul 21, 2021. It is now read-only.

Commit 0e8986c

Browse files
Expand allowedUsers email field to support comma-separated and domains (decke#9)
* Expand allowedUsers email field to support comma-separated and domains Closes decke#8 * Refactor AuthFetch() to return AuthUser struct Also, this breaks out a parseLine() function which can be easily tested. * Ignore empty addrs after splitting commas This ignores a trailing comma * Add tests for auth parseLine() * Update documentation in smtprelay.ini * Fix bug where addrAllowed() was incorrectly case-sensitive * Update allowedUsers allowed domain format to require leading @ This disambiguates a local user ('john.smith') from a domain ('example.com')
1 parent 5c2e28a commit 0e8986c

File tree

5 files changed

+278
-23
lines changed

5 files changed

+278
-23
lines changed

auth.go

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ var (
1313
filename string
1414
)
1515

16+
type AuthUser struct {
17+
username string
18+
passwordHash string
19+
allowedAddresses []string
20+
}
21+
1622
func AuthLoadFile(file string) error {
1723
f, err := os.Open(file)
1824
if err != nil {
@@ -28,50 +34,66 @@ func AuthReady() bool {
2834
return (filename != "")
2935
}
3036

31-
// Returns bcrypt-hash, email
32-
// email can be empty in which case it is not checked
33-
func AuthFetch(username string) (string, string, error) {
37+
// Split a string and ignore empty results
38+
// https://stackoverflow.com/a/46798310/119527
39+
func splitstr(s string, sep rune) []string {
40+
return strings.FieldsFunc(s, func(c rune) bool { return c == sep })
41+
}
42+
43+
func parseLine(line string) *AuthUser {
44+
parts := strings.Fields(line)
45+
46+
if len(parts) < 2 || len(parts) > 3 {
47+
return nil
48+
}
49+
50+
user := AuthUser{
51+
username: parts[0],
52+
passwordHash: parts[1],
53+
allowedAddresses: nil,
54+
}
55+
56+
if len(parts) >= 3 {
57+
user.allowedAddresses = splitstr(parts[2], ',')
58+
}
59+
60+
return &user
61+
}
62+
63+
func AuthFetch(username string) (*AuthUser, error) {
3464
if !AuthReady() {
35-
return "", "", errors.New("Authentication file not specified. Call LoadFile() first")
65+
return nil, errors.New("Authentication file not specified. Call LoadFile() first")
3666
}
3767

3868
file, err := os.Open(filename)
3969
if err != nil {
40-
return "", "", err
70+
return nil, err
4171
}
4272
defer file.Close()
4373

4474
scanner := bufio.NewScanner(file)
4575
for scanner.Scan() {
46-
parts := strings.Fields(scanner.Text())
47-
48-
if len(parts) < 2 || len(parts) > 3 {
76+
user := parseLine(scanner.Text())
77+
if user == nil {
4978
continue
5079
}
5180

52-
if strings.ToLower(username) != strings.ToLower(parts[0]) {
81+
if strings.ToLower(username) != strings.ToLower(user.username) {
5382
continue
5483
}
5584

56-
hash := parts[1]
57-
email := ""
58-
59-
if len(parts) >= 3 {
60-
email = parts[2]
61-
}
62-
63-
return hash, email, nil
85+
return user, nil
6486
}
6587

66-
return "", "", errors.New("User not found")
88+
return nil, errors.New("User not found")
6789
}
6890

6991
func AuthCheckPassword(username string, secret string) error {
70-
hash, _, err := AuthFetch(username)
92+
user, err := AuthFetch(username)
7193
if err != nil {
7294
return err
7395
}
74-
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) == nil {
96+
if bcrypt.CompareHashAndPassword([]byte(user.passwordHash), []byte(secret)) == nil {
7597
return nil
7698
}
7799
return errors.New("Password invalid")

auth_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func stringsEqual(a, b []string) bool {
8+
if len(a) != len(b) {
9+
return false
10+
}
11+
for i, _ := range a {
12+
if a[i] != b[i] {
13+
return false
14+
}
15+
}
16+
return true
17+
}
18+
19+
func TestParseLine(t *testing.T) {
20+
var tests = []struct {
21+
name string
22+
expectFail bool
23+
line string
24+
username string
25+
addrs []string
26+
}{
27+
{
28+
name: "Empty line",
29+
expectFail: true,
30+
line: "",
31+
},
32+
{
33+
name: "Too few fields",
34+
expectFail: true,
35+
line: "joe",
36+
},
37+
{
38+
name: "Too many fields",
39+
expectFail: true,
40+
line: "joe xxx joe@example.com whatsthis",
41+
},
42+
{
43+
name: "Normal case",
44+
line: "joe xxx joe@example.com",
45+
username: "joe",
46+
addrs: []string{"joe@example.com"},
47+
},
48+
{
49+
name: "No allowed addrs given",
50+
line: "joe xxx",
51+
username: "joe",
52+
addrs: []string{},
53+
},
54+
{
55+
name: "Trailing comma",
56+
line: "joe xxx joe@example.com,",
57+
username: "joe",
58+
addrs: []string{"joe@example.com"},
59+
},
60+
{
61+
name: "Multiple allowed addrs",
62+
line: "joe xxx joe@example.com,@foo.example.com",
63+
username: "joe",
64+
addrs: []string{"joe@example.com", "@foo.example.com"},
65+
},
66+
}
67+
68+
for i, test := range tests {
69+
t.Run(test.name, func(t *testing.T) {
70+
user := parseLine(test.line)
71+
if user == nil {
72+
if !test.expectFail {
73+
t.Errorf("parseLine() returned nil unexpectedly")
74+
}
75+
return
76+
}
77+
78+
if user.username != test.username {
79+
t.Errorf("Testcase %d: Incorrect username: expected %v, got %v",
80+
i, test.username, user.username)
81+
}
82+
83+
if !stringsEqual(user.allowedAddresses, test.addrs) {
84+
t.Errorf("Testcase %d: Incorrect addresses: expected %v, got %v",
85+
i, test.addrs, user.allowedAddresses)
86+
}
87+
})
88+
}
89+
}

main.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,58 @@ func connectionChecker(peer smtpd.Peer) error {
3636
return smtpd.Error{Code: 421, Message: "Denied"}
3737
}
3838

39+
func addrAllowed(addr string, allowedAddrs []string) bool {
40+
if allowedAddrs == nil {
41+
// If absent, all addresses are allowed
42+
return true
43+
}
44+
45+
addr = strings.ToLower(addr)
46+
47+
// Extract optional domain part
48+
domain := ""
49+
if idx := strings.LastIndex(addr, "@"); idx != -1 {
50+
domain = strings.ToLower(addr[idx+1:])
51+
}
52+
53+
// Test each address from allowedUsers file
54+
for _, allowedAddr := range allowedAddrs {
55+
allowedAddr = strings.ToLower(allowedAddr)
56+
57+
// Three cases for allowedAddr format:
58+
if idx := strings.Index(allowedAddr, "@"); idx == -1 {
59+
// 1. local address (no @) -- must match exactly
60+
if allowedAddr == addr {
61+
return true
62+
}
63+
} else {
64+
if idx != 0 {
65+
// 2. email address (user@domain.com) -- must match exactly
66+
if allowedAddr == addr {
67+
return true
68+
}
69+
} else {
70+
// 3. domain (@domain.com) -- must match addr domain
71+
allowedDomain := allowedAddr[idx+1:]
72+
if allowedDomain == domain {
73+
return true
74+
}
75+
}
76+
}
77+
}
78+
79+
return false
80+
}
81+
3982
func senderChecker(peer smtpd.Peer, addr string) error {
4083
// check sender address from auth file if user is authenticated
4184
if *allowedUsers != "" && peer.Username != "" {
42-
_, email, err := AuthFetch(peer.Username)
85+
user, err := AuthFetch(peer.Username)
4386
if err != nil {
4487
return smtpd.Error{Code: 451, Message: "Bad sender address"}
4588
}
4689

47-
if email != "" && strings.ToLower(addr) != strings.ToLower(email) {
90+
if !addrAllowed(addr, user.allowedAddresses) {
4891
return smtpd.Error{Code: 451, Message: "Bad sender address"}
4992
}
5093
}

main_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestAddrAllowedNoDomain(t *testing.T) {
8+
allowedAddrs := []string{"joe@abc.com"}
9+
if addrAllowed("bob.com", allowedAddrs) {
10+
t.FailNow()
11+
}
12+
}
13+
14+
func TestAddrAllowedSingle(t *testing.T) {
15+
allowedAddrs := []string{"joe@abc.com"}
16+
17+
if !addrAllowed("joe@abc.com", allowedAddrs) {
18+
t.FailNow()
19+
}
20+
if addrAllowed("bob@abc.com", allowedAddrs) {
21+
t.FailNow()
22+
}
23+
}
24+
25+
func TestAddrAllowedDifferentCase(t *testing.T) {
26+
allowedAddrs := []string{"joe@abc.com"}
27+
testAddrs := []string{
28+
"joe@ABC.com",
29+
"Joe@abc.com",
30+
"JOE@abc.com",
31+
"JOE@ABC.COM",
32+
}
33+
for _, addr := range testAddrs {
34+
if !addrAllowed(addr, allowedAddrs) {
35+
t.Errorf("Address %v not allowed, but should be", addr)
36+
}
37+
}
38+
}
39+
40+
func TestAddrAllowedLocal(t *testing.T) {
41+
allowedAddrs := []string{"joe"}
42+
43+
if !addrAllowed("joe", allowedAddrs) {
44+
t.FailNow()
45+
}
46+
if addrAllowed("bob", allowedAddrs) {
47+
t.FailNow()
48+
}
49+
}
50+
51+
func TestAddrAllowedMulti(t *testing.T) {
52+
allowedAddrs := []string{"joe@abc.com", "bob@def.com"}
53+
if !addrAllowed("joe@abc.com", allowedAddrs) {
54+
t.FailNow()
55+
}
56+
if !addrAllowed("bob@def.com", allowedAddrs) {
57+
t.FailNow()
58+
}
59+
if addrAllowed("bob@abc.com", allowedAddrs) {
60+
t.FailNow()
61+
}
62+
}
63+
64+
func TestAddrAllowedSingleDomain(t *testing.T) {
65+
allowedAddrs := []string{"@abc.com"}
66+
if !addrAllowed("joe@abc.com", allowedAddrs) {
67+
t.FailNow()
68+
}
69+
if addrAllowed("joe@def.com", allowedAddrs) {
70+
t.FailNow()
71+
}
72+
}
73+
74+
func TestAddrAllowedMixed(t *testing.T) {
75+
allowedAddrs := []string{"app", "app@example.com", "@appsrv.example.com"}
76+
if !addrAllowed("app", allowedAddrs) {
77+
t.FailNow()
78+
}
79+
if !addrAllowed("app@example.com", allowedAddrs) {
80+
t.FailNow()
81+
}
82+
if addrAllowed("ceo@example.com", allowedAddrs) {
83+
t.FailNow()
84+
}
85+
if !addrAllowed("root@appsrv.example.com", allowedAddrs) {
86+
t.FailNow()
87+
}
88+
if !addrAllowed("dev@appsrv.example.com", allowedAddrs) {
89+
t.FailNow()
90+
}
91+
if addrAllowed("appsrv@example.com", allowedAddrs) {
92+
t.FailNow()
93+
}
94+
}

smtprelay.ini

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@
3737

3838
; File which contains username and password used for
3939
; authentication before they can send mail.
40-
; File format: username bcrypt-hash [email]
40+
; File format: username bcrypt-hash [email[,email[,...]]]
41+
; username: The SMTP auth username
42+
; bcrypt-hash: The bcrypt hash of the pasword (generate with "./hasher password")
43+
; email: Comma-separated list of allowed "from" addresses:
44+
; - If omitted, user can send from any address
45+
; - If @domain.com is given, user can send from any address @domain.com
46+
; - Otherwise, email address must match exactly (case-insensitive)
47+
; E.g. "app@example.com,@appsrv.example.com"
4148
;allowed_users =
4249

4350
; Relay all mails to this SMTP server

0 commit comments

Comments
 (0)