Compare commits

..

12 commits

Author SHA1 Message Date
2bde374d2c
Merge pull request #313 from wneessen/dependabot/github_actions/codecov/codecov-action-4.6.0
Bump codecov/codecov-action from 4.5.0 to 4.6.0
2024-10-02 15:57:23 +02:00
97ad132965
Merge pull request #314 from wneessen/dependabot/github_actions/golang/govulncheck-action-1.0.4
Bump golang/govulncheck-action from 1.0.3 to 1.0.4
2024-10-02 15:57:09 +02:00
b7fa04e0cb
Merge pull request #315 from wneessen/fix-github-actions
Fix GitHub actions
2024-10-02 15:55:11 +02:00
0c3bf239f1
Add support channel information for Gophers Slack
Updated the README file to include our new support and general discussion channel on the Gophers Slack. Users can now find us on both Discord and Slack for any queries or discussions related to go-mail.
2024-10-02 15:54:34 +02:00
cbba4d83d1
Add SCRAM authentication to CI workflows
This commit introduces SCRAM authentication configurations to both `codecov.yml` and `sonarqube.yml` GitHub Action workflow files. The changes include new environment variables for SCRAM host, user, and password to enhance the security and flexibility of the CI processes.
2024-10-02 15:51:56 +02:00
dependabot[bot]
3f3b21348f
Bump golang/govulncheck-action from 1.0.3 to 1.0.4
Bumps [golang/govulncheck-action](https://github.com/golang/govulncheck-action) from 1.0.3 to 1.0.4.
- [Release notes](https://github.com/golang/govulncheck-action/releases)
- [Commits](dd0578b371...b625fbe08f)

---
updated-dependencies:
- dependency-name: golang/govulncheck-action
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 13:45:37 +00:00
dependabot[bot]
e037df43a7
Bump codecov/codecov-action from 4.5.0 to 4.6.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](e28ff129e5...b9fd7d16f6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-02 13:45:34 +00:00
c8e45477bb
Merge pull request #312 from wneessen/bug/311_smtp-auth-login-should-follow-ietf-draft-more-closely
Enhance SMTP LOGIN auth and add comprehensive tests
2024-10-02 14:18:38 +02:00
761e205049
Fix client connection test error handling
Changed variable assignment in the test to fix error handling. This ensures the error is properly caught and reported during the client connection process.
2024-10-02 13:10:10 +02:00
9d70283af9
Reset response step in AUTH LOGIN initialization
The addition of `a.respStep = 0` resets the response step counter at the beginning of the AUTH LOGIN process. This ensures that the state starts correctly and avoids potential issues related to residual values from previous authentications.
2024-10-02 13:09:55 +02:00
93752280aa
Update smtp_test.go to add more authentication test cases
Enhanced the LoginAuth test coverage by adding new scenarios with different sequences and invalid cases. This ensures more robust validation and better handling of edge cases in authentication testing.
2024-10-02 12:54:32 +02:00
547f78dbee
Enhance SMTP LOGIN auth and add comprehensive tests
Refactored SMTP LOGIN auth to improve compatibility with various server responses, consolidating error handling and response steps. Added extensive tests to verify successful and failed authentication across different server configurations.
2024-10-02 12:37:54 +02:00
7 changed files with 223 additions and 45 deletions

View file

@ -27,6 +27,10 @@ env:
TEST_SMTPAUTH_USER: ${{ secrets.TEST_USER }} TEST_SMTPAUTH_USER: ${{ secrets.TEST_USER }}
TEST_SMTPAUTH_PASS: ${{ secrets.TEST_PASS }} TEST_SMTPAUTH_PASS: ${{ secrets.TEST_PASS }}
TEST_SMTPAUTH_TYPE: "LOGIN" TEST_SMTPAUTH_TYPE: "LOGIN"
TEST_ONLINE_SCRAM: "1"
TEST_HOST_SCRAM: ${{ secrets.TEST_HOST_SCRAM }}
TEST_USER_SCRAM: ${{ secrets.TEST_USER_SCRAM }}
TEST_PASS_SCRAM: ${{ secrets.TEST_USER_SCRAM }}
permissions: permissions:
contents: read contents: read
@ -58,6 +62,6 @@ jobs:
go test -v -race --coverprofile=coverage.coverprofile --covermode=atomic ./... go test -v -race --coverprofile=coverage.coverprofile --covermode=atomic ./...
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
if: success() && matrix.go == '1.23' && matrix.os == 'ubuntu-latest' if: success() && matrix.go == '1.23' && matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos

View file

@ -18,4 +18,4 @@ jobs:
with: with:
egress-policy: audit egress-policy: audit
- name: Run govulncheck - name: Run govulncheck
uses: golang/govulncheck-action@dd0578b371c987f96d1185abb54344b44352bd58 # v1.0.3 uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4

View file

@ -21,6 +21,10 @@ env:
TEST_SMTPAUTH_USER: ${{ secrets.TEST_USER }} TEST_SMTPAUTH_USER: ${{ secrets.TEST_USER }}
TEST_SMTPAUTH_PASS: ${{ secrets.TEST_PASS }} TEST_SMTPAUTH_PASS: ${{ secrets.TEST_PASS }}
TEST_SMTPAUTH_TYPE: "LOGIN" TEST_SMTPAUTH_TYPE: "LOGIN"
TEST_ONLINE_SCRAM: "1"
TEST_HOST_SCRAM: ${{ secrets.TEST_HOST_SCRAM }}
TEST_USER_SCRAM: ${{ secrets.TEST_USER_SCRAM }}
TEST_PASS_SCRAM: ${{ secrets.TEST_USER_SCRAM }}
jobs: jobs:
build: build:
name: Build name: Build

View file

@ -77,7 +77,8 @@ We guarantee that go-mail will always support the last four releases of Go. With
the user a timeframe of two years to update to the next or even the latest version of Go. the user a timeframe of two years to update to the next or even the latest version of Go.
## Support ## Support
We have a support and general discussion channel on Discord. Find us at: [#go-mail](https://discord.gg/dbfQyC4s) We have a support and general discussion channel on Discord. Find us at: [#go-mail](https://discord.gg/dbfQyC4s) alternatively find us
on the [Gophers Slack](https://gophers.slack.com) in #go-mail
## Middleware ## Middleware
The goal of go-mail is to keep it free from 3rd party dependencies and only focus on things a mail library should The goal of go-mail is to keep it free from 3rd party dependencies and only focus on things a mail library should

View file

@ -620,7 +620,7 @@ func TestClient_DialWithContext(t *testing.T) {
t.Skipf("failed to create test client: %s. Skipping tests", err) t.Skipf("failed to create test client: %s. Skipping tests", err)
} }
ctx := context.Background() ctx := context.Background()
if err := c.DialWithContext(ctx); err != nil { if err = c.DialWithContext(ctx); err != nil {
t.Errorf("failed to dial with context: %s", err) t.Errorf("failed to dial with context: %s", err)
return return
} }
@ -1872,6 +1872,119 @@ func TestClient_AuthSCRAMSHAX(t *testing.T) {
} }
} }
func TestClient_AuthLoginSuccess(t *testing.T) {
tests := []struct {
name string
featureSet string
}{
{"default", "250-AUTH LOGIN\r\n250-X-DEFAULT-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"},
{"mox server", "250-AUTH LOGIN\r\n250-X-MOX-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"},
{"null byte", "250-AUTH LOGIN\r\n250-X-NULLBYTE-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"},
{"bogus responses", "250-AUTH LOGIN\r\n250-X-BOGUS-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"},
{"empty responses", "250-AUTH LOGIN\r\n250-X-EMPTY-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"},
}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serverPort := TestServerPortBase + 40 + i
go func() {
if err := simpleSMTPServer(ctx, tt.featureSet, true, serverPort); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 300)
client, err := NewClient(TestServerAddr,
WithPort(serverPort),
WithTLSPortPolicy(NoTLS),
WithSMTPAuth(SMTPAuthLogin),
WithUsername("toni@tester.com"),
WithPassword("V3ryS3cr3t+"))
if err != nil {
t.Errorf("unable to create new client: %s", err)
return
}
if err = client.DialWithContext(context.Background()); err != nil {
t.Errorf("failed to dial to test server: %s", err)
}
if err = client.Close(); err != nil {
t.Errorf("failed to close server connection: %s", err)
}
})
}
}
func TestClient_AuthLoginFail(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serverPort := TestServerPortBase + 50
featureSet := "250-AUTH LOGIN\r\n250-X-DEFAULT-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 300)
client, err := NewClient(TestServerAddr,
WithPort(serverPort),
WithTLSPortPolicy(NoTLS),
WithSMTPAuth(SMTPAuthLogin),
WithUsername("toni@tester.com"),
WithPassword("InvalidPassword"))
if err != nil {
t.Errorf("unable to create new client: %s", err)
return
}
if err = client.DialWithContext(context.Background()); err == nil {
t.Error("expected to fail to dial to test server, but it succeeded")
}
}
func TestClient_AuthLoginFail_noTLS(t *testing.T) {
if os.Getenv("TEST_SKIP_ONLINE") != "" {
t.Skipf("env variable TEST_SKIP_ONLINE is set. Skipping online tests")
}
th := os.Getenv("TEST_HOST")
if th == "" {
t.Skipf("no host set. Skipping online tests")
}
tp := 587
if tps := os.Getenv("TEST_PORT"); tps != "" {
tpi, err := strconv.Atoi(tps)
if err == nil {
tp = tpi
}
}
client, err := NewClient(th, WithPort(tp), WithSMTPAuth(SMTPAuthLogin), WithTLSPolicy(NoTLS))
if err != nil {
t.Errorf("failed to create new client: %s", err)
}
u := os.Getenv("TEST_SMTPAUTH_USER")
if u != "" {
client.SetUsername(u)
}
p := os.Getenv("TEST_SMTPAUTH_PASS")
if p != "" {
client.SetPassword(p)
}
// We don't want to log authentication data in tests
client.SetDebugLog(false)
if err = client.DialWithContext(context.Background()); err == nil {
t.Error("expected to fail to dial to test server, but it succeeded")
}
if !errors.Is(err, smtp.ErrUnencrypted) {
t.Errorf("expected error to be %s, but got %s", smtp.ErrUnencrypted, err)
}
}
func TestClient_AuthSCRAMSHAX_fail(t *testing.T) { func TestClient_AuthSCRAMSHAX_fail(t *testing.T) {
if os.Getenv("TEST_ONLINE_SCRAM") == "" { if os.Getenv("TEST_ONLINE_SCRAM") == "" {
t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests")
@ -2491,6 +2604,51 @@ func handleTestServerConnection(connection net.Conn, featureSet string, failRese
break break
} }
_ = writeLine("235 2.7.0 Authentication successful") _ = writeLine("235 2.7.0 Authentication successful")
case strings.HasPrefix(data, "AUTH LOGIN"):
var username, password string
userResp := "VXNlcm5hbWU6"
passResp := "UGFzc3dvcmQ6"
if strings.Contains(featureSet, "250-X-MOX-LOGIN") {
userResp = ""
passResp = "UGFzc3dvcmQ="
}
if strings.Contains(featureSet, "250-X-NULLBYTE-LOGIN") {
userResp = "VXNlciBuYW1lAA=="
passResp = "UGFzc3dvcmQA"
}
if strings.Contains(featureSet, "250-X-BOGUS-LOGIN") {
userResp = "Qm9ndXM="
passResp = "Qm9ndXM="
}
if strings.Contains(featureSet, "250-X-EMPTY-LOGIN") {
userResp = ""
passResp = ""
}
_ = writeLine("334 " + userResp)
ddata, derr := reader.ReadString('\n')
if derr != nil {
fmt.Printf("failed to read username data from connection: %s\n", derr)
break
}
ddata = strings.TrimSpace(ddata)
username = ddata
_ = writeLine("334 " + passResp)
ddata, derr = reader.ReadString('\n')
if derr != nil {
fmt.Printf("failed to read password data from connection: %s\n", derr)
break
}
ddata = strings.TrimSpace(ddata)
password = ddata
if !strings.EqualFold(username, "dG9uaUB0ZXN0ZXIuY29t") ||
!strings.EqualFold(password, "VjNyeVMzY3IzdCs=") {
_ = writeLine("535 5.7.8 Error: authentication failed")
break
}
_ = writeLine("235 2.7.0 Authentication successful")
case strings.EqualFold(data, "DATA"): case strings.EqualFold(data, "DATA"):
_ = writeLine("354 End data with <CR><LF>.<CR><LF>") _ = writeLine("354 End data with <CR><LF>.<CR><LF>")
for { for {

View file

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: Copyright (c) 2022-2023 The go-mail Authors // SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
// //
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
@ -9,57 +9,42 @@ import (
"fmt" "fmt"
) )
// ErrUnencrypted is an error indicating that the connection is not encrypted.
var ErrUnencrypted = errors.New("unencrypted connection")
// loginAuth is the type that satisfies the Auth interface for the "SMTP LOGIN" auth // loginAuth is the type that satisfies the Auth interface for the "SMTP LOGIN" auth
type loginAuth struct { type loginAuth struct {
username, password string username, password string
host string host string
respStep uint8
} }
const (
// LoginXUsernameChallenge represents the Username Challenge response sent by the SMTP server per the AUTH LOGIN
// extension.
//
// See: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-xlogin/.
LoginXUsernameChallenge = "Username:"
LoginXUsernameLowerChallenge = "username:"
// LoginXPasswordChallenge represents the Password Challenge response sent by the SMTP server per the AUTH LOGIN
// extension.
//
// See: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-xlogin/.
LoginXPasswordChallenge = "Password:"
LoginXPasswordLowerChallenge = "password:"
// LoginXDraftUsernameChallenge represents the Username Challenge response sent by the SMTP server per the IETF
// draft AUTH LOGIN extension. It should be noted this extension is an expired draft which was never formally
// published and was deprecated in favor of the AUTH PLAIN extension.
//
// See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00.
LoginXDraftUsernameChallenge = "User Name\x00"
// LoginXDraftPasswordChallenge represents the Password Challenge response sent by the SMTP server per the IETF
// draft AUTH LOGIN extension. It should be noted this extension is an expired draft which was never formally
// published and was deprecated in favor of the AUTH PLAIN extension.
//
// See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00.
LoginXDraftPasswordChallenge = "Password\x00"
)
// LoginAuth returns an [Auth] that implements the LOGIN authentication // LoginAuth returns an [Auth] that implements the LOGIN authentication
// mechanism as it is used by MS Outlook. The Auth works similar to PLAIN // mechanism as it is used by MS Outlook. The Auth works similar to PLAIN
// but instead of sending all in one response, the login is handled within // but instead of sending all in one response, the login is handled within
// 3 steps: // 3 steps:
// - Sending AUTH LOGIN (server responds with "Username:") // - Sending AUTH LOGIN (server might responds with "Username:")
// - Sending the username (server responds with "Password:") // - Sending the username (server might responds with "Password:")
// - Sending the password (server authenticates) // - Sending the password (server authenticates)
// This is the common approach as specified by Microsoft in their MS-XLOGIN spec.
// See: https://msopenspecs.azureedge.net/files/MS-XLOGIN/%5bMS-XLOGIN%5d.pdf
// Yet, there is also an old IETF draft for SMTP AUTH LOGIN that states for clients:
// "The contents of both challenges SHOULD be ignored.".
// See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
// Since there is no official standard RFC and we've seen different implementations
// of this mechanism (sending "Username:", "Username", "username", "User name", etc.)
// we follow the IETF-Draft and ignore any server challange to allow compatiblity
// with most mail servers/providers.
// //
// LoginAuth will only send the credentials if the connection is using TLS // LoginAuth will only send the credentials if the connection is using TLS
// or is connected to localhost. Otherwise authentication will fail with an // or is connected to localhost. Otherwise authentication will fail with an
// error, without sending the credentials. // error, without sending the credentials.
func LoginAuth(username, password, host string) Auth { func LoginAuth(username, password, host string) Auth {
return &loginAuth{username, password, host} return &loginAuth{username, password, host, 0}
} }
// Start begins the SMTP authentication process by validating server's TLS status and hostname.
// Returns "LOGIN" on success.
func (a *loginAuth) Start(server *ServerInfo) (string, []byte, error) { func (a *loginAuth) Start(server *ServerInfo) (string, []byte, error) {
// Must have TLS, or else localhost server. // Must have TLS, or else localhost server.
// Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo.
@ -67,20 +52,25 @@ func (a *loginAuth) Start(server *ServerInfo) (string, []byte, error) {
// That might just be the attacker saying // That might just be the attacker saying
// "it's ok, you can trust me with your password." // "it's ok, you can trust me with your password."
if !server.TLS && !isLocalhost(server.Name) { if !server.TLS && !isLocalhost(server.Name) {
return "", nil, errors.New("unencrypted connection") return "", nil, ErrUnencrypted
} }
if server.Name != a.host { if server.Name != a.host {
return "", nil, errors.New("wrong host name") return "", nil, errors.New("wrong host name")
} }
a.respStep = 0
return "LOGIN", nil, nil return "LOGIN", nil, nil
} }
// Next processes responses from the server during the SMTP authentication exchange, sending the
// username and password.
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more { if more {
switch string(fromServer) { switch a.respStep {
case LoginXUsernameChallenge, LoginXUsernameLowerChallenge, LoginXDraftUsernameChallenge: case 0:
a.respStep++
return []byte(a.username), nil return []byte(a.username), nil
case LoginXPasswordChallenge, LoginXPasswordLowerChallenge, LoginXDraftPasswordChallenge: case 1:
a.respStep++
return []byte(a.password), nil return []byte(a.password), nil
default: default:
return nil, fmt.Errorf("unexpected server response: %s", string(fromServer)) return nil, fmt.Errorf("unexpected server response: %s", string(fromServer))

View file

@ -57,10 +57,31 @@ var authTests = []authTest{
}, },
{ {
LoginAuth("user", "pass", "testserver"), LoginAuth("user", "pass", "testserver"),
[]string{"Username:", "Password:", "User Name\x00", "Password\x00", "Invalid:"}, []string{"Username:", "Password:"},
"LOGIN", "LOGIN",
[]string{"", "user", "pass", "user", "pass", ""}, []string{"", "user", "pass"},
[]bool{false, false, false, false, true}, []bool{false, false},
},
{
LoginAuth("user", "pass", "testserver"),
[]string{"User Name\x00", "Password\x00"},
"LOGIN",
[]string{"", "user", "pass"},
[]bool{false, false},
},
{
LoginAuth("user", "pass", "testserver"),
[]string{"Invalid", "Invalid:"},
"LOGIN",
[]string{"", "user", "pass"},
[]bool{false, false},
},
{
LoginAuth("user", "pass", "testserver"),
[]string{"Invalid", "Invalid:", "Too many"},
"LOGIN",
[]string{"", "user", "pass", ""},
[]bool{false, false, true},
}, },
{ {
CRAMMD5Auth("user", "pass"), CRAMMD5Auth("user", "pass"),