diff --git a/backend/proxy/proxy_test.go b/backend/proxy/proxy_test.go
index 670585e..8746ec2 100644
--- a/backend/proxy/proxy_test.go
+++ b/backend/proxy/proxy_test.go
@@ -1,13 +1,32 @@
package proxy
import (
+ "bytes"
+ "compress/flate"
+ "compress/gzip"
+ "io"
"net/http"
+ "net/url"
+ "regexp"
+ "strconv"
"strings"
+ "sync"
"testing"
+ "github.com/andybalholm/brotli"
+ "github.com/google/uuid"
+ "github.com/klauspost/compress/zstd"
+ "github.com/phishingclub/phishingclub/database"
"github.com/phishingclub/phishingclub/service"
+ "go.uber.org/zap"
)
+// newTestHandler returns a handler with a no-op logger so functions that log on
+// their error paths can be exercised without a nil logger panic.
+func newTestHandler() *ProxyHandler {
+ return &ProxyHandler{logger: zap.NewNop().Sugar()}
+}
+
const (
testOriginalHost = "login.microsoftonline.com"
testPhishHost = "login.phish.example.com"
@@ -34,7 +53,7 @@ func TestPatchUrls_ReverseMapsPhishHostToOriginal(t *testing.T) {
// A request without a session must still reverse the phishing host embedded in
// a query parameter such as redirect_uri, so the phishing domain is not sent to
-// the upstream server. This is the concrete leak the fix closes.
+// the upstream server.
func TestPatchQueryParameters_ReverseMapsRedirectURI(t *testing.T) {
m := &ProxyHandler{}
reqCtx := &RequestContext{ConfigMap: testHostConfig()}
@@ -83,8 +102,7 @@ func TestPatchQueryParameters_EmptyConfigIsNoOp(t *testing.T) {
}
// normalizeRequestHeaders must reverse the phishing host in the Origin and
-// Referer headers. This guards against regressing to an unpopulated header
-// config, which would leak the phishing domain to the upstream server.
+// Referer headers, so the phishing domain is not leaked to the upstream server.
func TestNormalizeRequestHeaders_ReverseMapsOriginAndReferer(t *testing.T) {
m := &ProxyHandler{}
@@ -147,9 +165,8 @@ func cookieDomain(t *testing.T, resp *http.Response, name string) string {
}
// A Set-Cookie from a secondary upstream host must be rewritten to that host's
-// phishing counterpart using the full host mapping. This fails while the cookie
-// path only knows the primary target to phish pair, so the secondary cookie
-// keeps its upstream domain and the browser on the phishing domain drops it.
+// phishing counterpart using the full host mapping. Otherwise the cookie keeps
+// its upstream domain and the browser on the phishing domain drops it.
func TestProcessCookies_SecondaryHostRewrittenToPhish(t *testing.T) {
m := &ProxyHandler{}
reqCtx := &RequestContext{
@@ -166,8 +183,8 @@ func TestProcessCookies_SecondaryHostRewrittenToPhish(t *testing.T) {
}
}
-// The primary host cookie must keep being rewritten when the full mapping is
-// present. Regression guard, expected to pass before and after the fix.
+// The primary host cookie must be rewritten to its phishing counterpart when
+// the full mapping is present.
func TestProcessCookies_PrimaryHostRewritten(t *testing.T) {
m := &ProxyHandler{}
reqCtx := &RequestContext{
@@ -184,9 +201,8 @@ func TestProcessCookies_PrimaryHostRewritten(t *testing.T) {
}
}
-// With no full mapping available the path must fall back to the primary target
-// to phish pair, so the primary cookie is still rewritten. Regression guard,
-// expected to pass before and after the fix.
+// With no full mapping available the path falls back to the primary target to
+// phish pair, so the primary cookie is still rewritten.
func TestProcessCookies_FallbackWhenConfigMapEmpty(t *testing.T) {
m := &ProxyHandler{}
reqCtx := &RequestContext{
@@ -235,3 +251,964 @@ func TestExtractTopLevelDomain(t *testing.T) {
})
}
}
+
+// --- host mapping ---
+
+func TestBuildHostMapping_Directions(t *testing.T) {
+ m := &ProxyHandler{}
+ cfg := map[string]service.ProxyServiceDomainConfig{
+ "login.microsoftonline.com": {To: "login.phish.example.com"},
+ "empty.example.com": {To: ""}, // no phish target, must be skipped
+ }
+
+ rev, revHosts := m.buildHostMapping(cfg, CONVERT_TO_ORIGINAL_URLS)
+ if rev["login.phish.example.com"] != "login.microsoftonline.com" {
+ t.Fatalf("reverse mapping wrong: %v", rev)
+ }
+
+ fwd, _ := m.buildHostMapping(cfg, CONVERT_TO_PHISHING_URLS)
+ if fwd["login.microsoftonline.com"] != "login.phish.example.com" {
+ t.Fatalf("forward mapping wrong: %v", fwd)
+ }
+
+ if len(revHosts) != 1 {
+ t.Fatalf("entry with empty To must be skipped, got hosts: %v", revHosts)
+ }
+}
+
+func TestPatchUrls_ForwardMapsOriginalToPhish(t *testing.T) {
+ m := &ProxyHandler{}
+ in := []byte("https://" + testOriginalHost + "/authorize")
+ want := "https://" + testPhishHost + "/authorize"
+ if got := string(m.patchUrls(testHostConfig(), in, CONVERT_TO_PHISHING_URLS)); got != want {
+ t.Fatalf("forward patch\n got: %s\nwant: %s", got, want)
+ }
+}
+
+func TestReplaceHostWithPhished(t *testing.T) {
+ m := &ProxyHandler{}
+ cfg := testHostConfig()
+
+ if got := m.replaceHostWithPhished(testOriginalHost, cfg); got != testPhishHost {
+ t.Fatalf("exact match\n got: %s\nwant: %s", got, testPhishHost)
+ }
+ if got := m.replaceHostWithPhished("foo."+testOriginalHost, cfg); got != "foo."+testPhishHost {
+ t.Fatalf("subdomain match\n got: %s\nwant: foo.%s", got, testPhishHost)
+ }
+ if got := m.replaceHostWithPhished("unrelated.example.org", cfg); got != "" {
+ t.Fatalf("no match must return empty, got: %s", got)
+ }
+}
+
+func TestReplaceHostWithOriginal(t *testing.T) {
+ m := &ProxyHandler{}
+ cfg := testHostConfig()
+
+ if got := m.replaceHostWithOriginal(testPhishHost, cfg); got != testOriginalHost {
+ t.Fatalf("reverse host\n got: %s\nwant: %s", got, testOriginalHost)
+ }
+ if got := m.replaceHostWithOriginal("nomatch.example.org", cfg); got != "" {
+ t.Fatalf("no match must return empty, got: %s", got)
+ }
+}
+
+// --- content gates ---
+
+func TestShouldProcessContent(t *testing.T) {
+ m := &ProxyHandler{}
+ process := []string{
+ "text/html", "application/javascript", "application/x-javascript",
+ "text/javascript", "text/css", "application/json; charset=utf-8",
+ }
+ for _, ct := range process {
+ if !m.shouldProcessContent(ct) {
+ t.Errorf("expected %q to be processed", ct)
+ }
+ }
+ skip := []string{"image/png", "application/octet-stream", "font/woff2", ""}
+ for _, ct := range skip {
+ if m.shouldProcessContent(ct) {
+ t.Errorf("expected %q to be skipped", ct)
+ }
+ }
+}
+
+func TestShouldCacheControlContent(t *testing.T) {
+ m := &ProxyHandler{}
+ yes := []string{"text/html", "application/javascript", "application/json"}
+ for _, ct := range yes {
+ if !m.shouldCacheControlContent(ct) {
+ t.Errorf("expected cache control for %q", ct)
+ }
+ }
+ // css is processed for rewriting but is not cache control managed
+ no := []string{"text/css", "image/png", ""}
+ for _, ct := range no {
+ if m.shouldCacheControlContent(ct) {
+ t.Errorf("expected no cache control for %q", ct)
+ }
+ }
+}
+
+// --- cookie helpers ---
+
+func TestSameSiteToString(t *testing.T) {
+ m := &ProxyHandler{}
+ cases := map[http.SameSite]string{
+ http.SameSiteDefaultMode: "Default",
+ http.SameSiteLaxMode: "Lax",
+ http.SameSiteStrictMode: "Strict",
+ http.SameSiteNoneMode: "None",
+ }
+ for in, want := range cases {
+ if got := m.sameSiteToString(in); got != want {
+ t.Errorf("sameSiteToString(%d)\n got: %s\nwant: %s", in, got, want)
+ }
+ }
+ if got := m.sameSiteToString(http.SameSite(99)); got != "Unknown(99)" {
+ t.Errorf("unknown same site\n got: %s\nwant: Unknown(99)", got)
+ }
+}
+
+func TestAdjustCookieSettings_SecureBecomesSameSiteNone(t *testing.T) {
+ m := &ProxyHandler{}
+ ck := &http.Cookie{Name: "a", Secure: true}
+ m.adjustCookieSettings(ck, nil, nil)
+ if ck.SameSite != http.SameSiteNoneMode {
+ t.Fatalf("secure cookie must become SameSite=None, got %v", ck.SameSite)
+ }
+}
+
+func TestAdjustCookieSettings_DefaultBecomesLax(t *testing.T) {
+ m := &ProxyHandler{}
+ ck := &http.Cookie{Name: "a", Secure: false, SameSite: http.SameSiteDefaultMode}
+ m.adjustCookieSettings(ck, nil, nil)
+ if ck.SameSite != http.SameSiteLaxMode {
+ t.Fatalf("non secure default cookie must become SameSite=Lax, got %v", ck.SameSite)
+ }
+}
+
+func TestStripCookieDomainPort(t *testing.T) {
+ if got := stripCookieDomainPort("login.example.com:8443"); got != "login.example.com" {
+ t.Fatalf("port must be stripped, got %s", got)
+ }
+ if got := stripCookieDomainPort("login.example.com"); got != "login.example.com" {
+ t.Fatalf("host without port unchanged, got %s", got)
+ }
+}
+
+// --- config helpers ---
+
+func TestConfigToMap(t *testing.T) {
+ m := &ProxyHandler{}
+ var sm sync.Map
+ sm.Store(testOriginalHost, service.ProxyServiceDomainConfig{To: testPhishHost})
+
+ out := m.configToMap(&sm)
+ if len(out) != 1 || out[testOriginalHost].To != testPhishHost {
+ t.Fatalf("configToMap wrong: %v", out)
+ }
+}
+
+func TestBuildSessionConfig(t *testing.T) {
+ m := &ProxyHandler{}
+ pc := &service.ProxyServiceConfigYAML{
+ Hosts: map[string]*service.ProxyServiceDomainConfig{
+ "login.live.com": {To: "live.phish.example.com"},
+ },
+ Global: &service.ProxyServiceRules{
+ Capture: []service.ProxyServiceCaptureRule{{Name: "creds"}},
+ },
+ }
+
+ cfg := m.buildSessionConfig(testOriginalHost, testPhishHost, pc)
+
+ if cfg[testOriginalHost].To != testPhishHost {
+ t.Fatalf("primary target to phish mapping missing: %v", cfg[testOriginalHost])
+ }
+ if cfg["login.live.com"].To != "live.phish.example.com" {
+ t.Fatalf("secondary host not merged: %v", cfg["login.live.com"])
+ }
+ if len(cfg[testOriginalHost].Capture) != 1 {
+ t.Fatalf("global capture must be appended to the primary target, got %d", len(cfg[testOriginalHost].Capture))
+ }
+}
+
+func TestSetProxyConfigDefaults(t *testing.T) {
+ m := &ProxyHandler{}
+ cfg := &service.ProxyServiceConfigYAML{
+ Hosts: map[string]*service.ProxyServiceDomainConfig{
+ testOriginalHost: {
+ To: testPhishHost,
+ Capture: []service.ProxyServiceCaptureRule{{Name: "creds"}},
+ Response: []service.ProxyServiceResponseRule{{}},
+ Access: &service.ProxyServiceAccessControl{},
+ },
+ },
+ }
+
+ m.setProxyConfigDefaults(cfg)
+
+ if cfg.Version != "0.0" {
+ t.Errorf("version default\n got: %s\nwant: 0.0", cfg.Version)
+ }
+ hc := cfg.Hosts[testOriginalHost]
+ if hc.Capture[0].Required == nil || !*hc.Capture[0].Required {
+ t.Error("capture Required must default to true")
+ }
+ if hc.Response[0].Status != 200 {
+ t.Errorf("response status default\n got: %d\nwant: 200", hc.Response[0].Status)
+ }
+ if hc.Access.Mode != "private" {
+ t.Errorf("access mode default\n got: %s\nwant: private", hc.Access.Mode)
+ }
+ if hc.Access.OnDeny != "404" {
+ t.Errorf("private on_deny default\n got: %s\nwant: 404", hc.Access.OnDeny)
+ }
+}
+
+func TestParseProxyConfig_ValidAndInvalid(t *testing.T) {
+ m := &ProxyHandler{}
+
+ valid := testOriginalHost + ":\n to: " + testPhishHost + "\n"
+ cfg, err := m.parseProxyConfig(valid)
+ if err != nil {
+ t.Fatalf("valid config must parse: %v", err)
+ }
+ if cfg.Hosts[testOriginalHost] == nil || cfg.Hosts[testOriginalHost].To != testPhishHost {
+ t.Fatalf("inline host not parsed: %v", cfg.Hosts)
+ }
+
+ if _, err := m.parseProxyConfig("- a\n- b\n"); err == nil {
+ t.Fatal("a yaml sequence must fail to parse into the config struct")
+ }
+}
+
+// --- capture: json path ---
+
+func TestParseJSONPath(t *testing.T) {
+ m := &ProxyHandler{}
+
+ got := m.parseJSONPath("user.tokens[1].id")
+ want := []jsonPathPart{
+ {isArray: false, key: "user"},
+ {isArray: false, key: "tokens"},
+ {isArray: true, index: 1},
+ {isArray: false, key: "id"},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("parts length\n got: %v\nwant: %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("part %d\n got: %+v\nwant: %+v", i, got[i], want[i])
+ }
+ }
+}
+
+func TestExtractJSONPath(t *testing.T) {
+ m := &ProxyHandler{}
+ data := map[string]interface{}{
+ "user": map[string]interface{}{"name": "alice"},
+ "tokens": []interface{}{"t0", "t1"},
+ }
+
+ cases := []struct {
+ path string
+ want string
+ }{
+ {"user.name", "alice"},
+ {"tokens[1]", "t1"},
+ {"tokens[5]", ""}, // out of range
+ {"user.age", ""}, // missing key
+ {"missing", ""}, // missing top level
+ {"", ""}, // empty path
+ }
+ for _, tc := range cases {
+ if got := m.extractJSONPath(data, tc.path); got != tc.want {
+ t.Errorf("extractJSONPath(%q)\n got: %s\nwant: %s", tc.path, got, tc.want)
+ }
+ }
+}
+
+func TestJSONValueToString(t *testing.T) {
+ m := &ProxyHandler{}
+ if got := m.jsonValueToString("x"); got != "x" {
+ t.Errorf("string: got %s", got)
+ }
+ if got := m.jsonValueToString(float64(42)); got != "42" {
+ t.Errorf("float whole: got %s", got)
+ }
+ if got := m.jsonValueToString(float64(3.5)); got != "3.5" {
+ t.Errorf("float frac: got %s", got)
+ }
+ if got := m.jsonValueToString(true); got != "true" {
+ t.Errorf("bool: got %s", got)
+ }
+ if got := m.jsonValueToString(nil); got != "" {
+ t.Errorf("nil: got %s", got)
+ }
+ if got := m.jsonValueToString(map[string]interface{}{"a": "b"}); got != `{"a":"b"}` {
+ t.Errorf("complex: got %s", got)
+ }
+}
+
+// --- capture: rule matching ---
+
+func TestMatchesPath(t *testing.T) {
+ m := &ProxyHandler{}
+
+ // nil PathRe matches anything
+ req, _ := http.NewRequest(http.MethodGet, "https://x/anything", nil)
+ if !m.matchesPath(service.ProxyServiceCaptureRule{}, req) {
+ t.Error("nil PathRe must match")
+ }
+
+ rule := service.ProxyServiceCaptureRule{PathRe: regexp.MustCompile("^/login")}
+ loginReq, _ := http.NewRequest(http.MethodGet, "https://x/login", nil)
+ if !m.matchesPath(rule, loginReq) {
+ t.Error("expected /login to match ^/login")
+ }
+ otherReq, _ := http.NewRequest(http.MethodGet, "https://x/other", nil)
+ if m.matchesPath(rule, otherReq) {
+ t.Error("expected /other not to match ^/login")
+ }
+
+ // empty path is normalized to /
+ rootRule := service.ProxyServiceCaptureRule{PathRe: regexp.MustCompile("^/$")}
+ emptyReq := &http.Request{URL: &url.URL{Path: ""}}
+ if !m.matchesPath(rootRule, emptyReq) {
+ t.Error("empty path must normalize to / and match ^/$")
+ }
+}
+
+func TestShouldApplyCaptureRule(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodPost, "https://x/login", nil)
+
+ // engine cookie is owned by the cookie path and never applies here
+ if m.shouldApplyCaptureRule(service.ProxyServiceCaptureRule{Engine: "cookie"}, "request_body", req) {
+ t.Error("engine cookie must not apply in the text pipeline")
+ }
+ // From mismatch
+ if m.shouldApplyCaptureRule(service.ProxyServiceCaptureRule{From: "response_body"}, "request_body", req) {
+ t.Error("From mismatch must not apply")
+ }
+ // From any applies
+ if !m.shouldApplyCaptureRule(service.ProxyServiceCaptureRule{From: "any"}, "request_body", req) {
+ t.Error("From any must apply")
+ }
+ // method mismatch
+ if m.shouldApplyCaptureRule(service.ProxyServiceCaptureRule{Method: http.MethodGet}, "request_body", req) {
+ t.Error("method mismatch must not apply")
+ }
+ // no constraints applies
+ if !m.shouldApplyCaptureRule(service.ProxyServiceCaptureRule{}, "request_body", req) {
+ t.Error("unconstrained rule must apply")
+ }
+}
+
+func TestShouldProcessResponseBodyCapture(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodGet, "https://x/callback", nil)
+
+ if m.shouldProcessResponseBodyCapture(service.ProxyServiceCaptureRule{Engine: "cookie"}, req) {
+ t.Error("engine cookie must not process in response body pipeline")
+ }
+ // path based rule matching the request path
+ pathRule := service.ProxyServiceCaptureRule{Path: "^/callback", PathRe: regexp.MustCompile("^/callback")}
+ if !m.shouldProcessResponseBodyCapture(pathRule, req) {
+ t.Error("path based rule matching the path must process")
+ }
+ // plain response body rule
+ if !m.shouldProcessResponseBodyCapture(service.ProxyServiceCaptureRule{From: "response_body"}, req) {
+ t.Error("response_body rule must process")
+ }
+}
+
+// --- capture: extractors ---
+
+func TestCaptureFromURLEncoded(t *testing.T) {
+ m := &ProxyHandler{}
+ rule := service.ProxyServiceCaptureRule{Name: "creds", Find: []string{"username", "password"}}
+
+ got := m.captureFromURLEncoded("username=alice&password=secret&other=x", rule, nil, nil, "request_body")
+ if got == nil {
+ t.Fatal("expected a capture result")
+ }
+ if got["username"] != "alice" || got["password"] != "secret" {
+ t.Fatalf("wrong fields captured: %v", got)
+ }
+ if got["capture_name"] != "creds" {
+ t.Errorf("capture_name missing: %v", got)
+ }
+ if _, ok := got["other"]; ok {
+ t.Error("unlisted field must not be captured")
+ }
+
+ // nothing matches
+ if got := m.captureFromURLEncoded("foo=bar", rule, nil, nil, "request_body"); got != nil {
+ t.Errorf("no matching field must return nil, got: %v", got)
+ }
+}
+
+func TestCaptureFromJSON(t *testing.T) {
+ m := newTestHandler()
+ rule := service.ProxyServiceCaptureRule{Name: "creds", Find: []string{"user.name", "token"}}
+
+ got := m.captureFromJSON(`{"user":{"name":"alice"},"token":"abc"}`, rule, nil, nil, "request_body")
+ if got == nil || got["user.name"] != "alice" || got["token"] != "abc" {
+ t.Fatalf("wrong json capture: %v", got)
+ }
+
+ // invalid json takes the logged error path and returns nil
+ if got := m.captureFromJSON("{not json", rule, nil, nil, "request_body"); got != nil {
+ t.Errorf("invalid json must return nil, got: %v", got)
+ }
+ // valid json without the fields
+ if got := m.captureFromJSON(`{"unrelated":1}`, rule, nil, nil, "request_body"); got != nil {
+ t.Errorf("no matching field must return nil, got: %v", got)
+ }
+}
+
+func TestCaptureFromRegex(t *testing.T) {
+ m := &ProxyHandler{}
+ rule := service.ProxyServiceCaptureRule{Name: "tok", Find: "secret[0-9]+"}
+
+ got, err := m.captureFromRegex("noise secret123 noise", rule, nil, nil, "response_body")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got["matched"] != "secret123" {
+ t.Fatalf("wrong match: %v", got)
+ }
+
+ // no match returns nil map and nil error
+ got, err = m.captureFromRegex("nothing here", rule, nil, nil, "response_body")
+ if err != nil || got != nil {
+ t.Fatalf("no match must return nil, nil; got %v, %v", got, err)
+ }
+
+ // invalid pattern returns an error
+ bad := service.ProxyServiceCaptureRule{Name: "bad", Find: "[unclosed"}
+ if _, err := m.captureFromRegex("x", bad, nil, nil, "response_body"); err == nil {
+ t.Error("invalid regex must return an error")
+ }
+}
+
+// --- access control ---
+
+func TestGetClientIP(t *testing.T) {
+ m := &ProxyHandler{}
+
+ // X-Forwarded-For wins and its first entry is used
+ req := &http.Request{Header: http.Header{}, RemoteAddr: "10.0.0.1:1"}
+ req.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8")
+ req.Header.Set("X-Real-IP", "9.9.9.9")
+ if got := m.getClientIP(req); got != "1.2.3.4" {
+ t.Errorf("xff first\n got: %s\nwant: 1.2.3.4", got)
+ }
+
+ // X-Real-IP used when no X-Forwarded-For
+ req2 := &http.Request{Header: http.Header{}}
+ req2.Header.Set("X-Real-IP", "9.9.9.9")
+ if got := m.getClientIP(req2); got != "9.9.9.9" {
+ t.Errorf("x-real-ip\n got: %s\nwant: 9.9.9.9", got)
+ }
+
+ // RemoteAddr with port is stripped
+ if got := m.getClientIP(&http.Request{Header: http.Header{}, RemoteAddr: "10.0.0.1:5555"}); got != "10.0.0.1" {
+ t.Errorf("remote addr with port\n got: %s\nwant: 10.0.0.1", got)
+ }
+
+ // RemoteAddr without port is returned as is
+ if got := m.getClientIP(&http.Request{Header: http.Header{}, RemoteAddr: "10.0.0.1"}); got != "10.0.0.1" {
+ t.Errorf("remote addr no port\n got: %s\nwant: 10.0.0.1", got)
+ }
+
+ // nothing available
+ if got := m.getClientIP(&http.Request{Header: http.Header{}}); got != "" {
+ t.Errorf("empty\n got: %s\nwant: (empty)", got)
+ }
+}
+
+func TestCheckAccessRules(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodGet, "https://x/", nil)
+ id := uuid.New()
+
+ // nil access control allows everything
+ if ok, _ := m.checkAccessRules("/", nil, false, &RequestContext{}, req); !ok {
+ t.Error("nil access control must allow")
+ }
+ // public mode allows everything
+ if ok, _ := m.checkAccessRules("/", &service.ProxyServiceAccessControl{Mode: "public"}, false, &RequestContext{}, req); !ok {
+ t.Error("public mode must allow")
+ }
+ // private mode allows a lure request (has campaign recipient id)
+ if ok, _ := m.checkAccessRules("/", &service.ProxyServiceAccessControl{Mode: "private"}, false, &RequestContext{CampaignRecipientID: &id}, req); !ok {
+ t.Error("private mode must allow a lure request")
+ }
+ // private mode denies with the default action when there is no lure and no domain
+ if ok, action := m.checkAccessRules("/", &service.ProxyServiceAccessControl{Mode: "private"}, false, &RequestContext{}, req); ok || action != "404" {
+ t.Errorf("private deny default\n got ok=%v action=%s\nwant ok=false action=404", ok, action)
+ }
+ // private mode uses the configured on_deny action
+ if ok, action := m.checkAccessRules("/", &service.ProxyServiceAccessControl{Mode: "private", OnDeny: "redirect:https://e.com"}, false, &RequestContext{}, req); ok || action != "redirect:https://e.com" {
+ t.Errorf("private deny custom\n got ok=%v action=%s", ok, action)
+ }
+ // unknown mode falls back to allow
+ if ok, _ := m.checkAccessRules("/", &service.ProxyServiceAccessControl{Mode: "weird"}, false, &RequestContext{}, req); !ok {
+ t.Error("unknown mode must allow")
+ }
+}
+
+func TestApplyDefaultPrivateMode(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodGet, "https://x/", nil)
+ id := uuid.New()
+
+ if ok, _ := m.applyDefaultPrivateMode(&RequestContext{CampaignRecipientID: &id}, req); !ok {
+ t.Error("lure request must be allowed")
+ }
+ if ok, action := m.applyDefaultPrivateMode(&RequestContext{}, req); ok || action != "404" {
+ t.Errorf("no lure must deny 404\n got ok=%v action=%s", ok, action)
+ }
+}
+
+func TestEvaluatePathAccess(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodGet, "https://x/dashboard", nil)
+
+ // domain specific rule (public) allows
+ rcDomain := &RequestContext{
+ Domain: &database.Domain{},
+ PhishDomain: testPhishHost,
+ ProxyConfig: &service.ProxyServiceConfigYAML{
+ Hosts: map[string]*service.ProxyServiceDomainConfig{
+ testOriginalHost: {To: testPhishHost, Access: &service.ProxyServiceAccessControl{Mode: "public"}},
+ },
+ },
+ }
+ if ok, _ := m.evaluatePathAccess("/dashboard", rcDomain, false, req); !ok {
+ t.Error("domain public rule must allow")
+ }
+
+ // no matching domain rule, global public allows
+ rcGlobal := &RequestContext{
+ ProxyConfig: &service.ProxyServiceConfigYAML{
+ Global: &service.ProxyServiceRules{Access: &service.ProxyServiceAccessControl{Mode: "public"}},
+ },
+ }
+ if ok, _ := m.evaluatePathAccess("/x", rcGlobal, false, req); !ok {
+ t.Error("global public rule must allow")
+ }
+
+ // no configuration at all denies via default private mode
+ if ok, action := m.evaluatePathAccess("/x", &RequestContext{}, false, req); ok || action != "404" {
+ t.Errorf("no config must deny 404\n got ok=%v action=%s", ok, action)
+ }
+}
+
+// --- forwarding rules ---
+
+func TestCheckForwardInRules(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodGet, "https://x/api/data", nil)
+
+ global := &service.ProxyServiceRules{Response: []service.ProxyServiceResponseRule{
+ {Path: "^/api", PathRe: regexp.MustCompile("^/api"), Forward: true},
+ }}
+ if !m.checkForwardInGlobalRules(global, req) {
+ t.Error("matching global rule with forward true must forward")
+ }
+
+ other, _ := http.NewRequest(http.MethodGet, "https://x/other", nil)
+ if m.checkForwardInGlobalRules(global, other) {
+ t.Error("non matching path must not forward")
+ }
+ if m.checkForwardInGlobalRules(nil, req) {
+ t.Error("nil rules must not forward")
+ }
+
+ // a matching domain rule returns its own Forward value, not a constant
+ domainForward := &service.ProxyServiceDomainConfig{Response: []service.ProxyServiceResponseRule{
+ {Path: "^/api", PathRe: regexp.MustCompile("^/api"), Forward: true},
+ }}
+ if !m.checkForwardInDomainRules(domainForward, req) {
+ t.Error("matching domain rule with forward true must forward")
+ }
+ domainNoForward := &service.ProxyServiceDomainConfig{Response: []service.ProxyServiceResponseRule{
+ {Path: "^/api", PathRe: regexp.MustCompile("^/api"), Forward: false},
+ }}
+ if m.checkForwardInDomainRules(domainNoForward, req) {
+ t.Error("matching domain rule with forward false must not forward")
+ }
+}
+
+func TestRewriteRuleMatchesRequest(t *testing.T) {
+ m := &ProxyHandler{}
+ req, _ := http.NewRequest(http.MethodPost, "https://x/login", nil)
+
+ // nil request matches only an unconstrained rule
+ if !m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{}, nil) {
+ t.Error("nil req, unconstrained rule must match")
+ }
+ if m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{Method: http.MethodGet}, nil) {
+ t.Error("nil req with a method constraint must not match")
+ }
+
+ // method mismatch and case insensitive match
+ if m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{Method: http.MethodGet}, req) {
+ t.Error("method mismatch must not match")
+ }
+ if !m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{Method: "post"}, req) {
+ t.Error("method match is case insensitive")
+ }
+
+ // path set but not compiled is skipped safely
+ if m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{Path: "^/login"}, req) {
+ t.Error("path without compiled regex must not match")
+ }
+ // compiled path match
+ if !m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{Path: "^/login", PathRe: regexp.MustCompile("^/login")}, req) {
+ t.Error("compiled path must match")
+ }
+ // unconstrained matches
+ if !m.rewriteRuleMatchesRequest(service.ProxyServiceReplaceRule{}, req) {
+ t.Error("unconstrained rule must match")
+ }
+}
+
+// --- response header rewrites ---
+
+func TestRemoveSecurityHeaders(t *testing.T) {
+ m := &ProxyHandler{}
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Content-Security-Policy", "default-src 'self'")
+ resp.Header.Set("Strict-Transport-Security", "max-age=63072000")
+ resp.Header.Set("X-Frame-Options", "DENY")
+ resp.Header.Set("Content-Type", "text/html")
+
+ m.removeSecurityHeaders(resp)
+
+ for _, h := range []string{"Content-Security-Policy", "Strict-Transport-Security", "X-Frame-Options"} {
+ if resp.Header.Get(h) != "" {
+ t.Errorf("%s must be removed", h)
+ }
+ }
+ if resp.Header.Get("Content-Type") != "text/html" {
+ t.Error("non security header must be kept")
+ }
+}
+
+func TestRewriteLocationHeaderWithoutSession(t *testing.T) {
+ m := &ProxyHandler{}
+ cfg := testHostConfig()
+
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Location", "https://"+testOriginalHost+"/next")
+ m.rewriteLocationHeaderWithoutSession(resp, cfg)
+ if got, want := resp.Header.Get("Location"), "https://"+testPhishHost+"/next"; got != want {
+ t.Errorf("location rewrite\n got: %s\nwant: %s", got, want)
+ }
+
+ // empty Location is a no-op
+ resp2 := &http.Response{Header: http.Header{}}
+ m.rewriteLocationHeaderWithoutSession(resp2, cfg)
+ if resp2.Header.Get("Location") != "" {
+ t.Error("empty location must stay empty")
+ }
+
+ // unmatched host stays unchanged
+ resp3 := &http.Response{Header: http.Header{}}
+ resp3.Header.Set("Location", "https://other.example.org/x")
+ m.rewriteLocationHeaderWithoutSession(resp3, cfg)
+ if resp3.Header.Get("Location") != "https://other.example.org/x" {
+ t.Error("unmatched host must stay unchanged")
+ }
+}
+
+func TestRewriteCORSHeaderWithoutSession(t *testing.T) {
+ m := &ProxyHandler{}
+ cfg := testHostConfig()
+
+ // matched origin is rewritten and credentials are enabled
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Access-Control-Allow-Origin", "https://"+testOriginalHost)
+ m.rewriteCORSHeaderWithoutSession(resp, cfg)
+ if got, want := resp.Header.Get("Access-Control-Allow-Origin"), "https://"+testPhishHost; got != want {
+ t.Errorf("cors origin rewrite\n got: %s\nwant: %s", got, want)
+ }
+ if resp.Header.Get("Access-Control-Allow-Credentials") != "true" {
+ t.Error("credentials must be enabled for a specific origin")
+ }
+
+ // wildcard origin is left alone and does not enable credentials
+ resp2 := &http.Response{Header: http.Header{}}
+ resp2.Header.Set("Access-Control-Allow-Origin", "*")
+ m.rewriteCORSHeaderWithoutSession(resp2, cfg)
+ if resp2.Header.Get("Access-Control-Allow-Origin") != "*" {
+ t.Error("wildcard origin must stay wildcard")
+ }
+ if resp2.Header.Get("Access-Control-Allow-Credentials") != "" {
+ t.Error("wildcard origin must not enable credentials")
+ }
+}
+
+// --- body rewrites and variables ---
+
+func TestApplyRegexReplacement(t *testing.T) {
+ m := newTestHandler()
+
+ rule := service.ProxyServiceReplaceRule{Find: "foo", Replace: "bar"}
+ if got := string(m.applyRegexReplacement([]byte("a foo b foo"), rule, "sid")); got != "a bar b bar" {
+ t.Errorf("replacement\n got: %s\nwant: a bar b bar", got)
+ }
+
+ // no match returns the original body
+ if got := string(m.applyRegexReplacement([]byte("nothing"), rule, "sid")); got != "nothing" {
+ t.Errorf("no match must be unchanged, got: %s", got)
+ }
+
+ // invalid regex returns the body unchanged
+ bad := service.ProxyServiceReplaceRule{Find: "[unclosed", Replace: "x"}
+ if got := string(m.applyRegexReplacement([]byte("keep me"), bad, "sid")); got != "keep me" {
+ t.Errorf("invalid regex must be unchanged, got: %s", got)
+ }
+}
+
+func TestRewritePathsInContent(t *testing.T) {
+ m := newTestHandler()
+
+ // anchors are stripped so the pattern matches mid string
+ rule := service.ProxyServiceURLRewriteRule{Find: "^/old/", Replace: "/new/"}
+ if got := m.rewritePathsInContent(`x`, rule); got != `x` {
+ t.Errorf("path rewrite\n got: %s", got)
+ }
+
+ // invalid regex returns the content unchanged
+ bad := service.ProxyServiceURLRewriteRule{Find: "[unclosed", Replace: "y"}
+ if got := m.rewritePathsInContent("keep", bad); got != "keep" {
+ t.Errorf("invalid regex must be unchanged, got: %s", got)
+ }
+}
+
+func TestInterpolateVariables(t *testing.T) {
+ m := &ProxyHandler{}
+ data := map[string]string{"FirstName": "Alice", "Email": "a@e.com"}
+
+ // disabled context returns the input unchanged
+ if got := m.interpolateVariables("Hi {{.FirstName}}", &VariablesContext{Enabled: false, Data: data}); got != "Hi {{.FirstName}}" {
+ t.Error("disabled context must not interpolate")
+ }
+ // nil context returns the input unchanged
+ if got := m.interpolateVariables("Hi {{.FirstName}}", nil); got != "Hi {{.FirstName}}" {
+ t.Error("nil context must not interpolate")
+ }
+ // no template syntax returns the input unchanged
+ if got := m.interpolateVariables("plain", &VariablesContext{Enabled: true, Data: data}); got != "plain" {
+ t.Error("input without template syntax must be unchanged")
+ }
+ // with no allow list all known variables are replaced
+ if got := m.interpolateVariables("Hi {{.FirstName}} <{{.Email}}>", &VariablesContext{Enabled: true, Data: data}); got != "Hi Alice " {
+ t.Errorf("all variables must interpolate, got: %s", got)
+ }
+ // an allow list restricts interpolation to the allowed variable
+ ctx := &VariablesContext{
+ Enabled: true,
+ Data: data,
+ Config: &service.ProxyServiceVariablesConfig{Enabled: true, Allowed: []string{"FirstName"}},
+ }
+ if got := m.interpolateVariables("Hi {{.FirstName}} <{{.Email}}>", ctx); got != "Hi Alice <{{.Email}}>" {
+ t.Errorf("only allowed variable must interpolate, got: %s", got)
+ }
+}
+
+// --- body decompression ---
+
+func gzipBytes(t *testing.T, in []byte) []byte {
+ t.Helper()
+ var b bytes.Buffer
+ w := gzip.NewWriter(&b)
+ if _, err := w.Write(in); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return b.Bytes()
+}
+
+func deflateBytes(t *testing.T, in []byte) []byte {
+ t.Helper()
+ var b bytes.Buffer
+ w, err := flate.NewWriter(&b, flate.DefaultCompression)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.Write(in); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return b.Bytes()
+}
+
+func brotliBytes(t *testing.T, in []byte) []byte {
+ t.Helper()
+ var b bytes.Buffer
+ w := brotli.NewWriter(&b)
+ if _, err := w.Write(in); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return b.Bytes()
+}
+
+func zstdBytes(t *testing.T, in []byte) []byte {
+ t.Helper()
+ var b bytes.Buffer
+ w, err := zstd.NewWriter(&b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := w.Write(in); err != nil {
+ t.Fatal(err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ return b.Bytes()
+}
+
+func makeEncodedResponse(encoding string, body []byte) *http.Response {
+ resp := &http.Response{Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(body))}
+ if encoding != "" {
+ resp.Header.Set("Content-Encoding", encoding)
+ }
+ return resp
+}
+
+func TestReadAndDecompressBody(t *testing.T) {
+ m := newTestHandler()
+ payload := []byte("hello world, hello world, hello world")
+
+ cases := []struct {
+ name string
+ encoding string
+ body []byte
+ }{
+ {"gzip", "gzip", gzipBytes(t, payload)},
+ {"deflate", "deflate", deflateBytes(t, payload)},
+ {"brotli", "br", brotliBytes(t, payload)},
+ {"zstd", "zstd", zstdBytes(t, payload)},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := makeEncodedResponse(tc.encoding, tc.body)
+ got, wasCompressed, err := m.readAndDecompressBody(resp, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !wasCompressed {
+ t.Error("must report the body was compressed")
+ }
+ if string(got) != string(payload) {
+ t.Fatalf("decompressed body\n got: %s\nwant: %s", got, payload)
+ }
+ })
+ }
+
+ // no encoding is returned as is and reported not compressed
+ resp := makeEncodedResponse("", payload)
+ got, wasCompressed, err := m.readAndDecompressBody(resp, false)
+ if err != nil || wasCompressed || string(got) != string(payload) {
+ t.Fatalf("plain body\n got: %s was: %v err: %v", got, wasCompressed, err)
+ }
+}
+
+func TestReadAndDecompressBody_AlreadyDecompressed(t *testing.T) {
+ m := newTestHandler()
+
+ // header claims gzip but the body is plain, so the reader fails and the
+ // stale Content-Encoding header is removed and the body returned as is
+ resp := makeEncodedResponse("gzip", []byte("not actually gzip"))
+ got, wasCompressed, err := m.readAndDecompressBody(resp, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if wasCompressed {
+ t.Error("must report not compressed")
+ }
+ if string(got) != "not actually gzip" {
+ t.Errorf("body must be unchanged, got: %s", got)
+ }
+ if resp.Header.Get("Content-Encoding") != "" {
+ t.Error("stale content-encoding header must be removed")
+ }
+}
+
+func TestUpdateResponseBody_RecompressGzip(t *testing.T) {
+ m := newTestHandler()
+ payload := []byte("hello world round trip")
+
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Content-Encoding", "gzip")
+
+ m.updateResponseBody(resp, payload, true)
+
+ if resp.Header.Get("Content-Encoding") != "gzip" {
+ t.Error("content-encoding must be kept when recompressing")
+ }
+ gz, err := gzip.NewReader(resp.Body)
+ if err != nil {
+ t.Fatalf("body must be valid gzip: %v", err)
+ }
+ out, _ := io.ReadAll(gz)
+ if string(out) != string(payload) {
+ t.Fatalf("round trip mismatch\n got: %s\nwant: %s", out, payload)
+ }
+}
+
+func TestUpdateResponseBody_UncompressedWhenEncodingRemoved(t *testing.T) {
+ m := newTestHandler()
+ payload := []byte("plain body")
+
+ // wasCompressed is true but the encoding header was already removed, so the
+ // body must be sent uncompressed with a matching content length
+ resp := &http.Response{Header: http.Header{}}
+ m.updateResponseBody(resp, payload, true)
+
+ out, _ := io.ReadAll(resp.Body)
+ if string(out) != string(payload) {
+ t.Fatalf("body\n got: %s\nwant: %s", out, payload)
+ }
+ if resp.ContentLength != int64(len(payload)) {
+ t.Errorf("content length\n got: %d\nwant: %d", resp.ContentLength, len(payload))
+ }
+ if resp.Header.Get("Content-Length") != strconv.Itoa(len(payload)) {
+ t.Errorf("content-length header\n got: %s\nwant: %d", resp.Header.Get("Content-Length"), len(payload))
+ }
+}
+
+func TestUpdateResponseBody_NotCompressedStripsEncoding(t *testing.T) {
+ m := newTestHandler()
+ payload := []byte("plain")
+
+ resp := &http.Response{Header: http.Header{}}
+ resp.Header.Set("Content-Encoding", "gzip") // stale header
+
+ m.updateResponseBody(resp, payload, false)
+
+ out, _ := io.ReadAll(resp.Body)
+ if string(out) != string(payload) {
+ t.Fatalf("body\n got: %s\nwant: %s", out, payload)
+ }
+ if resp.Header.Get("Content-Encoding") != "" {
+ t.Error("stale content-encoding must be stripped for an uncompressed body")
+ }
+}