Files
upstream-ory-kratos/courier/smtp_wire_test.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

217 lines
6.6 KiB
Go

// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package courier_test
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/courier/template"
templates "github.com/ory/kratos/courier/template/email"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/x/configx"
"github.com/ory/x/sqlxx"
)
// smtpRecorder captures the raw SMTP commands and DATA lines a client sends.
type smtpRecorder struct {
mu sync.Mutex
cmds []string
data []string
}
func (r *smtpRecorder) snapshot() (cmds, data []string) {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.cmds...), append([]string(nil), r.data...)
}
// newRecordingSMTPServer starts a minimal SMTP server that accepts everything
// and records the raw protocol. It returns a courier SMTP connection URI.
func newRecordingSMTPServer(t *testing.T) (string, *smtpRecorder) {
t.Helper()
rec := &smtpRecorder{}
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = l.Close() })
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer func() { _ = conn.Close() }()
_, _ = fmt.Fprintf(conn, "220 fake ESMTP\r\n")
sc := bufio.NewScanner(conn)
inData := false
for sc.Scan() {
line := sc.Text()
if inData {
if line == "." {
inData = false
_, _ = fmt.Fprintf(conn, "250 ok\r\n")
continue
}
rec.mu.Lock()
rec.data = append(rec.data, line)
rec.mu.Unlock()
continue
}
rec.mu.Lock()
rec.cmds = append(rec.cmds, line)
rec.mu.Unlock()
switch verb := strings.ToUpper(line); {
case strings.HasPrefix(verb, "EHLO"), strings.HasPrefix(verb, "HELO"):
_, _ = fmt.Fprintf(conn, "250-fake\r\n250 8BITMIME\r\n")
case strings.HasPrefix(verb, "DATA"):
inData = true
_, _ = fmt.Fprintf(conn, "354 go ahead\r\n")
case strings.HasPrefix(verb, "QUIT"):
_, _ = fmt.Fprintf(conn, "221 bye\r\n")
return
default:
_, _ = fmt.Fprintf(conn, "250 ok\r\n")
}
}
}(conn)
}
}()
return fmt.Sprintf("smtp://%s/?disable_starttls=true", l.Addr().String()), rec
}
func rcptLines(cmds []string) []string {
var out []string
for _, c := range cmds {
if strings.HasPrefix(strings.ToUpper(c), "RCPT TO:") {
out = append(out, c)
}
}
return out
}
func headerLines(data []string, prefix string) []string {
var out []string
for _, d := range data {
if d == "" {
break // End of headers.
}
if strings.HasPrefix(d, prefix) {
out = append(out, d)
}
}
return out
}
func newWireCourier(t *testing.T, smtpURL string, extra map[string]any) (courier.Courier, *config.Config) {
t.Helper()
values := map[string]any{
config.ViperKeyCourierSMTPURL: smtpURL,
config.ViperKeyCourierSMTPFrom: "from@ory.sh",
config.ViperKeyCourierMessageRetries: 5,
config.ViperKeyClientSMTPNoPrivateIPRanges: false,
}
for k, v := range extra {
values[k] = v
}
conf, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(values))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
c.FailOnDispatchError()
return c, conf
}
func TestDispatchSMTPWireProtocol(t *testing.T) {
t.Run("case=rfc recipient keeps Cc and Bcc header recipients in the envelope", func(t *testing.T) {
smtpURL, rec := newRecordingSMTPServer(t)
c, _ := newWireCourier(t, smtpURL, map[string]any{
config.ViperKeyCourierSMTPHeaders + ".Bcc": "archive@ory.sh",
})
_, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "user@example.org", Subject: "s", Body: "b",
}))
require.NoError(t, err)
require.NoError(t, c.DispatchQueue(t.Context()))
require.EventuallyWithT(t, func(t *assert.CollectT) {
cmds, _ := rec.snapshot()
rcpts := rcptLines(cmds)
assert.Contains(t, rcpts, "RCPT TO:<user@example.org>")
assert.Contains(t, rcpts, "RCPT TO:<archive@ory.sh>")
}, 5*time.Second, 50*time.Millisecond)
})
t.Run("case=non-rfc recipient produces exactly one RCPT and one To header", func(t *testing.T) {
smtpURL, rec := newRecordingSMTPServer(t)
c, _ := newWireCourier(t, smtpURL, nil)
_, err := c.QueueEmail(t.Context(), templates.NewTestStub(&templates.TestStubModel{
To: "foo.@docomo.ne.jp", Subject: "s", Body: "b",
}))
require.NoError(t, err)
require.NoError(t, c.DispatchQueue(t.Context()))
require.EventuallyWithT(t, func(t *assert.CollectT) {
cmds, data := rec.snapshot()
assert.Equal(t, []string{"RCPT TO:<foo.@docomo.ne.jp>"}, rcptLines(cmds))
assert.Equal(t, []string{"To: foo.@docomo.ne.jp"}, headerLines(data, "To:"))
}, 5*time.Second, 50*time.Millisecond)
})
t.Run("case=poisoned recipient is abandoned at dispatch, nothing hits the wire", func(t *testing.T) {
smtpURL, rec := newRecordingSMTPServer(t)
_, reg := pkg.NewRegistryDefaultWithDSN(t, "", configx.WithValues(map[string]any{
config.ViperKeyCourierSMTPURL: smtpURL,
config.ViperKeyCourierSMTPFrom: "from@ory.sh",
config.ViperKeyCourierMessageRetries: 5,
config.ViperKeyClientSMTPNoPrivateIPRanges: false,
}))
c, err := reg.Courier(t.Context())
require.NoError(t, err)
// Insert a row with a smuggled header directly, bypassing QueueEmail's
// validation, to simulate a poisoned message from another path.
require.NoError(t, reg.CourierPersister().AddMessage(t.Context(), &courier.Message{
Type: courier.MessageTypeEmail,
Recipient: "victim@example.org\r\nBcc: attacker@evil.com",
Body: "b",
Subject: "s",
TemplateType: template.TypeTestStub,
Channel: sqlxx.NullString("email"),
}))
// Dispatch the message directly. DispatchMessage (unlike DispatchQueue)
// does not reset a failed message back to "queued", so the abandonment
// is observable.
queued, err := reg.CourierPersister().LatestQueuedMessage(t.Context())
require.NoError(t, err)
require.Error(t, c.DispatchMessage(t.Context(), *queued))
var got courier.Message
require.NoError(t, reg.Persister().GetConnection(t.Context()).Where("id = ?", queued.ID).First(&got))
assert.Equal(t, courier.MessageStatusAbandoned, got.Status)
cmds, data := rec.snapshot()
assert.Empty(t, rcptLines(cmds), "poisoned recipient must not produce a RCPT")
assert.Empty(t, headerLines(data, "Bcc:"), "poisoned recipient must not smuggle a Bcc header")
for _, line := range data {
assert.NotContains(t, line, "attacker@evil.com", "attacker address must never reach the wire")
}
})
}