From 46cb9f2b41e847c3ac51c85b37684ba79a1ba705 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 21 Jan 2026 20:14:45 -0500 Subject: [PATCH] User header --- Dockerfile-build | 1 + cmd/serve.go | 7 ++++ server/config.go | 2 + server/server.go | 18 ++++++++ server/server.yml | 15 +++++++ server/server_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 140 insertions(+) diff --git a/Dockerfile-build b/Dockerfile-build index 78f2d5d9..b1d1a843 100644 --- a/Dockerfile-build +++ b/Dockerfile-build @@ -37,6 +37,7 @@ ADD go.mod go.sum main.go ./ ADD ./client ./client ADD ./cmd ./cmd ADD ./log ./log +ADD ./payments ./payments ADD ./server ./server ADD ./user ./user ADD ./util ./util diff --git a/cmd/serve.go b/cmd/serve.go index b451a118..eb30bbd6 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -95,6 +95,7 @@ var flagsServe = append( altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-user-header", Aliases: []string{"auth_user_header"}, EnvVars: []string{"NTFY_AUTH_USER_HEADER"}, Value: "", Usage: "if set (along with behind-proxy and auth-file), trust this header to contain the authenticated username (e.g. X-Forwarded-User, Remote-User)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), @@ -206,6 +207,7 @@ func execServe(c *cli.Context) error { behindProxy := c.Bool("behind-proxy") proxyForwardedHeader := c.String("proxy-forwarded-header") proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") + authUserHeader := c.String("auth-user-header") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") billingContact := c.String("billing-contact") @@ -344,6 +346,10 @@ func execServe(c *cli.Context) error { return errors.New("web push expiry warning duration cannot be higher than web push expiry duration") } else if behindProxy && proxyForwardedHeader == "" { return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set") + } else if authUserHeader != "" && !behindProxy { + return errors.New("auth-user-header requires behind-proxy to be set") + } else if authUserHeader != "" && authFile == "" { + return errors.New("auth-user-header requires auth-file to be set") } else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 { return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { @@ -484,6 +490,7 @@ func execServe(c *cli.Context) error { conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyTrustedPrefixes = trustedProxyPrefixes + conf.AuthUserHeader = authUserHeader conf.StripeSecretKey = stripeSecretKey conf.StripeWebhookKey = stripeWebhookKey conf.BillingContact = billingContact diff --git a/server/config.go b/server/config.go index 278b6aed..4410c1bc 100644 --- a/server/config.go +++ b/server/config.go @@ -166,6 +166,7 @@ type Config struct { BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true + AuthUserHeader string // Header to read the authenticated user from, if BehindProxy is true (e.g. X-Forwarded-User, Remote-User) StripeSecretKey string StripeWebhookKey string StripePriceCacheDuration time.Duration @@ -263,6 +264,7 @@ func NewConfig() *Config { VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6 BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs + AuthUserHeader: "", // Header to read the authenticated user from (requires behind-proxy and auth-file) StripeSecretKey: "", StripeWebhookKey: "", StripePriceCacheDuration: DefaultStripePriceCacheDuration, diff --git a/server/server.go b/server/server.go index 04133dac..9dcae651 100644 --- a/server/server.go +++ b/server/server.go @@ -2140,6 +2140,24 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { if s.userManager == nil { return vip, nil } + // Check for proxy-forwarded user header (requires behind-proxy and auth-user-header to be set) + if s.config.BehindProxy && s.config.AuthUserHeader != "" { + if username := strings.TrimSpace(r.Header.Get(s.config.AuthUserHeader)); username != "" { + u, err := s.userManager.User(username) + if err != nil { + logr(r).Err(err).Debug("User from auth-user-header not found") + return vip, errHTTPUnauthorized + } + if u.Deleted { + logr(r).Debug("User from auth-user-header is deleted") + return vip, errHTTPUnauthorized + } + logr(r).Debug("User from header found") + return s.visitor(ip, u), nil + } + // If auth-user-header is set, but no user was provided, return unauthorized + return vip, errHTTPUnauthorized + } header, err := readAuthHeader(r) if err != nil { return vip, err diff --git a/server/server.yml b/server/server.yml index 639ed492..7356cb0e 100644 --- a/server/server.yml +++ b/server/server.yml @@ -124,6 +124,21 @@ # proxy-forwarded-header: "X-Forwarded-For" # proxy-trusted-hosts: +# If set (along with behind-proxy and auth-file), trust this header to contain the authenticated +# username. This is useful when running ntfy behind an authentication proxy like Authelia, +# Authentik, or Caddy Security that handles authentication and forwards the user identity. +# +# Common header names: +# - X-Forwarded-User (Authelia default) +# - Remote-User (common convention) +# - X-Remote-User +# +# IMPORTANT: Only enable this if you trust the proxy to authenticate users. The header value +# is trusted unconditionally when behind-proxy is also set. Users must be pre-provisioned in +# the ntfy database (via auth-file); they are not auto-created. +# +# auth-user-header: + # If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments # are "attachment-cache-dir" and "base-url". # diff --git a/server/server_test.go b/server/server_test.go index 0b125638..3ce6b33f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2398,6 +2398,103 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { require.Equal(t, "2001:db8:3333::1", v.ip.String()) } +func TestServer_AuthUserHeader_Success(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BehindProxy = true + c.AuthUserHeader = "X-Forwarded-User" + s := newTestServer(t, c) + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + + r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) + r.RemoteAddr = "1.2.3.4:1234" + r.Header.Set("X-Forwarded-User", "phil") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.NotNil(t, v.User()) + require.Equal(t, "phil", v.User().Name) +} + +func TestServer_AuthUserHeader_UserNotFound(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BehindProxy = true + c.AuthUserHeader = "X-Forwarded-User" + s := newTestServer(t, c) + + r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) + r.RemoteAddr = "1.2.3.4:1234" + r.Header.Set("X-Forwarded-User", "unknown-user") + _, err := s.maybeAuthenticate(r) + require.Equal(t, errHTTPUnauthorized, err) +} + +func TestServer_AuthUserHeader_NoHeader_FallbackToStandardAuth(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BehindProxy = true + c.AuthUserHeader = "X-Forwarded-User" + s := newTestServer(t, c) + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + + // No X-Forwarded-User header, but with Authorization header + r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) + r.RemoteAddr = "1.2.3.4:1234" + r.Header.Set("Authorization", util.BasicAuth("phil", "phil")) + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.NotNil(t, v.User()) + require.Equal(t, "phil", v.User().Name) +} + +func TestServer_AuthUserHeader_NoHeader_AnonymousAllowed(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BehindProxy = true + c.AuthUserHeader = "X-Forwarded-User" + s := newTestServer(t, c) + + // No X-Forwarded-User header and no Authorization header -> anonymous + r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) + r.RemoteAddr = "1.2.3.4:1234" + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Nil(t, v.User()) +} + +func TestServer_AuthUserHeader_NotBehindProxy(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BehindProxy = false // Auth user header should be ignored if not behind proxy + c.AuthUserHeader = "X-Forwarded-User" + s := newTestServer(t, c) + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + + // Header is present but should be ignored since behind-proxy is false + r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) + r.RemoteAddr = "1.2.3.4:1234" + r.Header.Set("X-Forwarded-User", "phil") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Nil(t, v.User()) // Should be anonymous since header is ignored +} + +func TestServer_AuthUserHeader_RemoteUser(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.BehindProxy = true + c.AuthUserHeader = "Remote-User" // Common alternative header name + s := newTestServer(t, c) + + require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false)) + + r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) + r.RemoteAddr = "1.2.3.4:1234" + r.Header.Set("Remote-User", "admin") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.NotNil(t, v.User()) + require.Equal(t, "admin", v.User().Name) + require.True(t, v.User().IsAdmin()) +} + func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { t.Parallel() count := 50000