mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
Normalize local usernames using PRECIS
This patch implements local username normalization using PRECIS (https://tools.ietf.org/html/rfc7564, https://tools.ietf.org/html/rfc7613) It makes chasquid accept local email and authentication regardless of the case. It covers both userdb and aliases. Note that non-local usernames remain untouched.
This commit is contained in:
12
chasquid.go
12
chasquid.go
@@ -25,6 +25,7 @@ import (
|
|||||||
"blitiri.com.ar/go/chasquid/internal/config"
|
"blitiri.com.ar/go/chasquid/internal/config"
|
||||||
"blitiri.com.ar/go/chasquid/internal/courier"
|
"blitiri.com.ar/go/chasquid/internal/courier"
|
||||||
"blitiri.com.ar/go/chasquid/internal/envelope"
|
"blitiri.com.ar/go/chasquid/internal/envelope"
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||||
"blitiri.com.ar/go/chasquid/internal/queue"
|
"blitiri.com.ar/go/chasquid/internal/queue"
|
||||||
"blitiri.com.ar/go/chasquid/internal/set"
|
"blitiri.com.ar/go/chasquid/internal/set"
|
||||||
"blitiri.com.ar/go/chasquid/internal/spf"
|
"blitiri.com.ar/go/chasquid/internal/spf"
|
||||||
@@ -738,8 +739,15 @@ func (c *Conn) RCPT(params string) (code int, msg string) {
|
|||||||
return 503, "relay not allowed"
|
return 503, "relay not allowed"
|
||||||
}
|
}
|
||||||
|
|
||||||
if localDst && !c.userExists(addr) {
|
if localDst {
|
||||||
return 550, "recipient unknown, please check the address for typos"
|
addr, err = normalize.Addr(addr)
|
||||||
|
if err != nil {
|
||||||
|
return 550, "recipient invalid, please check the address for typos"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.userExists(addr) {
|
||||||
|
return 550, "recipient unknown, please check the address for typos"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.rcptTo = append(c.rcptTo, addr)
|
c.rcptTo = append(c.rcptTo, addr)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"blitiri.com.ar/go/chasquid/internal/aliases"
|
"blitiri.com.ar/go/chasquid/internal/aliases"
|
||||||
"blitiri.com.ar/go/chasquid/internal/config"
|
"blitiri.com.ar/go/chasquid/internal/config"
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||||
"blitiri.com.ar/go/chasquid/internal/userdb"
|
"blitiri.com.ar/go/chasquid/internal/userdb"
|
||||||
|
|
||||||
"github.com/docopt/docopt-go"
|
"github.com/docopt/docopt-go"
|
||||||
@@ -77,9 +78,14 @@ func AddUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user, err := normalize.User(args["<username>"].(string))
|
||||||
|
if err != nil {
|
||||||
|
Fatalf("Error normalizing user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
password := getPassword()
|
password := getPassword()
|
||||||
|
|
||||||
err = db.AddUser(args["<username>"].(string), password)
|
err = db.AddUser(user, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Fatalf("Error adding user: %v", err)
|
Fatalf("Error adding user: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
// is a tradeoff between flexibility and keeping the file format easy to edit
|
// is a tradeoff between flexibility and keeping the file format easy to edit
|
||||||
// for people.
|
// for people.
|
||||||
//
|
//
|
||||||
|
// User names will be normalized internally to lower-case.
|
||||||
|
//
|
||||||
// Usually there will be one database per domain, and there's no need to
|
// Usually there will be one database per domain, and there's no need to
|
||||||
// include the "@" in the user (in this case, "@" will be forbidden).
|
// include the "@" in the user (in this case, "@" will be forbidden).
|
||||||
//
|
//
|
||||||
@@ -59,6 +61,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"blitiri.com.ar/go/chasquid/internal/envelope"
|
"blitiri.com.ar/go/chasquid/internal/envelope"
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Recipient represents a single recipient, after resolving aliases.
|
// Recipient represents a single recipient, after resolving aliases.
|
||||||
@@ -176,6 +179,7 @@ func (v *Resolver) cleanIfLocal(addr string) string {
|
|||||||
|
|
||||||
user = removeAllAfter(user, v.SuffixSep)
|
user = removeAllAfter(user, v.SuffixSep)
|
||||||
user = removeChars(user, v.DropChars)
|
user = removeChars(user, v.DropChars)
|
||||||
|
user, _ = normalize.User(user)
|
||||||
return user + "@" + domain
|
return user + "@" + domain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +281,7 @@ func parseFile(domain, path string) (map[string][]Recipient, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addr = addr + "@" + domain
|
addr = addr + "@" + domain
|
||||||
|
addr, _ = normalize.Addr(addr)
|
||||||
|
|
||||||
if rawalias[0] == '|' {
|
if rawalias[0] == '|' {
|
||||||
cmd := strings.TrimSpace(rawalias[1:])
|
cmd := strings.TrimSpace(rawalias[1:])
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
|
"golang.org/x/net/idna"
|
||||||
|
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||||
"blitiri.com.ar/go/chasquid/internal/userdb"
|
"blitiri.com.ar/go/chasquid/internal/userdb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,11 +75,15 @@ func DecodeResponse(response string) (user, domain, passwd string, err error) {
|
|||||||
user = idsp[0]
|
user = idsp[0]
|
||||||
domain = idsp[1]
|
domain = idsp[1]
|
||||||
|
|
||||||
// TODO: Quedamos aca. Validar dominio no (solo) como utf8, sino ver que
|
// Normalize the user and domain. This is so users can write the username
|
||||||
// no contenga ni "/" ni "..". Podemos usar golang.org/x/net/idna para
|
// in their own style and still can log in. For the domain, we use IDNA
|
||||||
// convertirlo a unicode primero, o al reves. No se que queremos.
|
// to turn it to utf8 which is what we use internally.
|
||||||
if !utf8.ValidString(user) || !utf8.ValidString(domain) {
|
user, err = normalize.User(user)
|
||||||
err = fmt.Errorf("User/domain is not valid UTF-8")
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domain, err = idna.ToUnicode(domain)
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
internal/normalize/normalize.go
Normal file
31
internal/normalize/normalize.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// Package normalize contains functions to normalize usernames and addresses.
|
||||||
|
package normalize
|
||||||
|
|
||||||
|
import (
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/envelope"
|
||||||
|
"golang.org/x/text/secure/precis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User normalices an username using PRECIS.
|
||||||
|
// On error, it will also return the original username to simplify callers.
|
||||||
|
func User(user string) (string, error) {
|
||||||
|
norm, err := precis.UsernameCaseMapped.String(user)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return norm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name normalices an email address using PRECIS.
|
||||||
|
// On error, it will also return the original address to simplify callers.
|
||||||
|
func Addr(addr string) (string, error) {
|
||||||
|
user, domain := envelope.Split(addr)
|
||||||
|
|
||||||
|
user, err := User(user)
|
||||||
|
if err != nil {
|
||||||
|
return addr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user + "@" + domain, nil
|
||||||
|
}
|
||||||
64
internal/normalize/normalize_test.go
Normal file
64
internal/normalize/normalize_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package normalize
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestUser(t *testing.T) {
|
||||||
|
valid := []struct{ user, norm string }{
|
||||||
|
{"ÑAndÚ", "ñandú"},
|
||||||
|
{"Pingüino", "pingüino"},
|
||||||
|
}
|
||||||
|
for _, c := range valid {
|
||||||
|
nu, err := User(c.user)
|
||||||
|
if nu != c.norm {
|
||||||
|
t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%q error: %v", c.user, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{
|
||||||
|
"á é", "a\te", "x ", "x\xa0y", "x\x85y", "x\vy", "x\fy", "x\ry",
|
||||||
|
"henry\u2163", "\u265a", "\u00b9",
|
||||||
|
}
|
||||||
|
for _, u := range invalid {
|
||||||
|
nu, err := User(u)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected User(%+q) to fail, but did not", u)
|
||||||
|
}
|
||||||
|
if nu != u {
|
||||||
|
t.Errorf("%+q failed norm, but returned %+q", u, nu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddr(t *testing.T) {
|
||||||
|
valid := []struct{ user, norm string }{
|
||||||
|
{"ÑAndÚ@pampa", "ñandú@pampa"},
|
||||||
|
{"Pingüino@patagonia", "pingüino@patagonia"},
|
||||||
|
}
|
||||||
|
for _, c := range valid {
|
||||||
|
nu, err := Addr(c.user)
|
||||||
|
if nu != c.norm {
|
||||||
|
t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%q error: %v", c.user, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{
|
||||||
|
"á é@i", "henry\u2163@throne",
|
||||||
|
}
|
||||||
|
for _, u := range invalid {
|
||||||
|
nu, err := Addr(u)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected Addr(%+q) to fail, but did not", u)
|
||||||
|
}
|
||||||
|
if nu != u {
|
||||||
|
t.Errorf("%+q failed norm, but returned %+q", u, nu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,12 +37,11 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/scrypt"
|
"golang.org/x/crypto/scrypt"
|
||||||
|
|
||||||
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||||
"blitiri.com.ar/go/chasquid/internal/protoio"
|
"blitiri.com.ar/go/chasquid/internal/protoio"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ type DB struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrInvalidUsername = errors.New("username contains invalid characters")
|
ErrInvalidUsername = errors.New("invalid username")
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(fname string) *DB {
|
func New(fname string) *DB {
|
||||||
@@ -107,15 +106,6 @@ func (db *DB) Write() error {
|
|||||||
return protoio.WriteTextMessage(db.fname, db.db, 0660)
|
return protoio.WriteTextMessage(db.fname, db.db, 0660)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Does this user exist in the database?
|
|
||||||
func (db *DB) Exists(user string) bool {
|
|
||||||
db.mu.RLock()
|
|
||||||
_, ok := db.db.Users[user]
|
|
||||||
db.mu.RUnlock()
|
|
||||||
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is this password valid for the user?
|
// Is this password valid for the user?
|
||||||
func (db *DB) Authenticate(name, plainPassword string) bool {
|
func (db *DB) Authenticate(name, plainPassword string) bool {
|
||||||
db.mu.RLock()
|
db.mu.RLock()
|
||||||
@@ -142,23 +132,10 @@ func (p *Password) PasswordMatches(plain string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the given user name is valid.
|
|
||||||
// User names have to be UTF-8, and must not have some particular characters,
|
|
||||||
// including whitespace.
|
|
||||||
func ValidUsername(name string) bool {
|
|
||||||
return utf8.ValidString(name) &&
|
|
||||||
!strings.ContainsAny(name, illegalUsernameChars)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Illegal characters. Only whitespace for now, to prevent/minimize the
|
|
||||||
// chances of parsing issues.
|
|
||||||
// TODO: do we want to stop other characters, specifically about email? Or
|
|
||||||
// keep this generic and handle the mail-specific filtering in chasquid?
|
|
||||||
const illegalUsernameChars = "\t\n\v\f\r \xa0\x85"
|
|
||||||
|
|
||||||
// Add a user to the database. If the user is already present, override it.
|
// Add a user to the database. If the user is already present, override it.
|
||||||
|
// Note we enforce that the name has been normalized previously.
|
||||||
func (db *DB) AddUser(name, plainPassword string) error {
|
func (db *DB) AddUser(name, plainPassword string) error {
|
||||||
if !ValidUsername(name) {
|
if norm, err := normalize.User(name); err != nil || name != norm {
|
||||||
return ErrInvalidUsername
|
return ErrInvalidUsername
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +172,6 @@ func (db *DB) AddUser(name, plainPassword string) error {
|
|||||||
// otherwise.
|
// otherwise.
|
||||||
func (db *DB) RemoveUser(name string) bool {
|
func (db *DB) RemoveUser(name string) bool {
|
||||||
db.mu.Lock()
|
db.mu.Lock()
|
||||||
|
|
||||||
_, present := db.db.Users[name]
|
_, present := db.db.Users[name]
|
||||||
delete(db.db.Users, name)
|
delete(db.db.Users, name)
|
||||||
db.mu.Unlock()
|
db.mu.Unlock()
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func TestWrite(t *testing.T) {
|
|||||||
|
|
||||||
db = mustLoad(t, fname)
|
db = mustLoad(t, fname)
|
||||||
for _, name := range []string{"user1", "ñoño"} {
|
for _, name := range []string{"user1", "ñoño"} {
|
||||||
if !db.Exists(name) {
|
if !db.HasUser(name) {
|
||||||
t.Errorf("user %q not in database", name)
|
t.Errorf("user %q not in database", name)
|
||||||
}
|
}
|
||||||
if db.db.Users[name].GetScheme() == nil {
|
if db.db.Users[name].GetScheme() == nil {
|
||||||
@@ -179,8 +179,17 @@ func TestInvalidUsername(t *testing.T) {
|
|||||||
defer removeIfSuccessful(t, fname)
|
defer removeIfSuccessful(t, fname)
|
||||||
db := mustLoad(t, fname)
|
db := mustLoad(t, fname)
|
||||||
|
|
||||||
|
// Names that are invalid.
|
||||||
names := []string{
|
names := []string{
|
||||||
" ", " ", "a b", "ñ ñ", "a\xa0b", "a\x85b", "a\nb", "a\tb", "a\xffb"}
|
// Contain various types of spaces.
|
||||||
|
" ", " ", "a b", "ñ ñ", "a\xa0b", "a\x85b", "a\nb", "a\tb", "a\xffb",
|
||||||
|
|
||||||
|
// Contain characters not allowed by PRECIS.
|
||||||
|
"\u00b9", "\u2163",
|
||||||
|
|
||||||
|
// Names that are not normalized, but would otherwise be valid.
|
||||||
|
"A", "Ñ",
|
||||||
|
}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
err := db.AddUser(name, "passwd")
|
err := db.AddUser(name, "passwd")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -289,7 +298,7 @@ func TestHasUser(t *testing.T) {
|
|||||||
defer removeIfSuccessful(t, fname)
|
defer removeIfSuccessful(t, fname)
|
||||||
db := mustLoad(t, fname)
|
db := mustLoad(t, fname)
|
||||||
|
|
||||||
if ok := db.HasUser("unknown"); ok {
|
if db.HasUser("unknown") {
|
||||||
t.Errorf("unknown user exists")
|
t.Errorf("unknown user exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,15 +306,15 @@ func TestHasUser(t *testing.T) {
|
|||||||
t.Fatalf("error adding user: %v", err)
|
t.Fatalf("error adding user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok := db.HasUser("unknown"); ok {
|
if db.HasUser("unknown") {
|
||||||
t.Errorf("unknown user exists")
|
t.Errorf("unknown user exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok := db.HasUser("user"); !ok {
|
if !db.HasUser("user") {
|
||||||
t.Errorf("known user does not exist")
|
t.Errorf("known user does not exist")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok := db.HasUser("user"); !ok {
|
if !db.HasUser("user") {
|
||||||
t.Errorf("known user does not exist")
|
t.Errorf("known user does not exist")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user