diff --git a/handler/http/auth.go b/handler/http/auth.go index 92f70dc0..ab75ae4c 100644 --- a/handler/http/auth.go +++ b/handler/http/auth.go @@ -26,9 +26,9 @@ import ( // - "web": fetch a URL and replay its response as the decoy. // - "host": forward the raw request to a decoy host and relay the reply. // - "file": serve a local file with Content-Type: text/html. -// - knock: when pr.Knock is set, probe resistance only activates if the -// request hostname does NOT match the knock address. Clients that know -// the knock hostname get the normal 407 Proxy-Auth-Required. +// - knock: when pr.Knock is set (comma-separated hostnames), probe resistance +// only activates if the request hostname matches NONE of the knock addresses. +// Clients that know any knock hostname get the normal 407 Proxy-Auth-Required. // // On success it returns the authenticated client ID. On failure it writes // the response (407 or probe-resistance decoy) to conn and returns ok=false. @@ -49,7 +49,7 @@ func (h *httpHandler) authenticate(ctx context.Context, conn net.Conn, req *http // When pr.Knock matches the hostname, probe resistance is bypassed and // the normal 407 Proxy-Auth-Required is returned, revealing the proxy // only to clients that know the knock address. - if pr != nil && (pr.Knock == "" || !strings.EqualFold(req.URL.Hostname(), pr.Knock)) { + if pr != nil && (pr.Knock == "" || !knockMatch(req.URL.Hostname(), pr.Knock)) { resp.StatusCode = http.StatusServiceUnavailable switch pr.Type { @@ -159,3 +159,18 @@ func (h *httpHandler) checkRateLimit(addr net.Addr) bool { return true } + +// knockMatch reports whether hostname matches any entry in the +// comma-separated knock list. Matching is case-insensitive. An empty +// knock string returns false (no match). +func knockMatch(hostname, knock string) bool { + if knock == "" { + return false + } + for _, h := range strings.Split(knock, ",") { + if strings.EqualFold(hostname, strings.TrimSpace(h)) { + return true + } + } + return false +} diff --git a/handler/http/auth_test.go b/handler/http/auth_test.go index 54d036b6..08f5078e 100644 --- a/handler/http/auth_test.go +++ b/handler/http/auth_test.go @@ -279,6 +279,116 @@ func TestAuthenticate_ProbeResistanceKnockMismatch(t *testing.T) { } } +func TestAuthenticate_ProbeResistanceKnockMultiMatch(t *testing.T) { + h := &httpHandler{ + options: handler.Options{ + Auther: &stubAuther{accept: false}, + Logger: &testLogger{}, + }, + } + h.md.proxyAgent = defaultProxyAgent + // Third entry matches — probe resistance should be bypassed, return 407 + h.md.probeResistance = &probeResistance{ + Type: "code", + Value: "404", + Knock: "a.example.com, b.example.com, secret.example.com", + } + + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + req, _ := http.NewRequest("GET", "http://secret.example.com/", nil) + resp := &http.Response{ + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + ContentLength: -1, + } + + go func() { + h.authenticate(context.Background(), server, req, resp, &testLogger{}) + }() + + br := bufio.NewReader(client) + r, err := http.ReadResponse(br, req) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + defer r.Body.Close() + + if r.StatusCode != http.StatusProxyAuthRequired { + t.Errorf("got status %d, want %d (knock matched, should bypass probe resistance)", r.StatusCode, http.StatusProxyAuthRequired) + } +} + +func TestAuthenticate_ProbeResistanceKnockMultiMismatch(t *testing.T) { + h := &httpHandler{ + options: handler.Options{ + Auther: &stubAuther{accept: false}, + Logger: &testLogger{}, + }, + } + h.md.proxyAgent = defaultProxyAgent + // No entry matches — probe resistance should activate + h.md.probeResistance = &probeResistance{ + Type: "code", + Value: "503", + Knock: "a.example.com, b.example.com", + } + + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + req, _ := http.NewRequest("GET", "http://other.example.com/", nil) + resp := &http.Response{ + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{}, + ContentLength: -1, + } + + go func() { + h.authenticate(context.Background(), server, req, resp, &testLogger{}) + }() + + br := bufio.NewReader(client) + r, err := http.ReadResponse(br, req) + if err != nil { + t.Fatalf("failed to read response: %v", err) + } + defer r.Body.Close() + + if r.StatusCode != 503 { + t.Errorf("got status %d, want 503", r.StatusCode) + } +} + +func TestKnockMatch(t *testing.T) { + tests := []struct { + hostname string + knock string + want bool + }{ + {"", "", false}, + {"example.com", "", false}, + {"example.com", "example.com", true}, + {"EXAMPLE.COM", "example.com", true}, // case-insensitive + {"example.com", "other.com", false}, + {"example.com", " one.example.com , example.com , two.example.com ", true}, + {"example.com", " one.example.com, two.example.com ", false}, + {"", "example.com", false}, + {"example.com", " example.com , , two.example.com ", true}, // empty entry between commas + } + for _, tt := range tests { + got := knockMatch(tt.hostname, tt.knock) + if got != tt.want { + t.Errorf("knockMatch(%q, %q) = %v, want %v", tt.hostname, tt.knock, got, tt.want) + } + } +} + func TestCheckRateLimit_NoLimiter(t *testing.T) { h := &httpHandler{ options: handler.Options{}, diff --git a/handler/http/metadata.go b/handler/http/metadata.go index 055a3293..c327b2d0 100644 --- a/handler/http/metadata.go +++ b/handler/http/metadata.go @@ -148,5 +148,5 @@ func (h *httpHandler) parseMetadata(md mdata.Metadata) error { type probeResistance struct { Type string // strategy: "code", "web", "host", or "file" Value string // strategy-specific parameter (status code, URL, address, path) - Knock string // optional hostname; when set, probe resistance only fires for non-matching hosts + Knock string // optional comma-separated hostnames; probe resistance only fires when the request hostname matches none of them } diff --git a/handler/http/metadata_test.go b/handler/http/metadata_test.go index 147510cc..fcf7b508 100644 --- a/handler/http/metadata_test.go +++ b/handler/http/metadata_test.go @@ -117,6 +117,22 @@ func TestParseMetadata_ProbeResistance(t *testing.T) { } } +func TestParseMetadata_ProbeResistance_KnockMulti(t *testing.T) { + h := &httpHandler{} + if err := h.parseMetadata(testMD(map[string]any{ + "probeResist": "code:404", + "knock": " a.example.com , b.example.com ", + })); err != nil { + t.Fatal(err) + } + if h.md.probeResistance == nil { + t.Fatal("expected probeResistance to be set") + } + if h.md.probeResistance.Knock != " a.example.com , b.example.com " { + t.Errorf("got knock %q, want raw comma-separated string", h.md.probeResistance.Knock) + } +} + func TestParseMetadata_ProbeResistance_InvalidFormat(t *testing.T) { h := &httpHandler{} if err := h.parseMetadata(testMD(map[string]any{"probeResist": "invalid"})); err != nil { diff --git a/handler/http2/handler.go b/handler/http2/handler.go index 3865222e..4c886001 100644 --- a/handler/http2/handler.go +++ b/handler/http2/handler.go @@ -391,7 +391,7 @@ func (h *http2Handler) authenticate(ctx context.Context, w http.ResponseWriter, pr := h.md.probeResistance // probing resistance is enabled, and knocking host is mismatch. - if pr != nil && (pr.Knock == "" || !strings.EqualFold(r.URL.Hostname(), pr.Knock)) { + if pr != nil && (pr.Knock == "" || !knockMatch(r.URL.Hostname(), pr.Knock)) { resp.StatusCode = http.StatusServiceUnavailable // default status code switch pr.Type { case "code": @@ -536,3 +536,18 @@ func (h *http2Handler) observeStats(ctx context.Context) { } } } + +// knockMatch reports whether hostname matches any entry in the +// comma-separated knock list. Matching is case-insensitive. An empty +// knock string returns false (no match). +func knockMatch(hostname, knock string) bool { + if knock == "" { + return false + } + for _, h := range strings.Split(knock, ",") { + if strings.EqualFold(hostname, strings.TrimSpace(h)) { + return true + } + } + return false +} diff --git a/handler/http2/metadata.go b/handler/http2/metadata.go index f7aeaf79..56fb6d3e 100644 --- a/handler/http2/metadata.go +++ b/handler/http2/metadata.go @@ -64,5 +64,5 @@ func (h *http2Handler) parseMetadata(md mdata.Metadata) error { type probeResistance struct { Type string Value string - Knock string + Knock string // optional comma-separated hostnames; probe resistance only fires when the request hostname matches none of them }