mirror of
https://github.com/vee1e/subidx.git
synced 2026-09-01 09:50:18 +00:00
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:
parent
12d3379dc0
commit
9a232a950c
5 changed files with 90 additions and 12 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@
|
|||
/data/
|
||||
*.log
|
||||
SPEC.md
|
||||
/docs/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
20
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue