diff options
Diffstat (limited to 'internal/submit')
| -rw-r--r-- | internal/submit/message.go | 14 | ||||
| -rw-r--r-- | internal/submit/message_test.go | 41 | ||||
| -rw-r--r-- | internal/submit/types.go | 91 | ||||
| -rw-r--r-- | internal/submit/types_test.go | 125 | ||||
| -rw-r--r-- | internal/submit/validation_test.go | 2 |
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") } |
