smtp2shoutrrr/config_test.go
butterrobot 7565618efd
Some checks failed
CI / goreleaser-lint (push) Successful in 3s
CI / format (push) Successful in 1m47s
CI / lint (push) Successful in 2m7s
CI / test (push) Successful in 13m1s
CI / build (push) Successful in 12m51s
Release / release (push) Failing after 1m7s
bugfix: fix SMTP authentication bypass, remote DoS, and silent delivery failures (#9) (FMG-3)
Three defects in the SMTP backend, all predating the FMG-2 dependency upgrade.

## Authentication bypass

The credential check joined its two comparisons with `&&`, so it only rejected a client that got *both* halves wrong — a correct username with any password authenticated, as did a correct password with any username. Both comparisons now have to pass, and both are evaluated before either is acted on so the timing does not distinguish a wrong username from a wrong password. They compare SHA-256 digests through `subtle.ConstantTimeCompare`, which keeps the length of the configured credential out of the timing as well.

Fixing the comparison alone does not close the hole. go-smtp advertises `AUTH` but leaves enforcement to the backend, so a client could skip `AUTH` entirely and still have its message forwarded. The session now tracks authentication and refuses `MAIL`, `RCPT` and `DATA` without it.

Nor does the gate mean anything while the credentials it checks can be guessed. `SetDefaults` invented `Username = "username"` and `Password = "password"` when the config omitted them, with only a `slog.Warn` — so a `config.toml` naming nothing but a port and its recipients relayed to anyone who tried the obvious pair, for exactly the deployments that had configured the least. `Validate` now rejects unset credentials the way it already rejects an unusable target, and the invented defaults are gone.

**Behavior change:** a client that used to send without issuing `AUTH` now has to be configured with the credentials from `config.toml`, and a deployment that never set them will not start until it does. Both documented in the README.

## Remote denial of service

A malformed `Content-Type` reached `log.Fatalf`, which calls `os.Exit(1)`. One message with an attacker-controlled header terminated the daemon and took every other in-flight message with it. `Body()` now returns the parse error like the rest of the function does.

## Silent notification loss

`Data` logged forwarding errors and returned `250`, so a client whose notification reached no target at all treated the message as delivered and never retried. The reply now reflects what happened:

- `250` — at least one target accepted it. A *partial* failure is logged but still accepted, since a retry redelivers to every target and would duplicate the notification on the ones that already have it. This keeps the existing `TestPartialTargetFailure` contract.
- `451` — no target accepted it. Worth retrying.
- `550 5.6.0` — the message cannot be parsed. No retry can fix the same bytes.
- `550 5.3.5` — the recipient has no notification targets configured. A configuration fault; no retry can add targets. `LoadConfig` blocks such a config, so this is only reachable from one built in code.

## Session transaction state

`Session.Reset` was empty while go-smtp calls it after every message, so recipients accumulated across a connection and the second message on it was also forwarded to the first one's targets.

Fixing that introduced a data race, caught in review: `Reset` runs on the connection goroutine, but go-smtp runs `Data` on its own goroutine for a `BDAT` transfer and does not block `RSET` during one. A mutex now guards the recipients and the authentication flag across `Rcpt`, `Data` and `Reset`, and `Data` snapshots the recipients up front instead of reading the slice while forwarding.

## Validation

`make format`, `make ci-lint` (0 issues), `make test` (`-race`, 94.2% coverage), `make check` and `make build` all pass locally.

Every regression test was confirmed to fail against the code it guards:

- the six original ones against `origin/main` — the malformed `Content-Type` one by killing the test binary outright, which is the reported DoS;
- `TestResetDuringChunkedTransferIsSynchronized` against `da8e8e4`, where `-race` reports `Session.Reset` racing `Session.Data` on all three runs attempted;
- `TestLoadConfigRejectsUnsetCredentials` against `da8e8e4`'s `config.go`.

## Known gaps, not addressed here

- `Body()` returns `("", nil)` for a `multipart/*` with an unusable boundary and for any media type that is neither `multipart/` nor `text/`, so an empty notification is pushed to every target and answered `250`.
- `forwardEmail` breaks after the first matching `[[Recipients]]` entry, so a message addressed to two matching entries notifies only the first and still answers `250`. Pre-existing.
- A message for an address matching no entry, with no `[CatchAll]`, is accepted with `250` and dropped. Rejecting at `RCPT` would be more honest.
- `smtp.ErrAuthRequired` is go-smtp's `502 5.7.0` where RFC 4954 §6 specifies `530 5.7.0`. Left on the library's own constant.

Closes FMG-3

Co-authored-by: Full-Stack Developer <me@fmartingr.com>
Reviewed-on: #9
Reviewed-by: Felipe M <me@fmartingr.com>
2026-09-07 22:27:04 +02:00

193 lines
5.4 KiB
Go

package smtp2shoutrrr
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// credentials is prepended to fixtures that are not themselves about
// authentication, so each one still fails (or passes) for the reason it names
// rather than for the credentials it does not mention.
const credentials = `
Username = "user"
Password = "secret"
`
func writeConfig(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
return path
}
func TestLoadConfig(t *testing.T) {
path := writeConfig(t, `
Port = 2525
Username = "user"
Password = "secret"
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
[[Recipients]]
Addresses = ["legacy@example.com"]
Target = "ntfy://ntfy.sh/legacy"
[CatchAll]
Targets = ["ntfy://ntfy.sh/catch-all"]
`)
config, err := LoadConfig(path)
require.NoError(t, err)
require.Equal(t, 2525, config.Port)
require.Equal(t, "user", config.Username)
require.Equal(t, "secret", config.Password)
require.Len(t, config.Recipients, 2)
require.Equal(t, []string{"user@example.com"}, config.Recipients[0].Addresses)
require.Equal(t, []string{"ntfy://ntfy.sh/topic"}, config.Recipients[0].Targets)
require.Equal(t, "ntfy://ntfy.sh/legacy", config.Recipients[1].Target)
require.NotNil(t, config.CatchAll)
require.Equal(t, []string{"ntfy://ntfy.sh/catch-all"}, config.CatchAll.Targets)
}
func TestLoadConfigAppliesDefaults(t *testing.T) {
path := writeConfig(t, credentials+`
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
`)
config, err := LoadConfig(path)
require.NoError(t, err)
require.Equal(t, 11125, config.Port)
require.Nil(t, config.CatchAll)
// Credentials deliberately have no default; see
// TestLoadConfigRejectsUnsetCredentials.
require.Equal(t, "user", config.Username)
require.Equal(t, "secret", config.Password)
}
// Regression: unset credentials used to fall back to username/password with
// only a warning, so a config naming nothing but a port and its recipients
// left the AUTH gate open to the most obvious guess there is — and the README
// promised an authentication that had never been configured.
func TestLoadConfigRejectsUnsetCredentials(t *testing.T) {
recipient := `
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
`
for name, contents := range map[string]string{
"neither credential": recipient,
"no Username": `Password = "secret"` + recipient,
"no Password": `Username = "user"` + recipient,
"empty Username": `Username = ""` + "\n" + `Password = "secret"` + recipient,
"empty Password": `Username = "user"` + "\n" + `Password = ""` + recipient,
} {
t.Run(name, func(t *testing.T) {
config, err := LoadConfig(writeConfig(t, contents))
require.Error(t, err)
require.Nil(t, config)
})
}
}
func TestLoadConfigMissingFile(t *testing.T) {
_, err := LoadConfig(filepath.Join(t.TempDir(), "does-not-exist.toml"))
require.Error(t, err)
}
func TestLoadConfigInvalidTOML(t *testing.T) {
_, err := LoadConfig(writeConfig(t, "Port = "))
require.Error(t, err)
}
func TestLoadConfigRejectsUnusableConfigurations(t *testing.T) {
// A TOML decode ignores unknown keys, so each of these parses cleanly and
// would otherwise start a server that forwards nothing.
for name, contents := range map[string]string{
"mistyped recipients table": `
[[Recipient]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
`,
"mistyped addresses key": `
[[Recipients]]
Adresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
`,
"recipient without targets": `
[[Recipients]]
Addresses = ["user@example.com"]
`,
"recipient with only unparseable targets": `
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["://invalid-url"]
`,
"recipient whose target is not a URL at all": `
[[Recipients]]
Addresses = ["user@example.com"]
Targets = ["this is not a url at all"]
`,
"catch-all without targets": `
[CatchAll]
Addresses = ["user@example.com"]
`,
"empty configuration": `
Port = 2525
`,
} {
t.Run(name, func(t *testing.T) {
_, err := LoadConfig(writeConfig(t, credentials+contents))
require.Error(t, err)
})
}
}
func TestLoadConfigAcceptsCatchAllOnly(t *testing.T) {
config, err := LoadConfig(writeConfig(t, credentials+`
[CatchAll]
Targets = ["ntfy://ntfy.sh/catch-all"]
`))
require.NoError(t, err)
require.Empty(t, config.Recipients)
require.NotNil(t, config.CatchAll)
}
// A mistyped table name is silently dropped by the decoder. It is only fatal
// when nothing else is configured, so with a catch-all present the warning is
// the sole signal that the block was ignored.
func TestLoadConfigWarnsAboutIgnoredKeys(t *testing.T) {
var logged bytes.Buffer
restore := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelWarn})))
t.Cleanup(func() { slog.SetDefault(restore) })
config, err := LoadConfig(writeConfig(t, credentials+`
[[Recipient]]
Addresses = ["user@example.com"]
Targets = ["ntfy://ntfy.sh/topic"]
[CatchAll]
Targets = ["ntfy://ntfy.sh/catch-all"]
`))
require.NoError(t, err)
require.Empty(t, config.Recipients, "the mistyped table is still ignored")
require.Contains(t, logged.String(), "ignoring unknown key in config file")
require.Contains(t, logged.String(), "Recipient")
}