Commit e635d41

Antonio Mika  ·  2026-10-24 18:25:23 -0400 EDT
parent 759b2ff
fix(pgs): preserve request URL through password login
3 files changed,  +164, -8
+1, -0
......@@ -18,6 +18,7 @@
1818
1919 <form method="POST" action="/pgs/login">
2020 <input type="hidden" name="project" value="{{.ProjectName}}">
21+ <input type="hidden" name="redirect" value="{{.Redirect}}">
2122 <label for="password">Enter password:</label>
2223 <div class="flex gap">
2324 <input type="password" id="password" name="password" placeholder="Password" required autofocus>
+25, -8
......@@ -3,6 +3,7 @@ package pgs
33 import (
44 "log/slog"
55 "net/http"
6+ "net/url"
67 "strings"
78 "time"
89
......@@ -23,6 +24,7 @@ func getCookieName(projectName string) string {
2324 type loginFormData struct {
2425 ProjectName string
2526 Error string
27+ Redirect string
2628 }
2729
2830 // serveLoginFormWithConfig renders and serves the login form using templates.
......@@ -36,8 +38,13 @@ func serveLoginFormWithConfig(w http.ResponseWriter, r *http.Request, project *d
3638 data := loginFormData{
3739 ProjectName: project.Name,
3840 Error: errorMsg,
41+ Redirect: r.URL.RequestURI(),
3942 }
4043
44+ renderLoginForm(w, data, cfg, logger)
45+}
46+
47+func renderLoginForm(w http.ResponseWriter, data loginFormData, cfg *Config, logger *slog.Logger) {
4148 w.WriteHeader(http.StatusForbidden)
4249
4350 ts, err := router.RenderTemplate(staticDir, []string{cfg.StaticPath("html/login.page.tmpl")})
......@@ -70,6 +77,22 @@ func handleLogin(w http.ResponseWriter, r *http.Request, cfg *Config) {
7077
7178 projectName := strings.TrimSpace(r.FormValue("project"))
7279 password := r.FormValue("password")
80+ redirectPath := r.PostForm.Get("redirect")
81+ if redirectPath == "" {
82+ redirectPath = r.Header.Get("X-PGS-Referer")
83+ }
84+ redirectURL, err := url.Parse(redirectPath)
85+ if err != nil || !strings.HasPrefix(redirectPath, "/") || strings.HasPrefix(redirectPath, "//") || strings.Contains(redirectURL.Path, `\`) {
86+ redirectPath = "/"
87+ }
88+
89+ invalidPassword := func() {
90+ renderLoginForm(w, loginFormData{
91+ ProjectName: projectName,
92+ Error: "Invalid password",
93+ Redirect: redirectPath,
94+ }, cfg, cfg.Logger)
95+ }
7396
7497 if projectName == "" {
7598 http.Error(w, "missing project name", http.StatusBadRequest)
......@@ -77,8 +100,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request, cfg *Config) {
77100 }
78101
79102 if password == "" {
80- // Redirect back with error
81- http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther)
103+ invalidPassword()
82104 return
83105 }
84106
......@@ -119,7 +141,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request, cfg *Config) {
119141 storedPass := project.Acl.Data[0]
120142 if !validatePassword(storedPass, password) {
121143 cfg.Logger.Info("invalid password attempt", "projectName", projectName)
122- http.Redirect(w, r, "/?error=invalid", http.StatusSeeOther)
144+ invalidPassword()
123145 return
124146 }
125147
......@@ -137,11 +159,6 @@ func handleLogin(w http.ResponseWriter, r *http.Request, cfg *Config) {
137159 }
138160 http.SetCookie(w, cookie)
139161
140- redirectPath := r.Header.Get("X-PGS-Referer")
141- if redirectPath == "" || !strings.HasPrefix(redirectPath, "/") {
142- redirectPath = "/"
143- }
144-
145162 cfg.Logger.Info("successful login", "projectName", projectName, "username", props.Username)
146163 http.Redirect(w, r, redirectPath, http.StatusSeeOther)
147164 }
+138, -0
......@@ -0,0 +1,138 @@
1+package pgs
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+
12+ "github.com/picosh/pico/pkg/shared"
13+ "github.com/picosh/pico/pkg/storage"
14+ "golang.org/x/net/html"
15+)
16+
17+func loginFormValues(t *testing.T, body string) url.Values {
18+ t.Helper()
19+ values := url.Values{}
20+ tokens := html.NewTokenizer(strings.NewReader(body))
21+ for {
22+ switch tokens.Next() {
23+ case html.ErrorToken:
24+ if err := tokens.Err(); err != io.EOF {
25+ t.Fatal(err)
26+ }
27+ if values.Get("redirect") == "" {
28+ t.Fatal("login form is missing redirect")
29+ }
30+ return values
31+ case html.StartTagToken, html.SelfClosingTagToken:
32+ token := tokens.Token()
33+ if token.Data != "input" {
34+ continue
35+ }
36+ var name, value string
37+ for _, attr := range token.Attr {
38+ switch attr.Key {
39+ case "name":
40+ name = attr.Val
41+ case "value":
42+ value = attr.Val
43+ }
44+ }
45+ if name != "" {
46+ values.Set(name, value)
47+ }
48+ }
49+ }
50+}
51+
52+func TestLoginPreservesRequestURL(t *testing.T) {
53+ t.Chdir("../../..")
54+ logger := slog.Default()
55+ dbpool := NewPgsDb(logger)
56+ project, err := dbpool.FindProjectByName(dbpool.Users[0].ID, "test")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ project.Acl.Type = "http-pass"
61+ project.Acl.Data = []string{"test-password"}
62+ st, err := storage.NewStorageMemory(map[string]map[string]string{
63+ shared.GetAssetBucketName(dbpool.Users[0].ID): {},
64+ })
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ pubsub := NewPubsubChan()
69+ t.Cleanup(func() { _ = pubsub.Close() })
70+ cfg := NewConfig(logger, dbpool, st, pubsub)
71+ cfg.Domain = "pgs.test"
72+ router := NewWebRouter(cfg)
73+
74+ paths := []string{
75+ "/",
76+ "/?page=2&sort=asc",
77+ "/docs/page.html?tag=one&tag=two&empty=&q=a+b%26c%3Dd",
78+ "/docs/a%20b.html?next=%2Fother%3Fx%3D1%26y%3D2",
79+ "/docs/page.html?redirect=%2Felsewhere&error=invalid",
80+ }
81+ for _, path := range paths {
82+ t.Run(path, func(t *testing.T) {
83+ response := httptest.NewRecorder()
84+ router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, dbpool.mkpath(path), nil))
85+ if response.Code != http.StatusForbidden {
86+ t.Fatalf("login form status = %d", response.Code)
87+ }
88+ form := loginFormValues(t, response.Body.String())
89+ if got := form.Get("redirect"); got != path {
90+ t.Fatalf("form redirect = %q, want %q", got, path)
91+ }
92+
93+ for _, password := range []string{"wrong-password", "", "test-password"} {
94+ form.Set("password", password)
95+ request := httptest.NewRequest(http.MethodPost, dbpool.mkpath("/pgs/login"), strings.NewReader(form.Encode()))
96+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
97+ response = httptest.NewRecorder()
98+ router.ServeHTTP(response, request)
99+ if password != "test-password" {
100+ if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "Invalid password") {
101+ t.Fatalf("invalid password response: %d %s", response.Code, response.Body.String())
102+ }
103+ form = loginFormValues(t, response.Body.String())
104+ if got := form.Get("redirect"); got != path {
105+ t.Fatalf("retry redirect = %q, want %q", got, path)
106+ }
107+ continue
108+ }
109+ if response.Code != http.StatusSeeOther {
110+ t.Fatalf("login status = %d", response.Code)
111+ }
112+ if got := response.Header().Get("Location"); got != path {
113+ t.Errorf("redirect = %q, want %q", got, path)
114+ }
115+ cookies := response.Result().Cookies()
116+ if len(cookies) != 1 || cookies[0].Name != getCookieName(project.Name) || cookies[0].Value != project.ID {
117+ t.Errorf("login did not set the project session cookie")
118+ }
119+ }
120+ })
121+ }
122+}
123+
124+func TestLoginRejectsExternalRedirect(t *testing.T) {
125+ t.Chdir("../../..")
126+ for _, target := range []string{"", "https://example.com/path", "//example.com/path", `/\example.com/path`, "relative/path", "/\nLocation: https://example.com"} {
127+ t.Run(target, func(t *testing.T) {
128+ form := url.Values{"project": {"test"}, "redirect": {target}}
129+ request := httptest.NewRequest(http.MethodPost, "https://example.pgs.test/pgs/login", strings.NewReader(form.Encode()))
130+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
131+ response := httptest.NewRecorder()
132+ handleLogin(response, request, &Config{Logger: slog.Default()})
133+ if got := loginFormValues(t, response.Body.String()).Get("redirect"); got != "/" {
134+ t.Errorf("redirect = %q, want /", got)
135+ }
136+ })
137+ }
138+}