Skip to content

Commit eef6211

Browse files
authored
Merge commit from fork
1 parent 4343194 commit eef6211

2 files changed

Lines changed: 107 additions & 21 deletions

File tree

main.go

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
htemplate "html/template"
78
"log/slog"
89
"math/rand"
910
"net"
@@ -15,7 +16,6 @@ import (
1516
"slices"
1617
"strings"
1718
"sync"
18-
"text/template"
1919
"time"
2020

2121
"github.com/libops/captcha-protect/internal/helper"
@@ -93,7 +93,7 @@ type CaptchaProtect struct {
9393
googlebotIPs *helper.GooglebotIPs
9494
captchaConfig CaptchaConfig
9595
exemptIps []*net.IPNet
96-
tmpl *template.Template
96+
tmpl *htemplate.Template
9797
ipv4Mask net.IPMask
9898
ipv6Mask net.IPMask
9999
protectRoutesRegex []*regexp.Regexp
@@ -116,6 +116,14 @@ type captchaResponse struct {
116116
Success bool `json:"success"`
117117
}
118118

119+
type challengeData struct {
120+
SiteKey string
121+
FrontendJS string
122+
FrontendKey string
123+
ChallengeURL string
124+
Destination string
125+
}
126+
119127
func CreateConfig() *Config {
120128
return &Config{
121129
RateLimit: 20,
@@ -217,18 +225,18 @@ func NewCaptchaProtect(ctx context.Context, next http.Handler, config *Config, n
217225
}
218226
config.ParseHttpMethods(log)
219227

220-
var tmpl *template.Template
228+
var tmpl *htemplate.Template
221229
if _, err := os.Stat(config.ChallengeTmpl); os.IsNotExist(err) {
222230
log.Warn("Unable to find template file. Using default template", "challengeTmpl", config.ChallengeTmpl)
223231
ts := helper.GetDefaultTmpl()
224-
tmpl, err = template.New("challenge").Parse(ts)
232+
tmpl, err = htemplate.New("challenge").Parse(ts)
225233
if err != nil {
226234
return nil, fmt.Errorf("unable to parse challenge template: %v", err)
227235
}
228236
} else if err != nil {
229237
return nil, fmt.Errorf("error checking for template file %s: %v", config.ChallengeTmpl, err)
230238
} else {
231-
tmpl, err = template.ParseFiles(config.ChallengeTmpl)
239+
tmpl, err = htemplate.ParseFiles(config.ChallengeTmpl)
232240
if err != nil {
233241
return nil, fmt.Errorf("unable to parse challenge template file %s: %v", config.ChallengeTmpl, err)
234242
}
@@ -590,12 +598,12 @@ func (bc *CaptchaProtect) servePojJS(rw http.ResponseWriter) {
590598
func (bc *CaptchaProtect) serveChallengePage(rw http.ResponseWriter, destination string) {
591599
activeConfig := bc.getActiveCaptchaConfig()
592600

593-
d := map[string]string{
594-
"SiteKey": bc.config.SiteKey,
595-
"FrontendJS": activeConfig.js,
596-
"FrontendKey": activeConfig.key,
597-
"ChallengeURL": bc.config.ChallengeURL,
598-
"Destination": destination,
601+
d := challengeData{
602+
SiteKey: bc.config.SiteKey,
603+
FrontendJS: activeConfig.js,
604+
FrontendKey: activeConfig.key,
605+
ChallengeURL: bc.config.ChallengeURL,
606+
Destination: destination,
599607
}
600608

601609
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -662,16 +670,8 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
662670
if success {
663671
bc.verifiedCache.Set(ip, true, exp)
664672

665-
destination := req.FormValue("destination")
666-
if destination == "" {
667-
destination = "%2F"
668-
}
669-
u, err := url.QueryUnescape(destination)
670-
if err != nil {
671-
bc.log.Error("unable to unescape destination", "destination", destination, "err", err)
672-
u = "/"
673-
}
674-
http.Redirect(rw, req, u, http.StatusFound)
673+
destination := normalizeDestination(req.FormValue("destination"))
674+
http.Redirect(rw, req, destination, http.StatusFound)
675675
return http.StatusFound
676676
}
677677

@@ -680,6 +680,42 @@ func (bc *CaptchaProtect) verifyChallengePage(rw http.ResponseWriter, req *http.
680680
return http.StatusForbidden
681681
}
682682

683+
func normalizeDestination(destination string) string {
684+
if destination == "" {
685+
return "/"
686+
}
687+
688+
unescaped, err := url.QueryUnescape(destination)
689+
if err == nil && unescaped != destination {
690+
if sanitized := sanitizeDestination(unescaped); sanitized != "/" || unescaped == "/" {
691+
return sanitized
692+
}
693+
}
694+
695+
return sanitizeDestination(destination)
696+
}
697+
698+
func sanitizeDestination(destination string) string {
699+
if destination == "" {
700+
return "/"
701+
}
702+
703+
u, err := url.Parse(destination)
704+
if err != nil {
705+
return "/"
706+
}
707+
708+
if u.IsAbs() || u.Host != "" {
709+
return "/"
710+
}
711+
712+
if !strings.HasPrefix(u.Path, "/") {
713+
return "/"
714+
}
715+
716+
return u.RequestURI()
717+
}
718+
683719
func (bc *CaptchaProtect) serveStatsPage(rw http.ResponseWriter, ip string) {
684720
// only allow excluded IPs from viewing
685721
if !helper.IsIpExcluded(ip, bc.exemptIps) {

main_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1895,3 +1895,53 @@ func TestGooglebotIPCheckLoopInitialFetchError(t *testing.T) {
18951895
t.Fatal("did not expect googlebot IPs to update when initial fetch fails")
18961896
}
18971897
}
1898+
1899+
func TestServeChallengePageEscapesDestination(t *testing.T) {
1900+
config := CreateConfig()
1901+
config.SiteKey = "test-site-key"
1902+
config.SecretKey = "test-secret-key"
1903+
config.ProtectRoutes = []string{"/"}
1904+
config.ChallengeStatusCode = http.StatusTooManyRequests
1905+
1906+
bc, err := NewCaptchaProtect(context.Background(), nil, config, "test")
1907+
if err != nil {
1908+
t.Fatalf("Failed to create CaptchaProtect: %v", err)
1909+
}
1910+
1911+
rr := httptest.NewRecorder()
1912+
maliciousDestination := `" /><script>alert(1)</script>`
1913+
1914+
bc.serveChallengePage(rr, maliciousDestination)
1915+
1916+
body := rr.Body.String()
1917+
if !strings.Contains(body, `value="&#34; /&gt;&lt;script&gt;alert(1)&lt;/script&gt;"`) {
1918+
t.Fatalf("expected destination to be HTML-escaped in attribute context, body=%q", body)
1919+
}
1920+
if strings.Contains(body, `value="" /><script>alert(1)</script>`) {
1921+
t.Fatalf("expected raw destination not to be injected into HTML, body=%q", body)
1922+
}
1923+
}
1924+
1925+
func TestNormalizeDestination(t *testing.T) {
1926+
tests := []struct {
1927+
name string
1928+
destination string
1929+
want string
1930+
}{
1931+
{name: "empty", destination: "", want: "/"},
1932+
{name: "decoded local path", destination: "/home?foo=bar", want: "/home?foo=bar"},
1933+
{name: "encoded local path", destination: "%2Fhome%3Ffoo%3Dbar", want: "/home?foo=bar"},
1934+
{name: "absolute url", destination: "https://evil.com/phish", want: "/"},
1935+
{name: "protocol relative url", destination: "//evil.com/phish", want: "/"},
1936+
{name: "relative path", destination: "home", want: "/"},
1937+
{name: "invalid escape", destination: "%ZZ", want: "/"},
1938+
}
1939+
1940+
for _, tt := range tests {
1941+
t.Run(tt.name, func(t *testing.T) {
1942+
if got := normalizeDestination(tt.destination); got != tt.want {
1943+
t.Fatalf("normalizeDestination(%q) = %q, want %q", tt.destination, got, tt.want)
1944+
}
1945+
})
1946+
}
1947+
}

0 commit comments

Comments
 (0)