summaryrefslogtreecommitdiffstats
path: root/internal/submit
diff options
context:
space:
mode:
authorGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
committerGab Virebent <gabriel1@virebent.art>2026-08-24 17:34:45 +0200
commite9fbbe3373eb66a345f5e3829e2563b94dc92051 (patch)
tree950d75fda88574afb46b4aeab36e96f3c089a3bb /internal/submit
parentfb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff)
downloadn2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.gz
n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.xz
n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.zip
Harden transport and preserve profile identitiesHEADmain
Diffstat (limited to 'internal/submit')
-rw-r--r--internal/submit/message.go14
-rw-r--r--internal/submit/message_test.go41
-rw-r--r--internal/submit/types.go91
-rw-r--r--internal/submit/types_test.go125
-rw-r--r--internal/submit/validation_test.go2
5 files changed, 257 insertions, 16 deletions
diff --git a/internal/submit/message.go b/internal/submit/message.go
index 3bf8834..db80503 100644
--- a/internal/submit/message.go
+++ b/internal/submit/message.go
@@ -17,14 +17,24 @@ import (
var messageIDRE = regexp.MustCompile(`^<[^<>\s]+@[^<>\s]+>$`)
-func BuildMessage(sub Submission, recipient, messageIDDomain, identiconsCLI string, requireFace bool) (raw string, messageID string, err error) {
+func BuildMessage(sub Submission, recipient, sender, messageIDDomain, identiconsCLI string, requireFace bool) (raw string, messageID string, err error) {
messageID, err = newMessageID(messageIDDomain)
if err != nil {
return "", "", err
}
+ identity, err := mail.ParseAddress(sub.From)
+ if err != nil {
+ return "", "", fmt.Errorf("invalid identity address")
+ }
+ transportSender, err := mail.ParseAddress(sender)
+ if err != nil || transportSender.Address == "" {
+ return "", "", fmt.Errorf("invalid transport Sender address")
+ }
+
headers := []string{
- "From: " + sub.From,
+ "From: " + identity.String(),
+ "Sender: " + transportSender.String(),
"To: " + recipient,
"Subject: " + sub.Subject,
"Message-ID: " + messageID,
diff --git a/internal/submit/message_test.go b/internal/submit/message_test.go
new file mode 100644
index 0000000..c13e958
--- /dev/null
+++ b/internal/submit/message_test.go
@@ -0,0 +1,41 @@
+package submit
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildMessagePublishesProfileIdentityAndTransportSender(t *testing.T) {
+ sub := Submission{
+ From: "Alice <alice@example.invalid>",
+ Newsgroups: []string{"misc.test"},
+ Subject: "Test",
+ Message: "hello",
+ PublicKeyB64: "unused-without-face",
+ SignatureB64: "unused-without-face",
+ }
+
+ raw, _, err := BuildMessage(
+ sub,
+ "mail2news@mail2news.tcpreset.net",
+ "n2usenet@virebent.art",
+ "n2usenet.virebent.art",
+ "/missing/identicons-cli",
+ false,
+ )
+ if err != nil {
+ t.Fatalf("BuildMessage returned error: %v", err)
+ }
+ if !strings.Contains(raw, "From: \"Alice\" <alice@example.invalid>\r\n") {
+ t.Fatalf("profile identity is not used as the public From:\n%s", raw)
+ }
+ if !strings.Contains(raw, "Sender: <n2usenet@virebent.art>\r\n") {
+ t.Fatalf("transport Sender is missing:\n%s", raw)
+ }
+ if strings.Contains(raw, "From: \"Alice\" <n2usenet@virebent.art>\r\n") {
+ t.Fatalf("transport account replaced the public profile identity:\n%s", raw)
+ }
+ if strings.Contains(raw, "X-N2Usenet-Identity:") {
+ t.Fatalf("redundant private identity header is present:\n%s", raw)
+ }
+}
diff --git a/internal/submit/types.go b/internal/submit/types.go
index 52f57b4..66727b2 100644
--- a/internal/submit/types.go
+++ b/internal/submit/types.go
@@ -10,6 +10,7 @@ import (
"fmt"
"html/template"
"io/fs"
+ "log"
"mime"
"net"
"net/http"
@@ -27,12 +28,19 @@ type Mailer interface {
Send(ctx context.Context, msg smtpclient.Message) error
}
+type TransportState interface {
+ Ready() bool
+ MarkSuccess()
+ MarkFailure(error)
+}
+
type App struct {
cfg config.Config
mailer Mailer
replay *storage.ReplayCache
staticFS fs.FS
indexTmpl *template.Template
+ transport TransportState
csrfKey []byte
rateKey []byte
rateMu sync.Mutex
@@ -51,11 +59,16 @@ type IndexData struct {
PublicBaseURL string
}
-func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) {
- csrfKey := make([]byte, 32)
+func NewApp(cfg config.Config, mailer Mailer, transport TransportState, replay *storage.ReplayCache, staticFS fs.FS, indexTemplate string) (*App, error) {
+ csrfKey := append([]byte(nil), cfg.Security.CSRFKey...)
+ if len(csrfKey) == 0 {
+ csrfKey = make([]byte, 32)
+ }
rateKey := make([]byte, 32)
- if _, err := rand.Read(csrfKey); err != nil {
- return nil, fmt.Errorf("csrf key: %w", err)
+ if len(cfg.Security.CSRFKey) == 0 {
+ if _, err := rand.Read(csrfKey); err != nil {
+ return nil, fmt.Errorf("csrf key: %w", err)
+ }
}
if _, err := rand.Read(rateKey); err != nil {
return nil, fmt.Errorf("rate key: %w", err)
@@ -65,7 +78,7 @@ func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, stati
return nil, fmt.Errorf("parse index template: %w", err)
}
return &App{
- cfg: cfg, mailer: mailer, replay: replay, staticFS: staticFS,
+ cfg: cfg, mailer: mailer, transport: transport, replay: replay, staticFS: staticFS,
indexTmpl: tmpl, csrfKey: csrfKey, rateKey: rateKey,
rates: map[string]rateBucket{}, locks: map[string]struct{}{},
}, nil
@@ -74,7 +87,9 @@ func NewApp(cfg config.Config, mailer Mailer, replay *storage.ReplayCache, stati
func (a *App) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", a.handleIndex)
+ mux.HandleFunc("GET /csrf", a.handleCSRF)
mux.HandleFunc("GET /healthz", a.handleHealth)
+ mux.HandleFunc("GET /readyz", a.handleReadiness)
mux.HandleFunc("GET /favicon.ico", a.handleFavicon)
mux.HandleFunc("POST /identity/face", a.handleFace)
mux.HandleFunc("POST /identicon.php", a.handleIdenticonCompat)
@@ -145,19 +160,49 @@ func (a *App) handleIdenticonCompat(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) {
+ token := a.issueCSRFToken(w, r)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = a.indexTmpl.Execute(w, IndexData{CSRFToken: token, PublicBaseURL: a.cfg.PublicBaseURL})
+}
+
+func (a *App) handleCSRF(w http.ResponseWriter, r *http.Request) {
+ token := a.issueCSRFToken(w, r)
+ setNoStoreHeaders(w)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{"token": token})
+}
+
+func (a *App) issueCSRFToken(w http.ResponseWriter, r *http.Request) string {
+ if token, ok := a.csrfTokenFromCookie(r); ok {
+ return token
+ }
token := randomHex(32)
http.SetCookie(w, &http.Cookie{
Name: "m2u_csrf",
Value: a.signCSRF(token),
Path: "/",
- MaxAge: 7200,
Secure: a.cfg.Security.SecureCookies,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Header().Set("Cache-Control", "no-store")
- _ = a.indexTmpl.Execute(w, IndexData{CSRFToken: token, PublicBaseURL: a.cfg.PublicBaseURL})
+ return token
+}
+
+func (a *App) csrfTokenFromCookie(r *http.Request) (string, bool) {
+ cookie, err := r.Cookie("m2u_csrf")
+ if err != nil {
+ return "", false
+ }
+ dot := strings.LastIndexByte(cookie.Value, '.')
+ if dot <= 0 {
+ return "", false
+ }
+ token := cookie.Value[:dot]
+ if !hmac.Equal([]byte(cookie.Value), []byte(a.signCSRF(token))) {
+ return "", false
+ }
+ return token, true
}
func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -166,6 +211,18 @@ func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok\n"))
}
+func (a *App) handleReadiness(w http.ResponseWriter, _ *http.Request) {
+ setNoStoreHeaders(w)
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ if a.transport == nil || !a.transport.Ready() {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ _, _ = w.Write([]byte("transport unavailable\n"))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ready\n"))
+}
+
func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
if !a.allowRate(r) {
a.errorResponse(w, r, http.StatusTooManyRequests, "Too many requests. Try again later.")
@@ -204,15 +261,22 @@ func (a *App) handleSubmit(w http.ResponseWriter, r *http.Request) {
return
}
- raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace)
+ raw, messageID, err := BuildMessage(sub, a.cfg.SMTP.Recipient, a.cfg.SMTP.Sender, a.cfg.Security.MessageIDDomain, a.cfg.Security.IdenticonsCLI, a.cfg.Security.RequireFace)
if err != nil {
a.errorResponse(w, r, http.StatusInternalServerError, "Message build failed.")
return
}
if err := a.mailer.Send(r.Context(), smtpclient.Message{EnvelopeFrom: sub.FromAddress, Raw: raw}); err != nil {
+ if a.transport != nil {
+ a.transport.MarkFailure(err)
+ }
+ log.Printf("submission delivery failed message_id=%s: %v", messageID, err)
a.errorResponse(w, r, http.StatusBadGateway, "Delivery failed.")
return
}
+ if a.transport != nil {
+ a.transport.MarkSuccess()
+ }
a.successResponse(w, r, messageID)
}
@@ -321,7 +385,10 @@ func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID
if wantsJSON(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- _ = json.NewEncoder(w).Encode(map[string]string{"messageId": messageID})
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "messageId": messageID,
+ "status": "accepted",
+ })
return
}
a.successHTML(w, messageID)
@@ -330,7 +397,7 @@ func (a *App) successResponse(w http.ResponseWriter, r *http.Request, messageID
func (a *App) successHTML(w http.ResponseWriter, messageID string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
- _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message sent</title><main><h1>Message sent</h1><p><strong>Message-ID:</strong></p><pre>%s</pre><p><a href=\"/\">Send another</a></p></main>", template.HTMLEscapeString(messageID))
+ _, _ = fmt.Fprintf(w, "<!doctype html><meta charset=utf-8><title>Message accepted</title><main><h1>Message accepted by the mail relay</h1><p>Final Usenet delivery is asynchronous.</p><p><strong>Message-ID:</strong></p><pre>%s</pre><p><a href=\"/\">Send another</a></p></main>", template.HTMLEscapeString(messageID))
}
func randomHex(n int) string {
diff --git a/internal/submit/types_test.go b/internal/submit/types_test.go
index a2a7c1a..16a2b84 100644
--- a/internal/submit/types_test.go
+++ b/internal/submit/types_test.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
"testing/fstest"
@@ -15,13 +16,135 @@ import (
func newTestApp(t *testing.T) *App {
t.Helper()
- app, err := NewApp(config.Config{}, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
+ app, err := NewApp(config.Config{}, nil, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
if err != nil {
t.Fatalf("NewApp returned error: %v", err)
}
return app
}
+func TestCSRFCookieHasNoElapsedTimeLimit(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+
+ app.handleIndex(rec, req)
+
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("unexpected cookie count: got %d want 1", len(cookies))
+ }
+ cookie := cookies[0]
+ if cookie.Name != "m2u_csrf" {
+ t.Fatalf("unexpected cookie name: %q", cookie.Name)
+ }
+ if cookie.MaxAge != 0 || !cookie.Expires.IsZero() {
+ t.Fatalf("CSRF cookie has an elapsed-time expiry: MaxAge=%d Expires=%v", cookie.MaxAge, cookie.Expires)
+ }
+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
+ t.Fatal("CSRF cookie security attributes were weakened")
+ }
+}
+
+func TestCSRFRefreshIssuesValidToken(t *testing.T) {
+ app := newTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, "/csrf", nil)
+ rec := httptest.NewRecorder()
+
+ app.handleCSRF(rec, req)
+
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
+ t.Fatalf("unexpected Cache-Control: %q", got)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode CSRF response: %v", err)
+ }
+ token := body["token"]
+ if token == "" {
+ t.Fatal("CSRF refresh returned an empty token")
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("unexpected cookie count: got %d want 1", len(cookies))
+ }
+
+ form := url.Values{"csrf_token": {token}}
+ post := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
+ post.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ post.AddCookie(cookies[0])
+ if err := post.ParseForm(); err != nil {
+ t.Fatalf("parse form: %v", err)
+ }
+ if !app.verifyCSRF(post) {
+ t.Fatal("refreshed CSRF token did not verify")
+ }
+}
+
+func TestCSRFCookieSurvivesRestartWithPersistentKey(t *testing.T) {
+ cfg := config.Config{Security: config.SecurityConfig{CSRFKey: []byte(strings.Repeat("k", 32))}}
+ newApp := func() *App {
+ app, err := NewApp(cfg, nil, nil, storage.NewReplayCache(time.Minute), fstest.MapFS{}, "<!doctype html>")
+ if err != nil {
+ t.Fatalf("NewApp returned error: %v", err)
+ }
+ return app
+ }
+
+ first := httptest.NewRecorder()
+ newApp().handleCSRF(first, httptest.NewRequest(http.MethodGet, "/csrf", nil))
+ var firstBody map[string]string
+ if err := json.Unmarshal(first.Body.Bytes(), &firstBody); err != nil {
+ t.Fatalf("decode first CSRF response: %v", err)
+ }
+ cookies := first.Result().Cookies()
+ if len(cookies) != 1 {
+ t.Fatalf("unexpected cookie count: %d", len(cookies))
+ }
+
+ requestAfterRestart := httptest.NewRequest(http.MethodGet, "/csrf", nil)
+ requestAfterRestart.AddCookie(cookies[0])
+ second := httptest.NewRecorder()
+ newApp().handleCSRF(second, requestAfterRestart)
+ var secondBody map[string]string
+ if err := json.Unmarshal(second.Body.Bytes(), &secondBody); err != nil {
+ t.Fatalf("decode second CSRF response: %v", err)
+ }
+ if secondBody["token"] != firstBody["token"] {
+ t.Fatal("restart rotated a still-valid session token")
+ }
+ if got := len(second.Result().Cookies()); got != 0 {
+ t.Fatalf("valid session unexpectedly rotated %d cookies", got)
+ }
+}
+
+type transportStateStub struct {
+ ready bool
+}
+
+func (s *transportStateStub) Ready() bool { return s.ready }
+func (s *transportStateStub) MarkSuccess() { s.ready = true }
+func (s *transportStateStub) MarkFailure(error) { s.ready = false }
+
+func TestReadinessUsesCachedTransportState(t *testing.T) {
+ app := newTestApp(t)
+ state := &transportStateStub{}
+ app.transport = state
+
+ recorder := httptest.NewRecorder()
+ app.handleReadiness(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if recorder.Code != http.StatusServiceUnavailable {
+ t.Fatalf("unready status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
+ }
+
+ state.ready = true
+ recorder = httptest.NewRecorder()
+ app.handleReadiness(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("ready status = %d, want %d", recorder.Code, http.StatusOK)
+ }
+}
+
func TestSuccessResponseJSONNoStore(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodPost, "/submit", nil)
diff --git a/internal/submit/validation_test.go b/internal/submit/validation_test.go
index 630b647..d1284f1 100644
--- a/internal/submit/validation_test.go
+++ b/internal/submit/validation_test.go
@@ -88,7 +88,7 @@ func TestBuildMessageRequiresFaceWhenConfigured(t *testing.T) {
PublicKeyB64: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)),
SignatureB64: base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)),
}
- _, _, err := BuildMessage(sub, "mail2news@mail2news.tcpreset.net", "example.net", "/missing/identicons-cli", true)
+ _, _, err := BuildMessage(sub, "mail2news@mail2news.tcpreset.net", "n2usenet@virebent.art", "example.net", "/missing/identicons-cli", true)
if err == nil {
t.Fatal("expected missing Face generator to reject message")
}