Skip to content

Commit

Permalink
Protect save and update from CSRF (#117)
Browse files Browse the repository at this point in the history
Also, fix a lint.

Fixes #116

Signed-off-by: Chris Palmer <cpalmer@tailscale.com>
  • Loading branch information
Chris Palmer authored Apr 2, 2024
1 parent 6c79b50 commit c66cbb8
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 8 deletions.
68 changes: 63 additions & 5 deletions golink.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,20 @@ import (
"tailscale.com/util/dnsname"
)

const defaultHostname = "go"
const (
defaultHostname = "go"

// Used as a placeholder short name for generating the XSRF defense token,
// when creating new links.
newShortName = ".new"

// If the caller sends this header set to a non-empty value, we will allow
// them to make the call even without an XSRF token. JavaScript in browser
// cannot set this header, per the [Fetch Spec].
//
// [Fetch Spec]: https://fetch.spec.whatwg.org
secHeaderName = "Sec-Golink"
)

var (
verbose = flag.Bool("verbose", false, "be verbose")
Expand Down Expand Up @@ -254,11 +267,19 @@ type visitData struct {
NumClicks int
}

// homeData is the data used by the homeTmpl template.
// homeData is the data used by homeTmpl.
type homeData struct {
Short string
Long string
Clicks []visitData
XSRF string
}

// deleteData is the data used by deleteTmpl.
type deleteData struct {
Short string
Long string
XSRF string
}

var xsrfKey string
Expand Down Expand Up @@ -416,10 +437,16 @@ func serveHome(w http.ResponseWriter, r *http.Request, short string) {
}
}

cu, err := currentUser(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
homeTmpl.Execute(w, homeData{
Short: short,
Long: long,
Clicks: clicks,
XSRF: xsrftoken.Generate(xsrfKey, cu.login, newShortName),
})
}

Expand Down Expand Up @@ -739,6 +766,11 @@ func serveDelete(w http.ResponseWriter, r *http.Request) {
return
}

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

deleteTmpl.Execute(w, link)
deleteTmpl.Execute(w, deleteData{
Short: link.Short,
Long: link.Long,
XSRF: xsrftoken.Generate(xsrfKey, cu.login, newShortName),
})
}

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

if !isRequestAuthorized(r, cu, short) {
http.Error(w, "invalid XSRF token", http.StatusBadRequest)
return
}

// allow transferring ownership to valid users. If empty, set owner to current user.
owner := r.FormValue("owner")
if owner != "" {
Expand Down Expand Up @@ -886,8 +927,7 @@ func restoreLastSnapshot() error {
_, err := db.Load(link.Short)
if err == nil {
continue // exists
}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
if err := db.Save(link); err != nil {
Expand Down Expand Up @@ -923,3 +963,21 @@ func resolveLink(link *url.URL) (*url.URL, error) {
}
return dst, err
}

func isRequestAuthorized(r *http.Request, u user, short string) bool {
if *allowUnknownUsers {
return true
}
if r.Header.Get(secHeaderName) != "" {
return true
}

// If the request is to create a new link, test the XSRF token against
// newShortName instead of short.
tokenShortName := short
_, err := db.Load(short)
if r.URL.Path == "/" && r.Method == http.MethodPost && errors.Is(err, fs.ErrNotExist) {
tokenShortName = newShortName
}
return xsrftoken.Valid(r.PostFormValue("xsrf"), xsrfKey, u.login, tokenShortName)
}
24 changes: 22 additions & 2 deletions golink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,19 @@ func TestServeSave(t *testing.T) {
if err != nil {
t.Fatal(err)
}

db.Save(&Link{Short: "link-owned-by-tagged-devices", Long: "/before", Owner: "tagged-devices"})

fooXSRF := func(short string) string {
return xsrftoken.Generate(xsrfKey, "foo@example.com", short)
}
barXSRF := func(short string) string {
return xsrftoken.Generate(xsrfKey, "bar@example.com", short)
}

tests := []struct {
name string
short string
xsrf string
long string
allowUnknownUsers bool
currentUser func(*http.Request) (user, error)
Expand All @@ -155,33 +162,38 @@ func TestServeSave(t *testing.T) {
{
name: "save simple link",
short: "who",
xsrf: fooXSRF(newShortName),
long: "http://who/",
wantStatus: http.StatusOK,
},
{
name: "disallow editing another's link",
short: "who",
xsrf: barXSRF("who"),
long: "http://who/",
currentUser: func(*http.Request) (user, error) { return user{login: "bar@example.com"}, nil },
wantStatus: http.StatusForbidden,
},
{
name: "allow editing link owned by tagged-devices",
short: "link-owned-by-tagged-devices",
xsrf: barXSRF("link-owned-by-tagged-devices"),
long: "/after",
currentUser: func(*http.Request) (user, error) { return user{login: "bar@example.com"}, nil },
wantStatus: http.StatusOK,
},
{
name: "admins can edit any link",
short: "who",
xsrf: barXSRF("who"),
long: "http://who/",
currentUser: func(*http.Request) (user, error) { return user{login: "bar@example.com", isAdmin: true}, nil },
wantStatus: http.StatusOK,
},
{
name: "disallow unknown users",
short: "who2",
xsrf: fooXSRF("who2"),
long: "http://who/",
currentUser: func(*http.Request) (user, error) { return user{}, errors.New("") },
wantStatus: http.StatusInternalServerError,
Expand All @@ -194,6 +206,13 @@ func TestServeSave(t *testing.T) {
currentUser: func(*http.Request) (user, error) { return user{}, nil },
wantStatus: http.StatusOK,
},
{
name: "invalid xsrf",
short: "goat",
xsrf: fooXSRF("sheep"),
long: "https://goat.example.com/goat.php?goat=true",
wantStatus: http.StatusBadRequest,
},
}

for _, tt := range tests {
Expand All @@ -213,6 +232,7 @@ func TestServeSave(t *testing.T) {
r := httptest.NewRequest("POST", "/", strings.NewReader(url.Values{
"short": {tt.short},
"long": {tt.long},
"xsrf": {tt.xsrf},
}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
Expand Down Expand Up @@ -252,7 +272,7 @@ func TestServeDelete(t *testing.T) {
wantStatus: http.StatusBadRequest,
},
{
name: "non-existant link",
name: "nonexistent link",
short: "does-not-exist",
wantStatus: http.StatusNotFound,
},
Expand Down
1 change: 1 addition & 0 deletions tmpl/delete.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ <h2 class="text-xl font-bold pb-2">Link go/{{.Short}} Deleted</h2>
<p class="py-4">Deleted this by mistake? You can recreate the same link below.</p>

<form method="POST" action="/">
<input type="hidden" name="xsrf" value="{{ .XSRF }}" />
<div class="flex flex-wrap">
<div class="flex">
<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>
Expand Down
1 change: 1 addition & 0 deletions tmpl/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ <h2 class="text-xl font-bold pb-2">Link Details</h2>

{{ if .Editable }}
<form method="POST" action="/">
<input type="hidden" name="xsrf" value="{{ .XSRF }}" />
<div class="flex flex-wrap">
<div class="flex">
<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>
Expand Down
2 changes: 1 addition & 1 deletion tmpl/help.html
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ <h2 id="api">Application Programming Interface (API)</h2>
<p>
Create a new link by sending a POST request with a <code>short</code> and <code>long</code> value:

<pre>{{`$ curl -L -d short=cs -d long=https://cs.github.com/ go
<pre>{{`$ curl -L -H Sec-Golink:1 -d short=cs -d long=https://cs.github.com/ go
{"Short":"cs","Long":"https://cs.github.com/","Created":"2022-06-03T22:15:29.993978392Z","LastEdit":"2022-06-03T22:15:29.993978392Z","Owner":"amelie@example.com"}`}}
</pre>

Expand Down
1 change: 1 addition & 0 deletions tmpl/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ <h2 class="text-xl font-bold pb-2">Create a new link</h2>
<p class="">Did you mean <a class="text-blue-600 hover:underline" href="{{.}}">{{.}}</a> ? Create a go link for it now:</p>
{{ end }}
<form method="POST" action="/" class="flex flex-wrap">
<input type="hidden" name="xsrf" value="{{ .XSRF }}" />
<div class="flex">
<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>
<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."
Expand Down

0 comments on commit c66cbb8

Please sign in to comment.