mirror of
https://github.com/binwiederhier/ntfy.git
synced 2025-07-20 10:04:08 +00:00
Add Forwarded header parsing
This commit is contained in:
parent
db4ac158e3
commit
bbfaf2fc4d
5 changed files with 61 additions and 25 deletions
|
@ -563,8 +563,11 @@ ntfy server, they all share the proxy's IP address.
|
||||||
|
|
||||||
Relevant flags to consider:
|
Relevant flags to consider:
|
||||||
|
|
||||||
* `behind-proxy`: if set, ntfy will use the `proxy-forwarded-header` to identify visitors (default: `false`)
|
* `behind-proxy` makes it so that the real visitor IP address is extracted from the header defined in `proxy-forwarded-header`.
|
||||||
* `proxy-forwarded-header`: the header to use to identify visitors (default: `X-Forwarded-For`)
|
Without this, the remote address of the incoming connection is used (default: `false`).
|
||||||
|
* `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`),
|
||||||
|
a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style
|
||||||
|
header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`).
|
||||||
* `proxy-trusted-addresses`: a comma-separated list of IP addresses that are removed from the forwarded header
|
* `proxy-trusted-addresses`: a comma-separated list of IP addresses that are removed from the forwarded header
|
||||||
to determine the real IP address (default: empty)
|
to determine the real IP address (default: empty)
|
||||||
|
|
||||||
|
@ -578,7 +581,7 @@ Relevant flags to consider:
|
||||||
behind-proxy: true
|
behind-proxy: true
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml (with custom header)"
|
=== "/etc/ntfy/server.yml (X-Client-IP header)"
|
||||||
``` yaml
|
``` yaml
|
||||||
# Tell ntfy to use "X-Client-IP" header to identify visitors for rate limiting
|
# Tell ntfy to use "X-Client-IP" header to identify visitors for rate limiting
|
||||||
#
|
#
|
||||||
|
@ -589,6 +592,17 @@ Relevant flags to consider:
|
||||||
proxy-forwarded-header: "X-Client-IP"
|
proxy-forwarded-header: "X-Client-IP"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (Forwarded header)"
|
||||||
|
``` yaml
|
||||||
|
# Tell ntfy to use "Forwarded" header (RFC 7239) to identify visitors for rate limiting
|
||||||
|
#
|
||||||
|
# Example: If "Forwarded: for=1.2.3.4;by=proxy.example.com, for=9.9.9.9" is set,
|
||||||
|
# the visitor IP will be 9.9.9.9.
|
||||||
|
#
|
||||||
|
behind-proxy: true
|
||||||
|
proxy-forwarded-header: "Forwarded"
|
||||||
|
```
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml (multiple proxies)"
|
=== "/etc/ntfy/server.yml (multiple proxies)"
|
||||||
``` yaml
|
``` yaml
|
||||||
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting,
|
# Tell ntfy to use "X-Forwarded-For" header to identify visitors for rate limiting,
|
||||||
|
|
|
@ -1433,6 +1433,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
|
### ntfy server v2.13.0 (UNRELEASED)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-addresses` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
|
@ -95,8 +95,8 @@
|
||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
# auth-startup-queries:
|
# auth-startup-queries:
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header (or whatever is configured) is used to determine the visitor IP address
|
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||||
# instead of the remote address of the connection.
|
# the visitor IP address instead of the remote address of the connection.
|
||||||
#
|
#
|
||||||
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate-limited
|
# WARNING: If you are behind a proxy, you must set this, otherwise all visitors are rate-limited
|
||||||
# as if they are one.
|
# as if they are one.
|
||||||
|
@ -107,6 +107,9 @@
|
||||||
# - proxy-trusted-addrs defines a list of trusted IP addresses that are stripped out of the
|
# - proxy-trusted-addrs defines a list of trusted IP addresses that are stripped out of the
|
||||||
# forwarded header. This is useful if there are multiple trusted proxies involved.
|
# forwarded header. This is useful if there are multiple trusted proxies involved.
|
||||||
#
|
#
|
||||||
|
# The parsing of the forwarded header is very lenient. Here are some examples:
|
||||||
|
# - X-Forwarded-For: 1.2.3.4, 5.6.7.8 (->
|
||||||
|
#
|
||||||
# behind-proxy: false
|
# behind-proxy: false
|
||||||
# proxy-forwarded-header: "X-Forwarded-For"
|
# proxy-forwarded-header: "X-Forwarded-For"
|
||||||
# proxy-trusted-addrs:
|
# proxy-trusted-addrs:
|
||||||
|
|
|
@ -15,8 +15,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mimeDecoder mime.WordDecoder
|
mimeDecoder mime.WordDecoder
|
||||||
|
|
||||||
|
// priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored
|
||||||
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
|
priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`)
|
||||||
|
|
||||||
|
// forwardedHeaderRegex parses IPv4 addresses from the "Forwarded" header (RFC 7239)
|
||||||
|
forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"?`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
|
@ -35,15 +40,11 @@ func toBool(value string) bool {
|
||||||
return value == "1" || value == "yes" || value == "true"
|
return value == "1" || value == "yes" || value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
|
func readCommaSeparatedParam(r *http.Request, names ...string) []string {
|
||||||
paramStr := readParam(r, names...)
|
if paramStr := readParam(r, names...); paramStr != "" {
|
||||||
if paramStr != "" {
|
return util.Map(util.SplitNoEmpty(paramStr, ","), strings.TrimSpace)
|
||||||
params = make([]string, 0)
|
|
||||||
for _, s := range util.SplitNoEmpty(paramStr, ",") {
|
|
||||||
params = append(params, strings.TrimSpace(s))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return params
|
return []string{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readParam(r *http.Request, names ...string) string {
|
func readParam(r *http.Request, names ...string) string {
|
||||||
|
@ -95,22 +96,30 @@ func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader st
|
||||||
// only the right-most address can be trusted (as this is the one added by our proxy server).
|
// only the right-most address can be trusted (as this is the one added by our proxy server).
|
||||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
||||||
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) {
|
func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) {
|
||||||
value := strings.TrimSpace(r.Header.Get(forwardedHeader))
|
value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader)))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
|
return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader)
|
||||||
}
|
}
|
||||||
addrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
|
// Extract valid addresses
|
||||||
clientAddrs := util.Filter(addrs, func(addr string) bool {
|
addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace)
|
||||||
return !slices.Contains(trustedAddresses, addr)
|
var validAddrs []netip.Addr
|
||||||
|
for _, addrStr := range addrsStrs {
|
||||||
|
if addr, err := netip.ParseAddr(addrStr); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
|
} else if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 {
|
||||||
|
if addr, err := netip.ParseAddr(m[1]); err == nil {
|
||||||
|
validAddrs = append(validAddrs, addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filter out proxy addresses
|
||||||
|
clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool {
|
||||||
|
return !slices.Contains(trustedAddresses, addr.String())
|
||||||
})
|
})
|
||||||
if len(clientAddrs) == 0 {
|
if len(clientAddrs) == 0 {
|
||||||
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
|
return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value)
|
||||||
}
|
}
|
||||||
clientAddr, err := netip.ParseAddr(clientAddrs[len(clientAddrs)-1])
|
return clientAddrs[len(clientAddrs)-1], nil
|
||||||
if err != nil {
|
|
||||||
return netip.IPv4Unspecified(), fmt.Errorf("invalid IP address %s received in %s header: %s: %w", clientAddr, forwardedHeader, value, err)
|
|
||||||
}
|
|
||||||
return clientAddr, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||||
|
@ -143,7 +152,7 @@ func fromContext[T any](r *http.Request, key contextKey) (T, error) {
|
||||||
|
|
||||||
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
|
// maybeDecodeHeader decodes the given header value if it is MIME encoded, e.g. "=?utf-8?q?Hello_World?=",
|
||||||
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
|
// or returns the original header value if it is not MIME encoded. It also calls maybeIgnoreSpecialHeader
|
||||||
// to ignore new HTTP "Priority" header.
|
// to ignore the new HTTP "Priority" header.
|
||||||
func maybeDecodeHeader(name, value string) string {
|
func maybeDecodeHeader(name, value string) string {
|
||||||
decoded, err := mimeDecoder.DecodeHeader(value)
|
decoded, err := mimeDecoder.DecodeHeader(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -152,7 +161,7 @@ func maybeDecodeHeader(name, value string) string {
|
||||||
return maybeIgnoreSpecialHeader(name, decoded)
|
return maybeIgnoreSpecialHeader(name, decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeIgnoreSpecialHeader ignores new HTTP "Priority" header (see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-priority)
|
// maybeIgnoreSpecialHeader ignores the new HTTP "Priority" header (RFC 9218, see https://datatracker.ietf.org/doc/html/rfc9218)
|
||||||
//
|
//
|
||||||
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
|
// Cloudflare (and potentially other providers) add this to requests when forwarding to the backend (ntfy),
|
||||||
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
|
// so we just ignore it. If the "Priority" header is set to "u=*, i" or "u=*" (by Cloudflare), the header will be ignored.
|
||||||
|
|
|
@ -95,12 +95,14 @@ func TestExtractIPAddress(t *testing.T) {
|
||||||
r.Header.Set("X-Forwarded-For", " 1.2.3.4 , 5.6.7.8")
|
r.Header.Set("X-Forwarded-For", " 1.2.3.4 , 5.6.7.8")
|
||||||
r.Header.Set("X-Client-IP", "9.10.11.12")
|
r.Header.Set("X-Client-IP", "9.10.11.12")
|
||||||
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
|
r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1")
|
||||||
|
r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1")
|
||||||
|
|
||||||
trustedProxies := []string{"1.1.1.1"}
|
trustedProxies := []string{"1.1.1.1"}
|
||||||
|
|
||||||
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
|
require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String())
|
||||||
require.Equal(t, "13.14.15.16", extractIPAddress(r, true, "X-Real-IP", trustedProxies).String())
|
require.Equal(t, "13.14.15.16", extractIPAddress(r, true, "X-Real-IP", trustedProxies).String())
|
||||||
|
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
|
||||||
require.Equal(t, "10.0.0.1", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
require.Equal(t, "10.0.0.1", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,9 +110,11 @@ func TestExtractIPAddress_UnixSocket(t *testing.T) {
|
||||||
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil)
|
||||||
r.RemoteAddr = "@"
|
r.RemoteAddr = "@"
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
|
r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1")
|
||||||
|
r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20")
|
||||||
|
|
||||||
trustedProxies := []string{"1.1.1.1"}
|
trustedProxies := []string{"1.1.1.1"}
|
||||||
|
|
||||||
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String())
|
||||||
|
require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String())
|
||||||
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String())
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue