User header
This commit is contained in:
@@ -37,6 +37,7 @@ ADD go.mod go.sum main.go ./
|
|||||||
ADD ./client ./client
|
ADD ./client ./client
|
||||||
ADD ./cmd ./cmd
|
ADD ./cmd ./cmd
|
||||||
ADD ./log ./log
|
ADD ./log ./log
|
||||||
|
ADD ./payments ./payments
|
||||||
ADD ./server ./server
|
ADD ./server ./server
|
||||||
ADD ./user ./user
|
ADD ./user ./user
|
||||||
ADD ./util ./util
|
ADD ./util ./util
|
||||||
|
|||||||
@@ -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.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-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: "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-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: "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)"}),
|
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")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
proxyForwardedHeader := c.String("proxy-forwarded-header")
|
||||||
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
|
||||||
|
authUserHeader := c.String("auth-user-header")
|
||||||
stripeSecretKey := c.String("stripe-secret-key")
|
stripeSecretKey := c.String("stripe-secret-key")
|
||||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||||
billingContact := c.String("billing-contact")
|
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")
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
} else if behindProxy && proxyForwardedHeader == "" {
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
|
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 {
|
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
|
||||||
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||||
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||||
@@ -484,6 +490,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
conf.ProxyForwardedHeader = proxyForwardedHeader
|
conf.ProxyForwardedHeader = proxyForwardedHeader
|
||||||
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
|
||||||
|
conf.AuthUserHeader = authUserHeader
|
||||||
conf.StripeSecretKey = stripeSecretKey
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
conf.StripeWebhookKey = stripeWebhookKey
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
conf.BillingContact = billingContact
|
conf.BillingContact = billingContact
|
||||||
|
|||||||
@@ -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)
|
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)
|
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
|
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
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
StripePriceCacheDuration time.Duration
|
StripePriceCacheDuration time.Duration
|
||||||
@@ -263,6 +264,7 @@ func NewConfig() *Config {
|
|||||||
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
|
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
|
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
|
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: "",
|
StripeSecretKey: "",
|
||||||
StripeWebhookKey: "",
|
StripeWebhookKey: "",
|
||||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
|
|||||||
@@ -2140,6 +2140,24 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
|||||||
if s.userManager == nil {
|
if s.userManager == nil {
|
||||||
return vip, 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)
|
header, err := readAuthHeader(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vip, err
|
return vip, err
|
||||||
|
|||||||
@@ -124,6 +124,21 @@
|
|||||||
# proxy-forwarded-header: "X-Forwarded-For"
|
# proxy-forwarded-header: "X-Forwarded-For"
|
||||||
# proxy-trusted-hosts:
|
# 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
|
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||||
# are "attachment-cache-dir" and "base-url".
|
# are "attachment-cache-dir" and "base-url".
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -2398,6 +2398,103 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
|
|||||||
require.Equal(t, "2001:db8:3333::1", v.ip.String())
|
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) {
|
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
count := 50000
|
count := 50000
|
||||||
|
|||||||
Reference in New Issue
Block a user