Commit 15961c4783

15961c47839f73ebaeaa1f97c575ce86107e3b3b

parent: 191da4bb52

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 01:44 UTC

web: a failed form action's reason rides a one-shot cookie, not the URL

Action handlers redirected back with ?e=<message>, so the reason
survived a reload, landed in history and bookmarks, and every page
that could show one read the query string. setFlash queues the message
in a short-lived HttpOnly cookie on the redirect and takeFlash reads
and clears it where the page renders. The merge request form keeps its
prefill in the query and the account page keeps ?m= for its success
note.

Closes #119

Layout: unified · split

internal/httpd/account.go +3 −5
@@ -56,7 +56,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
5656 Notice string
5757 Message string
5858 }{s.baseFor(u), "account", keys, pgp, emails, s.cfg.SiteHost(),
59 r.URL.Query().Get("e"), r.URL.Query().Get("m")})
59 s.takeFlash(w, r), r.URL.Query().Get("m")})
6060}
6161
6262// accountSubmit routes the account forms to their commands. Everything
@@ -64,12 +64,10 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
6464func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
6565 back := func(msg, note string) {
6666 q := ""
67 switch {
68 case msg != "":
69 q = "?e=" + url.QueryEscape(msg)
70 case note != "":
67 if note != "" {
7168 q = "?m=" + url.QueryEscape(note)
7269 }
70 s.setFlash(w, msg)
7371 http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
7472 }
7573
internal/httpd/builds.go +1 −1
@@ -45,7 +45,7 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
4545 Jobs []jobView
4646 CanWrite bool
4747 Notice string
48 }{p, builds, jobs, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
48 }{p, builds, jobs, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
4949}
5050
5151func (s *Server) build(w http.ResponseWriter, r *http.Request) {
internal/httpd/flash.go added +44
@@ -0,0 +1,44 @@
1package httpd
2
3import (
4 "net/http"
5 "net/url"
6)
7
8// A form action that fails redirects back to the page it came from with
9// the reason. The reason used to ride the URL as ?e=, so it survived a
10// reload and landed in history and bookmarks. It rides a one-shot cookie
11// now: set on the redirect, read and cleared by the page that renders it
12// (#119).
13const flashCookie = "gitbay_notice"
14
15// setFlash queues msg for the next page render. An empty msg sets
16// nothing.
17func (s *Server) setFlash(w http.ResponseWriter, msg string) {
18 if msg == "" {
19 return
20 }
21 if len(msg) > 300 {
22 msg = msg[:300]
23 }
24 http.SetCookie(w, &http.Cookie{
25 Name: flashCookie, Value: url.QueryEscape(msg), Path: "/",
26 HttpOnly: true, SameSite: http.SameSiteLaxMode,
27 Secure: s.cfg.HTTP.TLS != "off",
28 MaxAge: 60,
29 })
30}
31
32// takeFlash returns the queued message, if any, and clears it.
33func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) string {
34 c, err := r.Cookie(flashCookie)
35 if err != nil || c.Value == "" {
36 return ""
37 }
38 http.SetCookie(w, &http.Cookie{Name: flashCookie, Value: "", Path: "/", MaxAge: -1})
39 msg, err := url.QueryUnescape(c.Value)
40 if err != nil {
41 return ""
42 }
43 return msg
44}
internal/httpd/issueactions.go +1 −7
@@ -3,7 +3,6 @@ package httpd
33import (
44 "fmt"
55 "net/http"
6 "net/url"
76 "strings"
87
98 "gitbay.org/gitbay/internal/store"
@@ -16,12 +15,7 @@ import (
1615func (s *Server) issueRedirect(w http.ResponseWriter, r *http.Request, msg string) {
1716 dest := fmt.Sprintf("/%s/%s/issues/%s",
1817 r.PathValue("owner"), r.PathValue("repo"), r.PathValue("n"))
19 if msg != "" {
20 if len(msg) > 300 {
21 msg = msg[:300]
22 }
23 dest += "?e=" + url.QueryEscape(msg)
24 }
18 s.setFlash(w, msg)
2519 http.Redirect(w, r, dest, http.StatusSeeOther)
2620}
2721
internal/httpd/mractions.go +5 −14
@@ -22,12 +22,7 @@ import (
2222func (s *Server) mrRedirect(w http.ResponseWriter, r *http.Request, msg string) {
2323 dest := fmt.Sprintf("/%s/%s/mrs/%s",
2424 r.PathValue("owner"), r.PathValue("repo"), r.PathValue("n"))
25 if msg != "" {
26 if len(msg) > 300 {
27 msg = msg[:300]
28 }
29 dest += "?e=" + url.QueryEscape(msg)
30 }
25 s.setFlash(w, msg)
3126 http.Redirect(w, r, dest, http.StatusSeeOther)
3227}
3328
@@ -35,12 +30,7 @@ func (s *Server) mrRedirect(w http.ResponseWriter, r *http.Request, msg string)
3530func (s *Server) mrDiffRedirect(w http.ResponseWriter, r *http.Request, msg string) {
3631 dest := fmt.Sprintf("/%s/%s/mrs/%s?view=diff",
3732 r.PathValue("owner"), r.PathValue("repo"), r.PathValue("n"))
38 if msg != "" {
39 if len(msg) > 300 {
40 msg = msg[:300]
41 }
42 dest += "&e=" + url.QueryEscape(msg)
43 }
33 s.setFlash(w, msg)
4434 http.Redirect(w, r, dest, http.StatusSeeOther)
4535}
4636
@@ -163,7 +153,7 @@ func (s *Server) mrCreateForm(w http.ResponseWriter, r *http.Request, u store.Us
163153 s.render(w, "mrnew.html", mrNewPage{
164154 repoPage: p, Branches: branches,
165155 Source: q.Get("source"), Target: target,
166 Title: q.Get("title"), Body: q.Get("body"), Notice: q.Get("e"),
156 Title: q.Get("title"), Body: q.Get("body"), Notice: s.takeFlash(w, r),
167157 })
168158}
169159
@@ -178,7 +168,8 @@ func (s *Server) mrCreateSubmit(w http.ResponseWriter, r *http.Request, u store.
178168 body := strings.TrimSpace(r.FormValue("body"))
179169
180170 back := func(msg string) {
181 q := url.Values{"source": {source}, "target": {target}, "title": {title}, "body": {body}, "e": {msg}}
171 q := url.Values{"source": {source}, "target": {target}, "title": {title}, "body": {body}}
172 s.setFlash(w, msg)
182173 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/new?%s", p.Repo.Path(), q.Encode()), http.StatusSeeOther)
183174 }
184175 if source == "" || title == "" {
internal/httpd/orgweb.go +2 −6
@@ -2,7 +2,6 @@ package httpd
22
33import (
44 "net/http"
5 "net/url"
65 "strings"
76
87 "gitbay.org/gitbay/internal/store"
@@ -45,11 +44,8 @@ func (s *Server) orgAdminView(viewer store.User, kind, name string) (teams []tea
4544func (s *Server) orgSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
4645 owner := r.PathValue("owner")
4746 back := func(msg string) {
48 dest := "/" + owner
49 if msg != "" {
50 dest += "?e=" + url.QueryEscape(msg)
51 }
52 http.Redirect(w, r, dest, http.StatusSeeOther)
47 s.setFlash(w, msg)
48 http.Redirect(w, r, "/"+owner, http.StatusSeeOther)
5349 }
5450 field := r.FormValue("field")
5551 team := strings.TrimSpace(r.FormValue("team"))
internal/httpd/releaseactions.go +1 −7
@@ -3,7 +3,6 @@ package httpd
33import (
44 "fmt"
55 "net/http"
6 "net/url"
76 "strings"
87
98 "gitbay.org/gitbay/internal/store"
@@ -15,12 +14,7 @@ import (
1514
1615func (s *Server) backTo(w http.ResponseWriter, r *http.Request, page, msg string) {
1716 dest := fmt.Sprintf("/%s/%s/%s", r.PathValue("owner"), r.PathValue("repo"), page)
18 if msg != "" {
19 if len(msg) > 300 {
20 msg = msg[:300]
21 }
22 dest += "?e=" + url.QueryEscape(msg)
23 }
17 s.setFlash(w, msg)
2418 http.Redirect(w, r, dest, http.StatusSeeOther)
2519}
2620
internal/httpd/settings.go +2 −8
@@ -3,7 +3,6 @@ package httpd
33import (
44 "fmt"
55 "net/http"
6 "net/url"
76 "strings"
87
98 "gitbay.org/gitbay/internal/gitutil"
@@ -39,18 +38,13 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us
3938 s.render(w, "settings.html", settingsPage{
4039 repoPage: p, Topics: topics, Branches: branches,
4140 DepsEnabled: depsErr == nil,
42 Notice: r.URL.Query().Get("e"),
41 Notice: s.takeFlash(w, r),
4342 })
4443}
4544
4645func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg string) {
4746 dest := fmt.Sprintf("/%s/%s/settings", r.PathValue("owner"), r.PathValue("repo"))
48 if msg != "" {
49 if len(msg) > 300 {
50 msg = msg[:300]
51 }
52 dest += "?e=" + url.QueryEscape(msg)
53 }
47 s.setFlash(w, msg)
5448 http.Redirect(w, r, dest, http.StatusSeeOther)
5549}
5650
internal/httpd/web.go +4 −4
@@ -469,7 +469,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
469469 Notice string
470470 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
471471 d.Repos, d.Members, d.Orgs,
472 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
472 weeks, activityTotal, teams, canAdmin, s.takeFlash(w, r)})
473473}
474474
475475func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
@@ -665,7 +665,7 @@ func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
665665 FreeTags []string
666666 CanWrite bool
667667 Notice string
668 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
668 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
669669}
670670
671671// releaseAsset streams one uploaded asset. Tags containing '/' are not
@@ -1640,7 +1640,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
16401640 LabelColors map[string]template.CSS
16411641 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
16421642 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1643 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1643 milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
16441644}
16451645
16461646// canEditItem: the author or anyone with write access may edit.
@@ -1817,7 +1817,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
18171817 Stacked []store.MR
18181818 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
18191819 reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1820 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1820 canWrite, unresolved, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
18211821}
18221822
18231823func (s *Server) refs(w http.ResponseWriter, r *http.Request) {