server: reject unexpected Host headers to block DNS rebinding

The API accepted any Host header, so a page running a DNS rebinding
attack could point attacker.com at 127.0.0.1 and read the collected
index from the victim's browser as same-origin JavaScript. Requests
whose Host is not localhost/127.0.0.1/::1 now get 421; -allowed-hosts
extends the list for exposed deployments. Rate limiting keyed on the
victim's own IP provided no protection here.
This commit is contained in:
lakshit verma 2026-08-22 03:43:52 +05:30
parent 12d3379dc0
commit 9a232a950c
No known key found for this signature in database
5 changed files with 90 additions and 12 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/data/
*.log
SPEC.md
/docs/

View file

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

View file

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

View file

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

20
main.go
View file

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