diff --git a/.gitignore b/.gitignore index 292810e..2aca093 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /data/ *.log SPEC.md +/docs/ diff --git a/README.md b/README.md index bccf174..e2eab20 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Useful flags: | `-no-drain` | off | Skip old and rejected logs (they hold years of history, terabytes) | | `-rate-limit` | `1000` | Search requests allowed per IP per rolling 24 hours | | `-max-results` | `100000` | Max results buffered per search query (newest-collected first) | +| `-allowed-hosts` | loopback names | Host header values to accept. Blocks DNS rebinding; add your hostname when exposing the API | | `-trusted-proxy-hops` | `0` | How many proxies in front of you. 0 means X-Forwarded-For is ignored | Only one process can use a store directory at a time. The database takes an exclusive lock. diff --git a/internal/server/server.go b/internal/server/server.go index 5dc286a..4c7b211 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "log" + "net" "net/http" "strconv" "strings" @@ -13,12 +14,36 @@ import ( ) type Server struct { - Store *store.Store - Limiter *Limiter - TrustedHops int - RateLimit int64 - MaxResults int - ReadyFn func() bool + Store *store.Store + Limiter *Limiter + TrustedHops int + RateLimit int64 + MaxResults int + AllowedHosts []string + ReadyFn func() bool +} + +var defaultHosts = []string{"localhost", "127.0.0.1", "::1"} + +// hostAllowed blocks requests whose Host header is not expected. Browsers +// always send the origin's real hostname, but a DNS rebinding attack makes +// attacker.com resolve to 127.0.0.1 while the browser keeps sending +// attacker.com in Host, which never matches the allow list. +func (s *Server) hostAllowed(host string) bool { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.ToLower(strings.Trim(host, "[]")) + allowed := s.AllowedHosts + if len(allowed) == 0 { + allowed = defaultHosts + } + for _, h := range allowed { + if host == strings.ToLower(h) { + return true + } + } + return false } func (s *Server) Handler() http.Handler { @@ -27,7 +52,15 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/healthz", s.handleHealth) mux.HandleFunc("/readyz", s.handleReady) mux.HandleFunc("/", s.handleRoot) - return mux + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.hostAllowed(r.Host) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusMisdirectedRequest) + w.Write([]byte("unrecognized host\n")) + return + } + mux.ServeHTTP(w, r) + }) } func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f1d7438..df65de7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -52,6 +52,7 @@ func waitIngest(t *testing.T, st *store.Store) { func do(t *testing.T, s *Server, method, target string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, target, nil) + req.Host = "localhost" rec := httptest.NewRecorder() s.Handler().ServeHTTP(rec, req) return rec @@ -196,3 +197,35 @@ func TestRateLimit(t *testing.T) { } var _ = io.Discard + +func TestHostAllowList(t *testing.T) { + s, _ := newTestServer(t, 0) + + ok := do(t, s, "GET", "http://localhost:8080/v1/search?apex=example.com") + if ok.Code != 200 { + t.Errorf("localhost: code = %d", ok.Code) + } + rebound := httptest.NewRequest("GET", "/v1/search?apex=example.com", nil) + rebound.Host = "evil.example.net" + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, rebound) + if rec.Code != http.StatusMisdirectedRequest { + t.Errorf("rebound host: code = %d, want 421", rec.Code) + } + hz := httptest.NewRequest("GET", "/healthz", nil) + hz.Host = "evil.example.net" + rec2 := httptest.NewRecorder() + s.Handler().ServeHTTP(rec2, hz) + if rec2.Code != http.StatusMisdirectedRequest { + t.Errorf("rebound healthz: code = %d, want 421", rec2.Code) + } + + s.AllowedHosts = []string{"MyHost.Example.COM"} + custom := httptest.NewRequest("GET", "/v1/search?apex=example.com", nil) + custom.Host = "myhost.example.com:8099" + rec3 := httptest.NewRecorder() + s.Handler().ServeHTTP(rec3, custom) + if rec3.Code != 200 { + t.Errorf("custom allowed host: code = %d", rec3.Code) + } +} diff --git a/main.go b/main.go index e8e6af7..bbc2c1b 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -100,6 +101,7 @@ func cmdServe(args []string) error { noTail := fs.Bool("no-tail", false, "disable CT tailers") rateLimit := fs.Int64("rate-limit", 1000, "requests per rolling 24h per IP") maxResults := fs.Int("max-results", store.DefaultScanLimit, "max results buffered per search query") + allowedHosts := fs.String("allowed-hosts", "", "comma-separated Host values to accept (default: localhost, 127.0.0.1, ::1)") trustedHops := fs.Int("trusted-proxy-hops", 0, "trusted proxies in front (0 = ignore X-Forwarded-For)") if err := fs.Parse(args); err != nil { return err @@ -124,12 +126,20 @@ func cmdServe(args []string) error { }() } + var hosts []string + for _, h := range strings.Split(*allowedHosts, ",") { + if h = strings.TrimSpace(h); h != "" { + hosts = append(hosts, h) + } + } + srv := &server.Server{ - Store: st, - Limiter: server.NewLimiter(*rateLimit, 24*time.Hour), - TrustedHops: *trustedHops, - RateLimit: *rateLimit, - MaxResults: *maxResults, + Store: st, + Limiter: server.NewLimiter(*rateLimit, 24*time.Hour), + TrustedHops: *trustedHops, + RateLimit: *rateLimit, + MaxResults: *maxResults, + AllowedHosts: hosts, ReadyFn: func() bool { t, err := st.Total() return err == nil && t >= 0