mirror of
https://github.com/NotAShelf/watchdog.git
synced 2026-04-15 14:54:00 +00:00
When trusted proxy headers are enabled, the code accepted `X-Real-IP` without validating it. The attacker could simply set `X-Real-IP` to an arbitrary and that IP would be recorded as is. We validate the IP format and ensure it's not from a trusted proxy, and add test cases. Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ic1e761ea623a69371a28ad15d465d6c66a6a6964
420 lines
11 KiB
Go
420 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"notashelf.dev/watchdog/internal/aggregate"
|
|
"notashelf.dev/watchdog/internal/config"
|
|
"notashelf.dev/watchdog/internal/normalize"
|
|
)
|
|
|
|
func TestIngestionHandler_Pageview(t *testing.T) {
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"},
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
Country: true,
|
|
Device: true,
|
|
Referrer: "domain",
|
|
},
|
|
Path: config.PathConfig{
|
|
StripQuery: true,
|
|
StripFragment: true,
|
|
CollapseNumericSegments: true,
|
|
NormalizeTrailingSlash: true,
|
|
},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 100,
|
|
MaxSources: 50,
|
|
},
|
|
}
|
|
|
|
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
|
|
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
|
|
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
|
|
metricsAgg := aggregate.NewMetricsAggregator(
|
|
pathRegistry,
|
|
aggregate.NewCustomEventRegistry(100),
|
|
&cfg,
|
|
)
|
|
|
|
handler := NewIngestionHandler(&cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
|
|
|
|
body := `{"d":"example.com","p":"/home?query=1","r":"https://google.com","w":1920}`
|
|
req := httptest.NewRequest("POST", "/api/event", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status %d, got %d", http.StatusNoContent, w.Code)
|
|
}
|
|
|
|
// Verify path was normalized and registered
|
|
if !pathRegistry.Contains("/home") {
|
|
t.Error("expected path to be registered")
|
|
}
|
|
}
|
|
|
|
func TestIngestionHandler_CustomEvent(t *testing.T) {
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"},
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
CustomEvents: []string{"signup", "purchase"},
|
|
Path: config.PathConfig{
|
|
StripQuery: true,
|
|
},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 100,
|
|
MaxSources: 50,
|
|
},
|
|
}
|
|
|
|
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
|
|
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
|
|
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
|
|
metricsAgg := aggregate.NewMetricsAggregator(
|
|
pathRegistry,
|
|
aggregate.NewCustomEventRegistry(100),
|
|
&cfg,
|
|
)
|
|
|
|
handler := NewIngestionHandler(&cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
|
|
|
|
body := `{"d":"example.com","p":"/signup","e":"signup"}`
|
|
req := httptest.NewRequest("POST", "/api/event", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status %d, got %d", http.StatusNoContent, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestIngestionHandler_WrongDomain(t *testing.T) {
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"},
|
|
Collect: config.CollectConfig{
|
|
Pageviews: true,
|
|
},
|
|
Path: config.PathConfig{},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 100,
|
|
MaxSources: 50,
|
|
},
|
|
}
|
|
|
|
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
|
|
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
|
|
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
|
|
metricsAgg := aggregate.NewMetricsAggregator(
|
|
pathRegistry,
|
|
aggregate.NewCustomEventRegistry(100),
|
|
&cfg,
|
|
)
|
|
|
|
handler := NewIngestionHandler(&cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
|
|
|
|
body := `{"d":"wrong.com","p":"/home"}`
|
|
req := httptest.NewRequest("POST", "/api/event", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestIngestionHandler_MethodNotAllowed(t *testing.T) {
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 100,
|
|
MaxSources: 50,
|
|
},
|
|
}
|
|
|
|
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
|
|
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
|
|
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
|
|
metricsAgg := aggregate.NewMetricsAggregator(
|
|
pathRegistry,
|
|
aggregate.NewCustomEventRegistry(100),
|
|
&cfg,
|
|
)
|
|
|
|
handler := NewIngestionHandler(&cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
|
|
|
|
req := httptest.NewRequest("GET", "/api/event", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
|
|
}
|
|
}
|
|
|
|
func TestIngestionHandler_InvalidJSON(t *testing.T) {
|
|
cfg := config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 100,
|
|
MaxSources: 50,
|
|
},
|
|
}
|
|
|
|
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
|
|
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
|
|
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
|
|
metricsAgg := aggregate.NewMetricsAggregator(
|
|
pathRegistry,
|
|
aggregate.NewCustomEventRegistry(100),
|
|
&cfg,
|
|
)
|
|
|
|
handler := NewIngestionHandler(&cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
|
|
|
|
body := `{invalid json}`
|
|
req := httptest.NewRequest("POST", "/api/event", bytes.NewBufferString(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code)
|
|
}
|
|
}
|
|
|
|
func newTestHandler(cfg *config.Config) *IngestionHandler {
|
|
pathNorm := normalize.NewPathNormalizer(cfg.Site.Path)
|
|
pathRegistry := aggregate.NewPathRegistry(cfg.Limits.MaxPaths)
|
|
refRegistry := normalize.NewReferrerRegistry(cfg.Limits.MaxSources)
|
|
metricsAgg := aggregate.NewMetricsAggregator(
|
|
pathRegistry,
|
|
aggregate.NewCustomEventRegistry(100),
|
|
cfg,
|
|
)
|
|
return NewIngestionHandler(cfg, pathNorm, pathRegistry, refRegistry, metricsAgg)
|
|
}
|
|
|
|
func TestExtractIP(t *testing.T) {
|
|
cfg := &config.Config{
|
|
Site: config.SiteConfig{
|
|
Domains: []string{"example.com"},
|
|
},
|
|
Limits: config.LimitsConfig{
|
|
MaxPaths: 100,
|
|
MaxSources: 50,
|
|
},
|
|
Security: config.SecurityConfig{
|
|
TrustedProxies: []string{"10.0.0.0/8", "192.168.1.1"},
|
|
},
|
|
}
|
|
h := newTestHandler(cfg)
|
|
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
headers map[string]string
|
|
want string
|
|
}{
|
|
{
|
|
name: "direct connection no proxy",
|
|
remoteAddr: "192.168.1.100:12345",
|
|
headers: map[string]string{},
|
|
want: "192.168.1.100",
|
|
},
|
|
{
|
|
name: "trusted proxy with X-Forwarded-For",
|
|
remoteAddr: "10.0.0.1:12345",
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "203.0.113.1, 10.0.0.5",
|
|
},
|
|
want: "203.0.113.1",
|
|
},
|
|
{
|
|
name: "trusted proxy with X-Real-IP",
|
|
remoteAddr: "10.0.0.1:12345",
|
|
headers: map[string]string{
|
|
"X-Real-IP": "203.0.113.2",
|
|
},
|
|
want: "203.0.113.2",
|
|
},
|
|
{
|
|
name: "X-Real-IP from trusted network should be ignored",
|
|
remoteAddr: "10.0.0.1:12345",
|
|
headers: map[string]string{
|
|
"X-Real-IP": "10.0.0.50", // trusted network, should fall back
|
|
},
|
|
want: "10.0.0.1", // falls back to remoteAddr
|
|
},
|
|
{
|
|
name: "X-Real-IP invalid IP should be ignored",
|
|
remoteAddr: "10.0.0.1:12345",
|
|
headers: map[string]string{
|
|
"X-Real-IP": "not-an-ip",
|
|
},
|
|
want: "10.0.0.1", // falls back
|
|
},
|
|
{
|
|
name: "untrusted proxy X-Forwarded-For ignored",
|
|
remoteAddr: "203.0.113.50:12345",
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "1.2.3.4",
|
|
},
|
|
want: "203.0.113.50", // uses remoteAddr, ignores header
|
|
},
|
|
{
|
|
name: "untrusted proxy X-Real-IP ignored",
|
|
remoteAddr: "203.0.113.50:12345",
|
|
headers: map[string]string{
|
|
"X-Real-IP": "1.2.3.4",
|
|
},
|
|
want: "203.0.113.50", // uses remoteAddr, ignores header
|
|
},
|
|
{
|
|
name: "X-Forwarded-For all trusted falls back",
|
|
remoteAddr: "10.0.0.1:12345",
|
|
headers: map[string]string{
|
|
"X-Forwarded-For": "10.0.0.2, 10.0.0.3",
|
|
},
|
|
want: "10.0.0.1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/api/event", nil)
|
|
req.RemoteAddr = tt.remoteAddr
|
|
for k, v := range tt.headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
got := h.extractIP(req)
|
|
if got != tt.want {
|
|
t.Errorf("extractIP() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyDevice_UA(t *testing.T) {
|
|
cfg := &config.Config{
|
|
Limits: config.LimitsConfig{
|
|
DeviceBreakpoints: config.DeviceBreaks{
|
|
Mobile: 768,
|
|
Tablet: 1024,
|
|
},
|
|
},
|
|
}
|
|
h := newTestHandler(cfg)
|
|
|
|
tests := []struct {
|
|
name string
|
|
width int
|
|
userAgent string
|
|
want string
|
|
}{
|
|
// UA takes priority
|
|
{
|
|
name: "iphone via UA",
|
|
width: 390,
|
|
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
|
|
want: "mobile",
|
|
},
|
|
{
|
|
name: "android phone via UA",
|
|
width: 0,
|
|
userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) Mobile Safari/537.36",
|
|
want: "mobile",
|
|
},
|
|
{
|
|
name: "windows phone via UA",
|
|
width: 0,
|
|
userAgent: "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0)",
|
|
want: "mobile",
|
|
},
|
|
{
|
|
name: "ipad via UA",
|
|
width: 1024,
|
|
userAgent: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
|
|
want: "tablet",
|
|
},
|
|
{
|
|
name: "android tablet via UA (no mobile keyword)",
|
|
width: 0,
|
|
userAgent: "Mozilla/5.0 (Linux; Android 13; SM-T870) AppleWebKit/537.36",
|
|
want: "tablet",
|
|
},
|
|
// Falls back to width when UA is desktop
|
|
{
|
|
name: "desktop UA wide screen",
|
|
width: 1920,
|
|
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0",
|
|
want: "desktop",
|
|
},
|
|
{
|
|
name: "desktop UA narrow width",
|
|
width: 500,
|
|
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0",
|
|
want: "mobile",
|
|
},
|
|
// Width-only fallback
|
|
{
|
|
name: "no UA mobile width",
|
|
width: 375,
|
|
userAgent: "",
|
|
want: "mobile",
|
|
},
|
|
{
|
|
name: "no UA tablet width",
|
|
width: 800,
|
|
userAgent: "",
|
|
want: "tablet",
|
|
},
|
|
{
|
|
name: "no UA desktop width",
|
|
width: 1440,
|
|
userAgent: "",
|
|
want: "desktop",
|
|
},
|
|
// Unknown
|
|
{
|
|
name: "no UA no width",
|
|
width: 0,
|
|
userAgent: "",
|
|
want: "unknown",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := h.classifyDevice(tt.width, tt.userAgent)
|
|
if got != tt.want {
|
|
t.Errorf("classifyDevice(%d, %q) = %q, want %q",
|
|
tt.width, tt.userAgent, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|