Skip to content

Commit c66cbb8

Browse files
author
Chris Palmer
authored
Protect save and update from CSRF (#117)
Also, fix a lint. Fixes #116 Signed-off-by: Chris Palmer <[email protected]>
1 parent 6c79b50 commit c66cbb8

File tree

6 files changed

+89
-8
lines changed

6 files changed

+89
-8
lines changed

golink.go

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,20 @@ import (
3939
"tailscale.com/util/dnsname"
4040
)
4141

42-
const defaultHostname = "go"
42+
const (
43+
defaultHostname = "go"
44+
45+
// Used as a placeholder short name for generating the XSRF defense token,
46+
// when creating new links.
47+
newShortName = ".new"
48+
49+
// If the caller sends this header set to a non-empty value, we will allow
50+
// them to make the call even without an XSRF token. JavaScript in browser
51+
// cannot set this header, per the [Fetch Spec].
52+
//
53+
// [Fetch Spec]: https://fetch.spec.whatwg.org
54+
secHeaderName = "Sec-Golink"
55+
)
4356

4457
var (
4558
verbose = flag.Bool("verbose", false, "be verbose")
@@ -254,11 +267,19 @@ type visitData struct {
254267
NumClicks int
255268
}
256269

257-
// homeData is the data used by the homeTmpl template.
270+
// homeData is the data used by homeTmpl.
258271
type homeData struct {
259272
Short string
260273
Long string
261274
Clicks []visitData
275+
XSRF string
276+
}
277+
278+
// deleteData is the data used by deleteTmpl.
279+
type deleteData struct {
280+
Short string
281+
Long string
282+
XSRF string
262283
}
263284

264285
var xsrfKey string
@@ -416,10 +437,16 @@ func serveHome(w http.ResponseWriter, r *http.Request, short string) {
416437
}
417438
}
418439

440+
cu, err := currentUser(r)
441+
if err != nil {
442+
http.Error(w, err.Error(), http.StatusInternalServerError)
443+
return
444+
}
419445
homeTmpl.Execute(w, homeData{
420446
Short: short,
421447
Long: long,
422448
Clicks: clicks,
449+
XSRF: xsrftoken.Generate(xsrfKey, cu.login, newShortName),
423450
})
424451
}
425452

@@ -739,6 +766,11 @@ func serveDelete(w http.ResponseWriter, r *http.Request) {
739766
return
740767
}
741768

769+
// Deletion by CLI has never worked because it has always required the XSRF
770+
// token. (Refer to commit c7ac33d04c33743606f6224009a5c73aa0b8dec0.) If we
771+
// want to enable deletion via CLI and to honor allowUnknownUsers for
772+
// deletion, we could change the below to a call to isRequestAuthorized. For
773+
// now, always require the XSRF token, thus maintaining the status quo.
742774
if !xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, cu.login, short) {
743775
http.Error(w, "invalid XSRF token", http.StatusBadRequest)
744776
return
@@ -750,7 +782,11 @@ func serveDelete(w http.ResponseWriter, r *http.Request) {
750782
}
751783
deleteLinkStats(link)
752784

753-
deleteTmpl.Execute(w, link)
785+
deleteTmpl.Execute(w, deleteData{
786+
Short: link.Short,
787+
Long: link.Long,
788+
XSRF: xsrftoken.Generate(xsrfKey, cu.login, newShortName),
789+
})
754790
}
755791

756792
// serveSave handles requests to save or update a Link. Both short name and
@@ -787,6 +823,11 @@ func serveSave(w http.ResponseWriter, r *http.Request) {
787823
return
788824
}
789825

826+
if !isRequestAuthorized(r, cu, short) {
827+
http.Error(w, "invalid XSRF token", http.StatusBadRequest)
828+
return
829+
}
830+
790831
// allow transferring ownership to valid users. If empty, set owner to current user.
791832
owner := r.FormValue("owner")
792833
if owner != "" {
@@ -886,8 +927,7 @@ func restoreLastSnapshot() error {
886927
_, err := db.Load(link.Short)
887928
if err == nil {
888929
continue // exists
889-
}
890-
if err != nil && !errors.Is(err, fs.ErrNotExist) {
930+
} else if !errors.Is(err, fs.ErrNotExist) {
891931
return err
892932
}
893933
if err := db.Save(link); err != nil {
@@ -923,3 +963,21 @@ func resolveLink(link *url.URL) (*url.URL, error) {
923963
}
924964
return dst, err
925965
}
966+
967+
func isRequestAuthorized(r *http.Request, u user, short string) bool {
968+
if *allowUnknownUsers {
969+
return true
970+
}
971+
if r.Header.Get(secHeaderName) != "" {
972+
return true
973+
}
974+
975+
// If the request is to create a new link, test the XSRF token against
976+
// newShortName instead of short.
977+
tokenShortName := short
978+
_, err := db.Load(short)
979+
if r.URL.Path == "/" && r.Method == http.MethodPost && errors.Is(err, fs.ErrNotExist) {
980+
tokenShortName = newShortName
981+
}
982+
return xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, u.login, tokenShortName)
983+
}

golink_test.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,19 @@ func TestServeSave(t *testing.T) {
129129
if err != nil {
130130
t.Fatal(err)
131131
}
132-
133132
db.Save(&Link{Short: "link-owned-by-tagged-devices", Long: "/before", Owner: "tagged-devices"})
134133

134+
fooXSRF := func(short string) string {
135+
return xsrftoken.Generate(xsrfKey, "[email protected]", short)
136+
}
137+
barXSRF := func(short string) string {
138+
return xsrftoken.Generate(xsrfKey, "[email protected]", short)
139+
}
140+
135141
tests := []struct {
136142
name string
137143
short string
144+
xsrf string
138145
long string
139146
allowUnknownUsers bool
140147
currentUser func(*http.Request) (user, error)
@@ -155,33 +162,38 @@ func TestServeSave(t *testing.T) {
155162
{
156163
name: "save simple link",
157164
short: "who",
165+
xsrf: fooXSRF(newShortName),
158166
long: "http://who/",
159167
wantStatus: http.StatusOK,
160168
},
161169
{
162170
name: "disallow editing another's link",
163171
short: "who",
172+
xsrf: barXSRF("who"),
164173
long: "http://who/",
165174
currentUser: func(*http.Request) (user, error) { return user{login: "[email protected]"}, nil },
166175
wantStatus: http.StatusForbidden,
167176
},
168177
{
169178
name: "allow editing link owned by tagged-devices",
170179
short: "link-owned-by-tagged-devices",
180+
xsrf: barXSRF("link-owned-by-tagged-devices"),
171181
long: "/after",
172182
currentUser: func(*http.Request) (user, error) { return user{login: "[email protected]"}, nil },
173183
wantStatus: http.StatusOK,
174184
},
175185
{
176186
name: "admins can edit any link",
177187
short: "who",
188+
xsrf: barXSRF("who"),
178189
long: "http://who/",
179190
currentUser: func(*http.Request) (user, error) { return user{login: "[email protected]", isAdmin: true}, nil },
180191
wantStatus: http.StatusOK,
181192
},
182193
{
183194
name: "disallow unknown users",
184195
short: "who2",
196+
xsrf: fooXSRF("who2"),
185197
long: "http://who/",
186198
currentUser: func(*http.Request) (user, error) { return user{}, errors.New("") },
187199
wantStatus: http.StatusInternalServerError,
@@ -194,6 +206,13 @@ func TestServeSave(t *testing.T) {
194206
currentUser: func(*http.Request) (user, error) { return user{}, nil },
195207
wantStatus: http.StatusOK,
196208
},
209+
{
210+
name: "invalid xsrf",
211+
short: "goat",
212+
xsrf: fooXSRF("sheep"),
213+
long: "https://goat.example.com/goat.php?goat=true",
214+
wantStatus: http.StatusBadRequest,
215+
},
197216
}
198217

199218
for _, tt := range tests {
@@ -213,6 +232,7 @@ func TestServeSave(t *testing.T) {
213232
r := httptest.NewRequest("POST", "/", strings.NewReader(url.Values{
214233
"short": {tt.short},
215234
"long": {tt.long},
235+
"xsrf": {tt.xsrf},
216236
}.Encode()))
217237
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
218238
w := httptest.NewRecorder()
@@ -252,7 +272,7 @@ func TestServeDelete(t *testing.T) {
252272
wantStatus: http.StatusBadRequest,
253273
},
254274
{
255-
name: "non-existant link",
275+
name: "nonexistent link",
256276
short: "does-not-exist",
257277
wantStatus: http.StatusNotFound,
258278
},

tmpl/delete.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ <h2 class="text-xl font-bold pb-2">Link go/{{.Short}} Deleted</h2>
44
<p class="py-4">Deleted this by mistake? You can recreate the same link below.</p>
55

66
<form method="POST" action="/">
7+
<input type="hidden" name="xsrf" value="{{ .XSRF }}" />
78
<div class="flex flex-wrap">
89
<div class="flex">
910
<label for=short class="flex my-2 px-2 items-center bg-gray-100 border border-r-0 border-gray-300 rounded-l-md text-gray-700">http://go/</label>

tmpl/detail.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ <h2 class="text-xl font-bold pb-2">Link Details</h2>
33

44
{{ if .Editable }}
55
<form method="POST" action="/">
6+
<input type="hidden" name="xsrf" value="{{ .XSRF }}" />
67
<div class="flex flex-wrap">
78
<div class="flex">
89
<label for=short class="flex my-2 px-2 items-center bg-gray-100 border border-r-0 border-gray-300 rounded-l-md text-gray-700">http://go/</label>

tmpl/help.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ <h2 id="api">Application Programming Interface (API)</h2>
122122
<p>
123123
Create a new link by sending a POST request with a <code>short</code> and <code>long</code> value:
124124

125-
<pre>{{`$ curl -L -d short=cs -d long=https://cs.github.com/ go
125+
<pre>{{`$ curl -L -H Sec-Golink:1 -d short=cs -d long=https://cs.github.com/ go
126126
{"Short":"cs","Long":"https://cs.github.com/","Created":"2022-06-03T22:15:29.993978392Z","LastEdit":"2022-06-03T22:15:29.993978392Z","Owner":"[email protected]"}`}}
127127
</pre>
128128

tmpl/home.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ <h2 class="text-xl font-bold pb-2">Create a new link</h2>
55
<p class="">Did you mean <a class="text-blue-600 hover:underline" href="{{.}}">{{.}}</a> ? Create a go link for it now:</p>
66
{{ end }}
77
<form method="POST" action="/" class="flex flex-wrap">
8+
<input type="hidden" name="xsrf" value="{{ .XSRF }}" />
89
<div class="flex">
910
<label for=short class="flex my-2 px-2 items-center bg-gray-100 border border-r-0 border-gray-300 rounded-l-md text-gray-700">http://go/</label>
1011
<input id=short name=short required type=text size=15 placeholder="shortname" value="{{.Short}}" pattern="\w[\w\-\.]*" title="Must start with letter or number; may contain letters, numbers, dashes, and periods."

0 commit comments

Comments
 (0)