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" ) func testHostConfig() map[string]service.ProxyServiceDomainConfig { return map[string]service.ProxyServiceDomainConfig{ testOriginalHost: {To: testPhishHost}, } } // patchUrls must reverse a phishing host back to the upstream host when a // request travels toward the upstream server. func TestPatchUrls_ReverseMapsPhishHostToOriginal(t *testing.T) { m := &ProxyHandler{} in := []byte("https://" + testPhishHost + "/oauth2/authorize?scope=openid") want := "https://" + testOriginalHost + "/oauth2/authorize?scope=openid" got := string(m.patchUrls(testHostConfig(), in, CONVERT_TO_ORIGINAL_URLS)) if got != want { t.Fatalf("reverse map failed\n got: %s\nwant: %s", got, want) } } // 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. func TestPatchQueryParameters_ReverseMapsRedirectURI(t *testing.T) { m := &ProxyHandler{} reqCtx := &RequestContext{ConfigMap: testHostConfig()} req, err := http.NewRequest( http.MethodGet, "https://"+testPhishHost+"/authorize?client_id=abc&redirect_uri=https%3A%2F%2F"+testPhishHost+"%2Fcb", nil, ) if err != nil { t.Fatal(err) } m.patchQueryParametersWithContext(req, reqCtx) got := req.URL.Query().Get("redirect_uri") want := "https://" + testOriginalHost + "/cb" if got != want { t.Fatalf("redirect_uri not reverse mapped\n got: %s\nwant: %s", got, want) } if cid := req.URL.Query().Get("client_id"); cid != "abc" { t.Fatalf("unrelated parameter altered: client_id=%s", cid) } } // With no mapping the query patch is a safe no-op and must not panic. This // documents that populating the mapping is what enables the reverse map. func TestPatchQueryParameters_EmptyConfigIsNoOp(t *testing.T) { m := &ProxyHandler{} reqCtx := &RequestContext{} req, err := http.NewRequest( http.MethodGet, "https://"+testPhishHost+"/authorize?redirect_uri=https%3A%2F%2F"+testPhishHost+"%2Fcb", nil, ) if err != nil { t.Fatal(err) } m.patchQueryParametersWithContext(req, reqCtx) if got := req.URL.Query().Get("redirect_uri"); got != "https://"+testPhishHost+"/cb" { t.Fatalf("unexpected rewrite with empty config: %s", got) } } // normalizeRequestHeaders must reverse the phishing host in the Origin and // Referer headers, so the phishing domain is not leaked to the upstream server. func TestNormalizeRequestHeaders_ReverseMapsOriginAndReferer(t *testing.T) { m := &ProxyHandler{} session := &service.ProxySession{} for host, cfg := range testHostConfig() { session.Config.Store(host, cfg) } req, err := http.NewRequest(http.MethodGet, "https://"+testPhishHost+"/authorize", nil) if err != nil { t.Fatal(err) } req.Header.Set("Origin", "https://"+testPhishHost) req.Header.Set("Referer", "https://"+testPhishHost+"/foo") m.normalizeRequestHeaders(req, session) if got, want := req.Header.Get("Origin"), "https://"+testOriginalHost; got != want { t.Fatalf("Origin not reverse mapped\n got: %s\nwant: %s", got, want) } if got, want := req.Header.Get("Referer"), "https://"+testOriginalHost+"/foo"; got != want { t.Fatalf("Referer not reverse mapped\n got: %s\nwant: %s", got, want) } } // --- multi host upstream cookie rewriting --- // multiHostConfig maps two upstream hosts to two phishing hosts, mirroring an // AiTM flow that spans more than one upstream login server. func multiHostConfig() map[string]service.ProxyServiceDomainConfig { return map[string]service.ProxyServiceDomainConfig{ "login.microsoftonline.com": {To: "login.phish.example.com"}, "login.live.com": {To: "live.phish.example.com"}, } } func newCookieResponse(t *testing.T, setCookies ...string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodGet, "https://login.phish.example.com/", nil) if err != nil { t.Fatal(err) } resp := &http.Response{Header: make(http.Header), Request: req} for _, sc := range setCookies { resp.Header.Add("Set-Cookie", sc) } return resp } // cookieDomain returns the Domain of the named cookie without a leading dot. func cookieDomain(t *testing.T, resp *http.Response, name string) string { t.Helper() for _, ck := range resp.Cookies() { if ck.Name == name { return strings.TrimPrefix(ck.Domain, ".") } } t.Fatalf("cookie %q not found in response", name) return "" } // A Set-Cookie from a secondary upstream host must be rewritten to that host's // 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{ TargetDomain: "login.microsoftonline.com", PhishDomain: "login.phish.example.com", ConfigMap: multiHostConfig(), } resp := newCookieResponse(t, "ESTSAUTH=abc; Domain=login.live.com; Path=/; Secure") m.processCookiesForPhishingDomainWithContext(resp, reqCtx) if got, want := cookieDomain(t, resp, "ESTSAUTH"), "live.phish.example.com"; got != want { t.Fatalf("secondary host cookie domain\n got: %s\nwant: %s", got, want) } } // 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{ TargetDomain: "login.microsoftonline.com", PhishDomain: "login.phish.example.com", ConfigMap: multiHostConfig(), } resp := newCookieResponse(t, "ESTSAUTHPERSISTENT=xyz; Domain=login.microsoftonline.com; Path=/; Secure") m.processCookiesForPhishingDomainWithContext(resp, reqCtx) if got, want := cookieDomain(t, resp, "ESTSAUTHPERSISTENT"), "login.phish.example.com"; got != want { t.Fatalf("primary host cookie domain\n got: %s\nwant: %s", got, want) } } // 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{ TargetDomain: "login.microsoftonline.com", PhishDomain: "login.phish.example.com", } resp := newCookieResponse(t, "ESTSAUTHPERSISTENT=xyz; Domain=login.microsoftonline.com; Path=/; Secure") m.processCookiesForPhishingDomainWithContext(resp, reqCtx) if got, want := cookieDomain(t, resp, "ESTSAUTHPERSISTENT"), "login.phish.example.com"; got != want { t.Fatalf("fallback primary cookie domain\n got: %s\nwant: %s", got, want) } } // --- registrable domain for the session cookie --- // extractTopLevelDomain must return the registrable domain so the session // cookie is scoped correctly. The naive last two labels approach breaks for // multi label public suffixes such as co.uk, for IP hosts and for hosts that // carry a port. func TestExtractTopLevelDomain(t *testing.T) { m := &ProxyHandler{} cases := []struct { name string in string want string }{ {"simple com", "login.evilcorp.com", "evilcorp.com"}, {"two label", "evilcorp.com", "evilcorp.com"}, {"deep subdomain com", "a.b.c.evilcorp.com", "evilcorp.com"}, {"dev test tld", "login.proxysaurous.test", "proxysaurous.test"}, {"single label", "localhost", "localhost"}, {"multi label suffix", "login.evilcorp.co.uk", "evilcorp.co.uk"}, {"deep multi label suffix", "a.b.evilcorp.co.uk", "evilcorp.co.uk"}, {"ip address", "127.0.0.1", "127.0.0.1"}, {"host with port", "login.evilcorp.com:8443", "evilcorp.com"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := m.extractTopLevelDomain(tc.in); got != tc.want { t.Fatalf("extractTopLevelDomain(%q)\n got: %s\nwant: %s", tc.in, got, tc.want) } }) } } // --- 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") } }