Skip to content

Commit

Permalink
website: add CSRF middleware
Browse files Browse the repository at this point in the history
website/admin: add CSRF tokens to form inputs

website: add CSRF tokens to form inputs
  • Loading branch information
Wessie committed Apr 30, 2024
1 parent d98533e commit a80a5e2
Show file tree
Hide file tree
Showing 12 changed files with 121 additions and 49 deletions.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/csrf v1.7.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/lufia/plan9stats v0.0.0-20240226150601-1dcf7310316a // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM=
Expand Down
13 changes: 11 additions & 2 deletions util/secret/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,24 @@ func NewSecretWithKey(length int, key []byte) Secret {
}

func NewSecret(length int) (Secret, error) {
key := make([]byte, keySize)
_, err := rand.Read(key[:])
key, err := NewKey(keySize)
if err != nil {
return nil, err
}

return NewSecretWithKey(length, key), nil
}

func NewKey(size int) ([]byte, error) {
key := make([]byte, size)
_, err := rand.Read(key[:])
if err != nil {
return nil, err
}

return key, nil
}

const (
DaypassLength = 16
SongLength = 24
Expand Down
19 changes: 13 additions & 6 deletions website/admin/news.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package admin

import (
"context"
"html/template"
"net/http"
"time"

Expand All @@ -11,6 +12,7 @@ import (
"github.com/R-a-dio/valkyrie/website/middleware"
"github.com/R-a-dio/valkyrie/website/shared"
"github.com/go-chi/chi/v5"
"github.com/gorilla/csrf"
"go.opentelemetry.io/otel/trace"
)

Expand All @@ -31,6 +33,7 @@ func (NewsInput) TemplateBundle() string {

type NewsInputPost struct {
middleware.Input
CSRFTokenInput template.HTML

IsNew bool
Raw radio.NewsPost
Expand All @@ -43,12 +46,14 @@ func (NewsInputPost) TemplateBundle() string {
return "news-single"
}

func AsNewsInputPost(ctx context.Context, cache *shared.NewsCache, entries []radio.NewsPost) ([]NewsInputPost, error) {
func AsNewsInputPost(ctx context.Context, cache *shared.NewsCache, r *http.Request, entries []radio.NewsPost) ([]NewsInputPost, error) {
const op errors.Op = "website/admin.AsNewsInputPost"
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer("markdown").Start(ctx, "markdown")
defer span.End()

sharedInput := middleware.InputFromContext(ctx)
sharedCsrf := csrf.TemplateField(r)

posts := make([]NewsInputPost, 0, len(entries))
for _, post := range entries {
header, err := cache.RenderHeader(post)
Expand All @@ -62,10 +67,11 @@ func AsNewsInputPost(ctx context.Context, cache *shared.NewsCache, entries []rad
}

posts = append(posts, NewsInputPost{
Input: sharedInput,
Raw: post,
Header: header,
Body: body,
Input: sharedInput,
CSRFTokenInput: sharedCsrf,
Raw: post,
Header: header,
Body: body,
})
}
return posts, nil
Expand All @@ -84,7 +90,7 @@ func NewNewsInput(cache *shared.NewsCache, ns radio.NewsStorage, r *http.Request
return nil, err
}

posts, err := AsNewsInputPost(ctx, cache, entries.Entries)
posts, err := AsNewsInputPost(ctx, cache, r, entries.Entries)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -152,6 +158,7 @@ func NewNewsInputPost(cache *shared.NewsCache, ns radio.NewsStorage, r *http.Req
}

input.Input = middleware.InputFromContext(ctx)
input.CSRFTokenInput = csrf.TemplateField(r)
input.IsNew = isNew
return &input, nil
}
Expand Down
36 changes: 24 additions & 12 deletions website/admin/pending.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package admin

import (
"fmt"
"html/template"
"net/http"
"net/url"
"os"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/R-a-dio/valkyrie/util"
"github.com/R-a-dio/valkyrie/website/middleware"
"github.com/go-chi/chi/v5"
"github.com/gorilla/csrf"
"github.com/rs/xid"
"github.com/rs/zerolog/hlog"
)
Expand All @@ -38,7 +40,8 @@ func (PendingInput) TemplateBundle() string {
type PendingForm struct {
radio.PendingSong

Errors map[string]string
CSRFTokenInput template.HTML
Errors map[string]string
}

func (PendingForm) TemplateBundle() string {
Expand All @@ -50,17 +53,19 @@ func (PendingForm) TemplateName() string {
}

// Hydrate hydrates the PendingInput with information from the SubmissionStorage
func (pi *PendingInput) Hydrate(s radio.SubmissionStorage) error {
func (pi *PendingInput) Hydrate(s radio.SubmissionStorage, r *http.Request) error {
const op errors.Op = "website/admin.pendingInput.Hydrate"

subms, err := s.All()
if err != nil {
return errors.E(op, err)
}

csrfInput := csrf.TemplateField(r)
pi.Submissions = make([]PendingForm, len(subms))
for i, v := range subms {
pi.Submissions[i].PendingSong = v
pi.Submissions[i].CSRFTokenInput = csrfInput
}
return nil
}
Expand Down Expand Up @@ -111,7 +116,7 @@ func (s *State) GetPendingSong(w http.ResponseWriter, r *http.Request) {
func (s *State) GetPending(w http.ResponseWriter, r *http.Request) {
var input = NewPendingInput(r)

if err := input.Hydrate(s.Storage.Submissions(r.Context())); err != nil {
if err := input.Hydrate(s.Storage.Submissions(r.Context()), r); err != nil {
hlog.FromRequest(r).Error().Err(err).Msg("database failure")
return
}
Expand Down Expand Up @@ -156,7 +161,7 @@ func (s *State) PostPending(w http.ResponseWriter, r *http.Request) {

// no htmx, send a full page back, but we have to hydrate the full list and swap out
// the element that was posted with the posted values
if err := input.Hydrate(s.Storage.Submissions(r.Context())); err != nil {
if err := input.Hydrate(s.Storage.Submissions(r.Context()), r); err != nil {
hlog.FromRequest(r).Error().Err(err).Msg("database failure")
return
}
Expand All @@ -181,22 +186,22 @@ func (s *State) postPending(w http.ResponseWriter, r *http.Request) (PendingForm
const op errors.Op = "website/admin.postPending"

if err := r.ParseForm(); err != nil {
return PendingForm{}, errors.E(op, err, errors.InvalidForm)
return newPendingForm(r), errors.E(op, err, errors.InvalidForm)
}
// grab the pending id
id, err := radio.ParseSubmissionID(r.PostFormValue("id"))
if err != nil {
return PendingForm{}, errors.E(op, err, errors.InvalidForm)
return newPendingForm(r), errors.E(op, err, errors.InvalidForm)
}

// grab the pending data from the database
song, err := s.Storage.Submissions(r.Context()).GetSubmission(id)
if err != nil {
return PendingForm{}, errors.E(op, err, errors.InternalServer)
return newPendingForm(r), errors.E(op, err, errors.InternalServer)
}

// then update it with the submitted form data
form, err := NewPendingForm(*song, r.PostForm)
form, err := NewPendingForm(r, *song)
if err != nil {
return form, errors.E(op, errors.InvalidForm)
}
Expand Down Expand Up @@ -275,7 +280,7 @@ func (s *State) postPendingDoReplace(w http.ResponseWriter, r *http.Request, for
return form, errors.E(op, err, errors.InternalServer)
}

return PendingForm{}, nil
return newPendingForm(r), nil
}

func (s *State) postPendingDoDecline(w http.ResponseWriter, r *http.Request, form PendingForm) (PendingForm, error) {
Expand Down Expand Up @@ -394,17 +399,24 @@ func (s *State) postPendingDoAccept(w http.ResponseWriter, r *http.Request, form

// NewPendingForm creates a PendingForm with song as a base and updating those
// values from the form values given.
func NewPendingForm(song radio.PendingSong, form url.Values) (PendingForm, error) {
func NewPendingForm(r *http.Request, song radio.PendingSong) (PendingForm, error) {
const op errors.Op = "website/admin.NewPendingForm"

pf := PendingForm{PendingSong: song}
pf.Update(form)
pf := newPendingForm(r)
pf.PendingSong = song
pf.Update(r.PostForm)
if !pf.Validate() {
return pf, errors.E(op, errors.InvalidForm)
}
return pf, nil
}

func newPendingForm(r *http.Request) PendingForm {
return PendingForm{
CSRFTokenInput: csrf.TemplateField(r),
}
}

func (pf *PendingForm) Update(form url.Values) {
switch form.Get("action") {
case "replace":
Expand Down
4 changes: 4 additions & 0 deletions website/admin/profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/R-a-dio/valkyrie/errors"
"github.com/R-a-dio/valkyrie/templates"
"github.com/R-a-dio/valkyrie/website/middleware"
"github.com/gorilla/csrf"
"github.com/spf13/afero"
)

Expand Down Expand Up @@ -56,6 +57,8 @@ type ProfilePermissionEntry struct {
type ProfileForm struct {
radio.User

// CSRFTokenInput is the <input> that should be included in the form for CSRF
CSRFTokenInput template.HTML
// PermissionList is the list of permissions we should render
PermissionList []ProfilePermissionEntry
// IsAdmin indicates if we're setting up the admin-only form
Expand Down Expand Up @@ -511,6 +514,7 @@ func newProfileForm(user radio.User, r *http.Request) ProfileForm {

return ProfileForm{
User: user,
CSRFTokenInput: csrf.TemplateField(r),
PermissionList: generatePermissionList(*requestUser, user),
IsAdmin: requestUser.UserPermissions.Has(radio.PermAdmin),
IsSelf: requestUser.Username == user.Username,
Expand Down
13 changes: 10 additions & 3 deletions website/admin/songs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ package admin

import (
"fmt"
"html/template"
"net/http"
"net/url"

radio "github.com/R-a-dio/valkyrie"
"github.com/R-a-dio/valkyrie/errors"
"github.com/R-a-dio/valkyrie/util"
"github.com/R-a-dio/valkyrie/util/secret"
"github.com/R-a-dio/valkyrie/website/middleware"
"github.com/R-a-dio/valkyrie/website/shared"
"github.com/gorilla/csrf"
)

const songsPageSize = 20
Expand All @@ -28,6 +29,8 @@ func (SongsInput) TemplateBundle() string {
}

type SongsForm struct {
CSRFTokenInput template.HTML

Errors map[string]string

// HasDelete indicates if we should show the delete button
Expand Down Expand Up @@ -71,10 +74,12 @@ func NewSongsInput(s radio.SearchService, ss secret.Secret, r *http.Request) (*S
),
}

csrfInput := csrf.TemplateField(r)
hasDelete := input.User.UserPermissions.Has(radio.PermDatabaseDelete)
hasEdit := input.User.UserPermissions.Has(radio.PermDatabaseEdit)
forms := make([]SongsForm, len(searchResult.Songs))
for i := range searchResult.Songs {
forms[i].CSRFTokenInput = csrfInput
forms[i].Song = searchResult.Songs[i]
forms[i].HasDelete = hasDelete
forms[i].HasEdit = hasEdit
Expand Down Expand Up @@ -144,7 +149,7 @@ func (s *State) postSongs(w http.ResponseWriter, r *http.Request) (*SongsForm, e
}

// construct the new updated song form from the input
form, err := NewSongsForm(ts, *user, r.Form)
form, err := NewSongsForm(ts, *user, r)
if err != nil {
return nil, errors.E(op, err)
}
Expand Down Expand Up @@ -182,10 +187,11 @@ func (s *State) postSongs(w http.ResponseWriter, r *http.Request) (*SongsForm, e
return form, nil
}

func NewSongsForm(ts radio.TrackStorage, user radio.User, values url.Values) (*SongsForm, error) {
func NewSongsForm(ts radio.TrackStorage, user radio.User, r *http.Request) (*SongsForm, error) {
const op errors.Op = "website/admin.NewSongsForm"

var form SongsForm
values := r.Form

tid, err := radio.ParseTrackID(values.Get("id"))
if err != nil {
Expand All @@ -212,6 +218,7 @@ func NewSongsForm(ts radio.TrackStorage, user radio.User, values url.Values) (*S
form.Song = *song
form.HasDelete = user.UserPermissions.Has(radio.PermDatabaseDelete)
form.HasEdit = user.UserPermissions.Has(radio.PermDatabaseEdit)
form.CSRFTokenInput = csrf.TemplateField(r)
return &form, nil
}

Expand Down
5 changes: 4 additions & 1 deletion website/admin/songs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,10 @@ func TestNewSongsForm(t *testing.T) {
values.Set("title", c.new.Title)
values.Set("tags", c.new.Tags)

form, err := NewSongsForm(ts, user, values)
r := httptest.NewRequest(http.MethodPost, "/admin/songs", nil)
r.Form = values

form, err := NewSongsForm(ts, user, r)
if !assert.NoError(t, err) {
continue
}
Expand Down
8 changes: 6 additions & 2 deletions website/admin/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package admin

import (
"cmp"
"html/template"
"net/http"
"slices"

radio "github.com/R-a-dio/valkyrie"
"github.com/R-a-dio/valkyrie/errors"
"github.com/R-a-dio/valkyrie/website/middleware"
"github.com/gorilla/csrf"
)

type UsersInput struct {
middleware.Input
CSRFTokenInput template.HTML

Users []radio.User
}
Expand All @@ -34,8 +37,9 @@ func NewUsersInput(us radio.UserStorage, r *http.Request) (*UsersInput, error) {
})
// construct the input
input := &UsersInput{
Input: middleware.InputFromRequest(r),
Users: users,
Input: middleware.InputFromRequest(r),
CSRFTokenInput: csrf.TemplateField(r),
Users: users,
}

return input, nil
Expand Down
Loading

0 comments on commit a80a5e2

Please sign in to comment.