1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "crypto/sha256"
7 "encoding/base64"
8 "html"
9 "math/bits"
10 "net/url"
11 "regexp"
12 "strconv"
13 "testing"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22var (
23 reFormAttr = func(name string) *regexp.Regexp { return regexp.MustCompile(`data-` + name + `="([^"]*)"`) }
24 reFormField = func(name string) *regexp.Regexp {
25 return regexp.MustCompile(`<input type="hidden" name="` + name + `" value="([^"]*)"`)
26 }
27 rePendingActions = regexp.MustCompile(`action="(/-/inbox/pending/[^"]+/(approve|reject|ban))"`)
28)
29
30// testPoW is the difficulty the tests lower the proof of work to.
31const testPoW = 8
32
33// solvePoW finds the smallest counter n with SHA-256(nonce || ":" || n) having
34// bits leading zero bits, as the browser does.
35func solvePoW(t *testing.T, nonce string, difficulty int) string {
36 t.Helper()
37 raw, err := url.PathUnescape(nonce)
38 require.NoError(t, err)
39 b, err := decodeB64URL(raw)
40 require.NoError(t, err)
41 for n := 0; n < 1<<26; n++ {
42 sum := sha256.Sum256(append(append(append([]byte{}, b...), ':'), strconv.Itoa(n)...))
43 z := 0
44 for _, c := range sum {
45 if c == 0 {
46 z += 8
47 continue
48 }
49 z += bits.LeadingZeros8(c)
50 break
51 }
52 if z >= difficulty {
53 return strconv.Itoa(n)
54 }
55 }
56 t.Fatal("no solution")
57 return ""
58}
59
60// formValues reads the challenge out of the HTML of the public form and
61// solves it.
62func formValues(t *testing.T, body string) url.Values {
63 t.Helper()
64 get := func(re *regexp.Regexp) string {
65 m := re.FindStringSubmatch(body)
66 require.Len(t, m, 2, "the form carries %s", re)
67 return html.UnescapeString(m[1])
68 }
69 diff, err := strconv.Atoi(get(reFormAttr("difficulty")))
70 require.NoError(t, err)
71 require.Equal(t, testPoW, diff, "the test difficulty")
72 return url.Values{
73 "token": {get(reFormField("token"))}, "form_token": {get(reFormField("form_token"))},
74 "solution": {solvePoW(t, get(reFormAttr("nonce")), diff)},
75 }
76}
77
78// The public issue form end to end: a visitor solves the proof of work and
79// submits, the owner sees the issue awaiting moderation in the inbox and
80// approves it, and a rejection with a ban shuts the same address out.
81func TestWebPublicIssueFormEndToEnd(t *testing.T) {
82 h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
83 cli := &cliHarness{harness: h, url: h.srv.URL}
84 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
85 require.NoError(t, err)
86 b := browser{h}
87 token := cli.signIn(t)
88
89 pub := domain.IssuesPublic
90 _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
91 require.NoError(t, err)
92 site := map[string]string{"Origin": publicOrigin}
93
94 // The visitor reads the list and finds the button.
95 list := b.get("/proj/issues", nil)
96 require.Equal(t, 200, list.status, list.body)
97 assert.Contains(t, list.body, `href="/proj/issues/new"`)
98
99 // The form, with a challenge, never stored.
100 form := b.get("/proj/issues/new", nil)
101 require.Equal(t, 200, form.status, form.body)
102 assert.Equal(t, "no-store", form.hdr.Get("Cache-Control"))
103 assert.Contains(t, form.body, "<noscript>")
104 assert.Contains(t, form.body, `name="website"`)
105 v := formValues(t, form.body)
106 v.Set("title", "Search is <b>slow</b>")
107 v.Set("body", "see [docs](https://example.org/d) and [x](javascript:alert(1))\r\n**bold**")
108 v.Set("author", "Alex")
109
110 // A bad cross-origin POST is refused before anything is checked.
111 assert.Equal(t, 403, b.do("POST", "/proj/issues/new", nil, v.Encode()).status)
112 assert.Equal(t, 403, b.do("POST", "/proj/issues/new", map[string]string{"Origin": "https://evil.example"}, v.Encode()).status)
113
114 // The submission passes.
115 sent := b.do("POST", "/proj/issues/new", site, v.Encode())
116 require.Equal(t, 303, sent.status, sent.body)
117 assert.Equal(t, "/proj/issues/submitted", sent.hdr.Get("Location"))
118 assert.Equal(t, "no-store", sent.hdr.Get("Cache-Control"))
119 thanks := b.get("/proj/issues/submitted", nil)
120 assert.Equal(t, 200, thanks.status)
121 assert.Contains(t, thanks.body, "Thanks, your issue is waiting for moderation.")
122
123 // The same challenge cannot be used twice.
124 replay := b.do("POST", "/proj/issues/new", site, v.Encode())
125 assert.Equal(t, 400, replay.status)
126 assert.Contains(t, replay.body, "could not be verified")
127
128 // Nothing is public yet.
129 assert.NotContains(t, b.get("/proj/issues", nil).body, "slow")
130
131 // A text error shows the form again with a new challenge and the text.
132 form2 := b.get("/proj/issues/new", nil)
133 v2 := formValues(t, form2.body)
134 v2.Set("title", "")
135 v2.Set("body", "kept text")
136 again := b.do("POST", "/proj/issues/new", site, v2.Encode())
137 require.Equal(t, 422, again.status, again.body)
138 assert.Contains(t, again.body, "kept text")
139 assert.Contains(t, again.body, "The title is empty.")
140 v3 := formValues(t, again.body)
141 assert.NotEqual(t, v2.Get("token"), v3.Get("token"))
142
143 // The owner finds it awaiting moderation, as plain text.
144 inbox := b.get("/-/inbox", as(token))
145 require.Equal(t, 200, inbox.status, inbox.body)
146 assert.Contains(t, inbox.body, "<strong>1</strong> awaiting moderation")
147 assert.Contains(t, inbox.body, "Search is <b>slow</b>")
148 assert.Contains(t, inbox.body, "**bold**")
149 assert.NotContains(t, inbox.body, "<strong>bold")
150 acts := rePendingActions.FindAllStringSubmatch(inbox.body, -1)
151 require.Len(t, acts, 3)
152 actions := map[string]string{}
153 for _, a := range acts {
154 actions[a[2]] = a[1]
155 }
156
157 // An anonymous POST to a moderation path is refused.
158 assert.Equal(t, 403, b.do("POST", actions["approve"], site, "").status)
159
160 // The owner shares the numbers with merge requests and issues: approve.
161 ok := b.form(actions["approve"], token, nil)
162 require.Equal(t, 303, ok.status, ok.body)
163 assert.Equal(t, "/-/inbox", ok.hdr.Get("Location"))
164 assert.Equal(t, 404, b.form(actions["approve"], token, nil).status, "approved once")
165 shown := b.get("/proj/issues/1", nil)
166 require.Equal(t, 200, shown.status, shown.body)
167 assert.Contains(t, shown.body, "Search is <b>slow</b>")
168 assert.Contains(t, shown.body, `href="https://example.org/d"`)
169 assert.Contains(t, shown.body, `rel="nofollow noopener noreferrer ugc"`)
170 assert.NotContains(t, shown.body, "javascript:")
171 assert.Contains(t, shown.body, "Alex")
172 assert.Contains(t, b.get("/proj/issues", nil).body, `href="/proj/issues/1"`)
173 empty := b.get("/-/inbox", as(token))
174 assert.Contains(t, empty.body, "<strong>0</strong> awaiting moderation")
175
176 // Reject and ban: the same address is then refused with the generic page.
177 form4 := b.get("/proj/issues/new", nil)
178 v4 := formValues(t, form4.body)
179 v4.Set("title", "spam")
180 require.Equal(t, 303, b.do("POST", "/proj/issues/new", site, v4.Encode()).status)
181 inbox2 := b.get("/-/inbox", as(token))
182 var ban string
183 for _, a := range rePendingActions.FindAllStringSubmatch(inbox2.body, -1) {
184 if a[2] == "ban" {
185 ban = a[1]
186 }
187 }
188 require.NotEmpty(t, ban)
189 require.Equal(t, 303, b.form(ban, token, nil).status)
190
191 form5 := b.get("/proj/issues/new", nil)
192 require.Equal(t, 200, form5.status, "the form is still served; the ban bites at submission")
193 v5 := formValues(t, form5.body)
194 v5.Set("title", "more spam")
195 banned := b.do("POST", "/proj/issues/new", site, v5.Encode())
196 assert.Equal(t, 400, banned.status)
197 assert.Contains(t, banned.body, "The submission could not be verified. Reload the page and try again.")
198 assert.NotContains(t, banned.body, "ban")
199 assert.Contains(t, b.get("/-/inbox", as(token)).body, "<strong>0</strong> awaiting moderation")
200}
201
202// A repository that is not public with public issues has no form.
203func TestWebPublicIssueFormIsNotThere(t *testing.T) {
204 h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
205 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
206 require.NoError(t, err)
207 b := browser{h}
208 site := map[string]string{"Origin": publicOrigin}
209 body := url.Values{"title": {"x"}}.Encode()
210
211 missing := b.get("/nope/issues/new", nil)
212 for name, mode := range map[string]domain.IssuesMode{"off": domain.IssuesOff, "owner": domain.IssuesOwner} {
213 _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &mode})
214 require.NoError(t, err)
215 got := b.get("/proj/issues/new", nil)
216 assert.Equal(t, 404, got.status, name)
217 assert.Equal(t, missing.body, got.body, name+": the same page as a missing repository")
218 assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, name)
219 }
220
221 pub := domain.IssuesPublic
222 _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
223 require.NoError(t, err)
224 priv := domain.VisibilityPrivate
225 _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
226 require.NoError(t, err)
227 assert.Equal(t, 404, b.get("/proj/issues/new", nil).status, "private")
228 assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, "private")
229}
230
231func decodeB64URL(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }