Skip to content

Commit e4edd5a

Browse files
Merge pull request projectdiscovery#7286 from projectdiscovery/4685-fix-fhr-redirect-port-normalization
fixing redirect
2 parents f70353b + 2f03145 commit e4edd5a

File tree

2 files changed

+234
-5
lines changed

2 files changed

+234
-5
lines changed

pkg/protocols/http/httpclientpool/clientpool.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,13 +385,14 @@ func makeCheckRedirectFunc(redirectType RedirectFlow, maxRedirects int) checkRed
385385
case DontFollowRedirect:
386386
return http.ErrUseLastResponse
387387
case FollowSameHostRedirect:
388-
var newHost = req.URL.Host
389-
var oldHost = via[0].Host
390-
if oldHost == "" {
391-
oldHost = via[0].URL.Host
388+
var newHost = normalizeHost(req.URL)
389+
var oldHost string
390+
if via[0].Host != "" {
391+
oldHost = normalizeHost(&url.URL{Scheme: via[0].URL.Scheme, Host: via[0].Host})
392+
} else {
393+
oldHost = normalizeHost(via[0].URL)
392394
}
393395
if newHost != oldHost {
394-
// Tell the http client to not follow redirect
395396
return http.ErrUseLastResponse
396397
}
397398
return checkMaxRedirects(req, via, maxRedirects)
@@ -402,6 +403,22 @@ func makeCheckRedirectFunc(redirectType RedirectFlow, maxRedirects int) checkRed
402403
}
403404
}
404405

406+
// normalizeHost strips default ports (80 for http, 443 for https) from
407+
// the URL host so that "example.com:80" and "example.com" compare equal.
408+
func normalizeHost(u *url.URL) string {
409+
host, port, err := net.SplitHostPort(u.Host)
410+
if err != nil {
411+
return u.Host
412+
}
413+
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
414+
if strings.Contains(host, ":") {
415+
return "[" + host + "]"
416+
}
417+
return host
418+
}
419+
return u.Host
420+
}
421+
405422
func checkMaxRedirects(req *http.Request, via []*http.Request, maxRedirects int) error {
406423
if maxRedirects == 0 {
407424
if len(via) > defaultMaxRedirects {
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package httpclientpool
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"testing"
7+
)
8+
9+
func TestNormalizeHost(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
scheme string
13+
host string
14+
want string
15+
}{
16+
// No port
17+
{"http no port", "http", "example.com", "example.com"},
18+
{"https no port", "https", "example.com", "example.com"},
19+
20+
// Default ports stripped
21+
{"http default port 80", "http", "example.com:80", "example.com"},
22+
{"https default port 443", "https", "example.com:443", "example.com"},
23+
24+
// Non-default ports preserved
25+
{"http non-default port", "http", "example.com:8080", "example.com:8080"},
26+
{"https non-default port", "https", "example.com:8443", "example.com:8443"},
27+
28+
// Cross-scheme default ports preserved (443 is not default for http)
29+
{"http with port 443", "http", "example.com:443", "example.com:443"},
30+
{"https with port 80", "https", "example.com:80", "example.com:80"},
31+
32+
// IP addresses
33+
{"ipv4 no port", "http", "127.0.0.1", "127.0.0.1"},
34+
{"ipv4 default port", "http", "127.0.0.1:80", "127.0.0.1"},
35+
{"ipv4 non-default port", "http", "127.0.0.1:8080", "127.0.0.1:8080"},
36+
{"ipv4 https default port", "https", "10.0.0.1:443", "10.0.0.1"},
37+
38+
// IPv6 addresses
39+
{"ipv6 no port", "http", "[::1]", "[::1]"},
40+
{"ipv6 default port http", "http", "[::1]:80", "[::1]"},
41+
{"ipv6 default port https", "https", "[::1]:443", "[::1]"},
42+
{"ipv6 non-default port", "https", "[::1]:8443", "[::1]:8443"},
43+
{"ipv6 full address default port", "http", "[2001:db8::1]:80", "[2001:db8::1]"},
44+
{"ipv6 full address non-default", "http", "[2001:db8::1]:9090", "[2001:db8::1]:9090"},
45+
46+
// Empty host
47+
{"empty host", "http", "", ""},
48+
}
49+
for _, tt := range tests {
50+
t.Run(tt.name, func(t *testing.T) {
51+
u := &url.URL{Scheme: tt.scheme, Host: tt.host}
52+
got := normalizeHost(u)
53+
if got != tt.want {
54+
t.Errorf("normalizeHost(%s://%s) = %q, want %q", tt.scheme, tt.host, got, tt.want)
55+
}
56+
})
57+
}
58+
}
59+
60+
func TestFollowSameHostRedirectWithPort(t *testing.T) {
61+
tests := []struct {
62+
name string
63+
oldURL string
64+
newURL string
65+
shouldAllow bool
66+
}{
67+
// Basic same host
68+
{"same host no port", "http://example.com/a", "http://example.com/b", true},
69+
{"same host same path", "http://example.com/a", "http://example.com/a", true},
70+
71+
// Default port normalization (the bug from #4685)
72+
{"old has :80 new does not", "http://example.com:80/a", "http://example.com/b", true},
73+
{"new has :80 old does not", "http://example.com/a", "http://example.com:80/b", true},
74+
{"both have :80", "http://example.com:80/a", "http://example.com:80/b", true},
75+
{"old has :443 new does not https", "https://example.com:443/a", "https://example.com/b", true},
76+
{"new has :443 old does not https", "https://example.com/a", "https://example.com:443/b", true},
77+
{"both have :443 https", "https://example.com:443/a", "https://example.com:443/b", true},
78+
79+
// Relative redirect (host carried from original request)
80+
{"relative redirect same host", "http://example.com/a", "http://example.com/target", true},
81+
82+
// IP address with default port
83+
{"ip old has :80 new does not", "http://127.0.0.1:80/a", "http://127.0.0.1/b", true},
84+
{"ip new has :80 old does not", "http://127.0.0.1/a", "http://127.0.0.1:80/b", true},
85+
{"ip both no port", "http://127.0.0.1/a", "http://127.0.0.1/b", true},
86+
87+
// Different hosts should be blocked
88+
{"different host", "http://example.com/a", "http://other.com/b", false},
89+
{"different host with port", "http://example.com:80/a", "http://other.com:80/b", false},
90+
{"different ip", "http://127.0.0.1/a", "http://127.0.0.2/b", false},
91+
92+
// Non-default ports
93+
{"same non-default port", "http://example.com:8080/a", "http://example.com:8080/b", true},
94+
{"different non-default port", "http://example.com:8080/a", "http://example.com:9090/b", false},
95+
{"non-default vs no port", "http://example.com:8080/a", "http://example.com/b", false},
96+
{"no port vs non-default", "http://example.com/a", "http://example.com:8080/b", false},
97+
98+
// Cross-scheme port (443 on http is non-default, should not strip)
99+
{"http port 443 vs no port", "http://example.com:443/a", "http://example.com/b", false},
100+
{"https port 80 vs no port", "https://example.com:80/a", "https://example.com/b", false},
101+
102+
// IPv6
103+
{"ipv6 same host", "http://[::1]/a", "http://[::1]/b", true},
104+
{"ipv6 default port normalization", "http://[::1]:80/a", "http://[::1]/b", true},
105+
}
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
checkFn := makeCheckRedirectFunc(FollowSameHostRedirect, 10)
109+
oldReq, _ := http.NewRequest("GET", tt.oldURL, nil)
110+
newReq, _ := http.NewRequest("GET", tt.newURL, nil)
111+
err := checkFn(newReq, []*http.Request{oldReq})
112+
allowed := err == nil
113+
if allowed != tt.shouldAllow {
114+
t.Errorf("redirect from %q to %q: allowed=%v, want %v", tt.oldURL, tt.newURL, allowed, tt.shouldAllow)
115+
}
116+
})
117+
}
118+
}
119+
120+
func TestFollowSameHostRedirectViaHost(t *testing.T) {
121+
// When via[0].Host is set (e.g. Host header override), it takes precedence over URL.Host
122+
checkFn := makeCheckRedirectFunc(FollowSameHostRedirect, 10)
123+
124+
oldReq, _ := http.NewRequest("GET", "http://proxy.internal/a", nil)
125+
oldReq.Host = "example.com"
126+
newReq, _ := http.NewRequest("GET", "http://example.com/b", nil)
127+
128+
err := checkFn(newReq, []*http.Request{oldReq})
129+
if err != nil {
130+
t.Errorf("redirect should be allowed when via[0].Host matches new host, got err: %v", err)
131+
}
132+
133+
// Mismatch: via[0].Host differs from redirect target
134+
oldReq2, _ := http.NewRequest("GET", "http://proxy.internal/a", nil)
135+
oldReq2.Host = "other.com"
136+
newReq2, _ := http.NewRequest("GET", "http://example.com/b", nil)
137+
138+
err = checkFn(newReq2, []*http.Request{oldReq2})
139+
if err == nil {
140+
t.Errorf("redirect should be blocked when via[0].Host differs from new host")
141+
}
142+
}
143+
144+
func TestDontFollowRedirect(t *testing.T) {
145+
checkFn := makeCheckRedirectFunc(DontFollowRedirect, 10)
146+
oldReq, _ := http.NewRequest("GET", "http://example.com/a", nil)
147+
newReq, _ := http.NewRequest("GET", "http://example.com/b", nil)
148+
149+
err := checkFn(newReq, []*http.Request{oldReq})
150+
if err != http.ErrUseLastResponse {
151+
t.Errorf("DontFollowRedirect should always return ErrUseLastResponse, got: %v", err)
152+
}
153+
}
154+
155+
func TestFollowAllRedirect(t *testing.T) {
156+
checkFn := makeCheckRedirectFunc(FollowAllRedirect, 10)
157+
158+
// Same host
159+
oldReq, _ := http.NewRequest("GET", "http://example.com/a", nil)
160+
newReq, _ := http.NewRequest("GET", "http://example.com/b", nil)
161+
if err := checkFn(newReq, []*http.Request{oldReq}); err != nil {
162+
t.Errorf("FollowAllRedirect should allow same host redirect, got: %v", err)
163+
}
164+
165+
// Different host
166+
oldReq2, _ := http.NewRequest("GET", "http://example.com/a", nil)
167+
newReq2, _ := http.NewRequest("GET", "http://other.com/b", nil)
168+
if err := checkFn(newReq2, []*http.Request{oldReq2}); err != nil {
169+
t.Errorf("FollowAllRedirect should allow cross-host redirect, got: %v", err)
170+
}
171+
}
172+
173+
func TestMaxRedirects(t *testing.T) {
174+
// Exceeding explicit max
175+
checkFn := makeCheckRedirectFunc(FollowAllRedirect, 2)
176+
req, _ := http.NewRequest("GET", "http://example.com/c", nil)
177+
via := make([]*http.Request, 3)
178+
for i := range via {
179+
via[i], _ = http.NewRequest("GET", "http://example.com/"+string(rune('a'+i)), nil)
180+
}
181+
if err := checkFn(req, via); err == nil {
182+
t.Errorf("should block after exceeding maxRedirects=2 with 3 via requests")
183+
}
184+
185+
// Within explicit max
186+
checkFn2 := makeCheckRedirectFunc(FollowAllRedirect, 5)
187+
via2 := make([]*http.Request, 3)
188+
for i := range via2 {
189+
via2[i], _ = http.NewRequest("GET", "http://example.com/"+string(rune('a'+i)), nil)
190+
}
191+
if err := checkFn2(req, via2); err != nil {
192+
t.Errorf("should allow within maxRedirects=5 with 3 via requests, got: %v", err)
193+
}
194+
195+
// maxRedirects=0 uses default (10)
196+
checkFn3 := makeCheckRedirectFunc(FollowAllRedirect, 0)
197+
via3 := make([]*http.Request, 11)
198+
for i := range via3 {
199+
via3[i], _ = http.NewRequest("GET", "http://example.com/x", nil)
200+
}
201+
if err := checkFn3(req, via3); err == nil {
202+
t.Errorf("should block after exceeding default maxRedirects (10) with 11 via requests")
203+
}
204+
205+
via4 := make([]*http.Request, 9)
206+
for i := range via4 {
207+
via4[i], _ = http.NewRequest("GET", "http://example.com/x", nil)
208+
}
209+
if err := checkFn3(req, via4); err != nil {
210+
t.Errorf("should allow within default maxRedirects (10) with 9 via requests, got: %v", err)
211+
}
212+
}

0 commit comments

Comments
 (0)