summaryrefslogtreecommitdiffstats
path: root/internal/submit/types.go
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/types.go
parentfb83c4d70616ec23d8a5397409a5d31c70b70d66 (diff)
downloadn2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.gz
n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.tar.xz
n2usenet-e9fbbe3373eb66a345f5e3829e2563b94dc92051.zip
Harden transport and preserve profile identitiesHEADmain
Diffstat (limited to 'internal/submit/types.go')
-rw-r--r--internal/submit/types.go91
1 files changed, 79 insertions, 12 deletions
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 {