User header

This commit is contained in:
binwiederhier
2026-01-21 20:14:45 -05:00
parent 77872f1b6a
commit 46cb9f2b41
6 changed files with 140 additions and 0 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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".
#

View File

@@ -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