From 9f91ad360ee2d7ccf1b130737e520eac06b34c35 Mon Sep 17 00:00:00 2001 From: Ian Meyer Date: Tue, 24 Sep 2024 15:13:37 +0000 Subject: [PATCH] Implement CSRF protection, update styles - Implement CSRF protection with middleware and token validation - Add CSRF tokens to forms and improve error logging - Update CSS with a different, blue-y look - Add nosemgrep to some more open redirects Change-Id: Ie69a0b7764113a0b05566a972a4ac047aa63d647 Signed-off-by: Ian Meyer --- csrf.go | 34 +- main.go | 5 + server.go | 36 ++- static/style.css | 699 +++++++++++++++++++++-------------------- tmpl/edit-profile.html | 1 + tmpl/newthread.html | 1 + tmpl/thread.html | 1 + 7 files changed, 416 insertions(+), 361 deletions(-) diff --git a/csrf.go b/csrf.go index 2614a93..7e759b5 100644 --- a/csrf.go +++ b/csrf.go @@ -1,9 +1,11 @@ package main import ( + "context" "crypto/rand" "encoding/base64" "errors" + "log/slog" "net/http" "sync" "time" @@ -19,8 +21,13 @@ var ( tokenStore = make(map[string]time.Time) tokenStoreMu sync.Mutex timeNow = time.Now + csrfLogger *slog.Logger ) +func initCSRFLogger(logger *slog.Logger) { + csrfLogger = logger +} + func generateCSRFToken() (string, error) { b := make([]byte, csrfTokenLength) _, err := rand.Read(b) @@ -46,6 +53,10 @@ func setCSRFToken(r *http.Request, w http.ResponseWriter) (string, error) { SameSite: http.SameSiteStrictMode, }) + if csrfLogger != nil { + csrfLogger.DebugContext(r.Context(), "set csrf cookie") + } + tokenStoreMu.Lock() tokenStore[token] = timeNow().Add(24 * time.Hour) // Token expires in 24 hours tokenStoreMu.Unlock() @@ -56,15 +67,27 @@ func setCSRFToken(r *http.Request, w http.ResponseWriter) (string, error) { func validateCSRFToken(r *http.Request) error { cookie, err := r.Cookie(csrfCookieName) if err != nil { + if csrfLogger != nil { + csrfLogger.DebugContext(r.Context(), "csrf error", slog.String("message", err.Error())) + } return errors.New("CSRF cookie not found") } token := r.Header.Get(csrfHeaderName) if token == "" { - return errors.New("CSRF token not found in header") + token = r.FormValue("csrf_token") + if token == "" { + if csrfLogger != nil { + csrfLogger.DebugContext(r.Context(), "csrf token not found in header or form") + } + return errors.New("CSRF token not found in header or form") + } } if cookie.Value != token { + if csrfLogger != nil { + csrfLogger.DebugContext(r.Context(), "csrf token mismatch") + } return errors.New("CSRF token mismatch") } @@ -73,11 +96,17 @@ func validateCSRFToken(r *http.Request) error { expiry, exists := tokenStore[token] if !exists { + if csrfLogger != nil { + csrfLogger.DebugContext(r.Context(), "csrf token not found in store") + } return errors.New("CSRF token not found in store") } if timeNow().After(expiry) { delete(tokenStore, token) + if csrfLogger != nil { + csrfLogger.DebugContext(r.Context(), "csrf token expired") + } return errors.New("CSRF token expired") } @@ -93,6 +122,9 @@ func CSRFMiddleware(next http.HandlerFunc) http.HandlerFunc { return } w.Header().Set(csrfHeaderName, token) + + ctx := context.WithValue(r.Context(), "CSRFToken", token) + next.ServeHTTP(w, r.WithContext(ctx)) } else { if err := validateCSRFToken(r); err != nil { http.Error(w, "CSRF validation failed", http.StatusForbidden) diff --git a/main.go b/main.go index d653126..ac6c485 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,11 @@ func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + // Enabling logging within csrf.go + if *debug { + initCSRFLogger(logger.With("component", "csrf")) + } + dbconn, err := setupDatabase(ctx, logger) if err != nil { logger.Error("failed to connect to database", slog.String("error", err.Error())) diff --git a/server.go b/server.go index 9655c4a..0239225 100644 --- a/server.go +++ b/server.go @@ -151,14 +151,16 @@ func setupMux(dsvc *DiscussService) http.Handler { } tailnetMux := http.NewServeMux() + + tailnetMux.HandleFunc("POST /member/edit", CSRFMiddleware(dsvc.EditMemberProfile)) + tailnetMux.HandleFunc("POST /thread/new", CSRFMiddleware(dsvc.CreateThread)) + tailnetMux.HandleFunc("POST /thread/{id}", CSRFMiddleware(dsvc.CreateThreadPost)) + tailnetMux.HandleFunc("GET /", dsvc.ListThreads) tailnetMux.HandleFunc("GET /member/{id}", dsvc.ListMember) tailnetMux.HandleFunc("GET /member/edit", dsvc.EditMemberProfile) - tailnetMux.HandleFunc("POST /member/edit", dsvc.EditMemberProfile) tailnetMux.HandleFunc("GET /thread/new", dsvc.NewThread) - tailnetMux.HandleFunc("POST /thread/new", dsvc.CreateThread) tailnetMux.HandleFunc("GET /thread/{id}", dsvc.ListThreadPosts) - tailnetMux.HandleFunc("POST /thread/{id}", dsvc.CreateThreadPost) tailnetMux.Handle("GET /metrics", promhttp.Handler()) tailnetMux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(fs)))) @@ -401,6 +403,7 @@ func (s *DiscussService) CreateThreadPost(w http.ResponseWriter, r *http.Request return } + // nosemgrep http.Redirect(w, r, fmt.Sprintf("/thread/%d", threadID), http.StatusSeeOther) } @@ -410,6 +413,8 @@ func (s *DiscussService) EditMemberProfile(w http.ResponseWriter, r *http.Reques return } + csrfToken, err := setCSRFToken(r, w) + // Get the current user's email currentUserEmail, err := s.GetTailscaleUserEmail(r) if err != nil { @@ -426,6 +431,7 @@ func (s *DiscussService) EditMemberProfile(w http.ResponseWriter, r *http.Reques "CurrentUserEmail": currentUserEmail, "Version": s.version, "GitSha": s.gitSha, + "CSRFToken": csrfToken, }) return } @@ -451,6 +457,7 @@ func (s *DiscussService) EditMemberProfile(w http.ResponseWriter, r *http.Reques "CurrentUserEmail": currentUserEmail, "Version": s.version, "GitSha": s.gitSha, + "CSRFToken": csrfToken, }) return } @@ -483,6 +490,7 @@ func (s *DiscussService) EditMemberProfile(w http.ResponseWriter, r *http.Reques } // Redirect to the member's profile page + // nosemgrep http.Redirect(w, r, fmt.Sprintf("/member/%d", memberID), http.StatusSeeOther) } @@ -567,6 +575,8 @@ func (s *DiscussService) ListThreadPosts(w http.ResponseWriter, r *http.Request) return } + csrfToken, err := setCSRFToken(r, w) + email, err := s.GetTailscaleUserEmail(r) if err != nil { s.renderError(w, r, err, http.StatusInternalServerError) @@ -615,10 +625,11 @@ func (s *DiscussService) ListThreadPosts(w http.ResponseWriter, r *http.Request) "Title": BOARD_TITLE, "ThreadPosts": threadPosts, // nosemgrep - "Subject": template.HTML(subject), - "ID": threadID, - "GitSha": s.gitSha, - "Version": s.version, + "Subject": template.HTML(subject), + "ID": threadID, + "GitSha": s.gitSha, + "Version": s.version, + "CSRFToken": csrfToken, }) } @@ -703,6 +714,8 @@ func (s *DiscussService) NewThread(w http.ResponseWriter, r *http.Request) { return } + csrfToken, err := setCSRFToken(r, w) + email, err := s.GetTailscaleUserEmail(r) if err != nil { s.renderError(w, r, err, http.StatusInternalServerError) @@ -717,9 +730,10 @@ func (s *DiscussService) NewThread(w http.ResponseWriter, r *http.Request) { } s.renderTemplate(w, r, "newthread.html", map[string]interface{}{ - "User": email, - "Title": BOARD_TITLE, - "Version": s.version, - "GitSha": s.gitSha, + "User": email, + "Title": BOARD_TITLE, + "CSRFToken": csrfToken, + "Version": s.version, + "GitSha": s.gitSha, }) } diff --git a/static/style.css b/static/style.css index 174be86..1a3faca 100644 --- a/static/style.css +++ b/static/style.css @@ -1,326 +1,215 @@ -/* Variables */ -:root { - /* Common variables */ - --border: 1px solid grey; - - /* Light mode variables */ - --background-color-light: #dfdfdf; - --content-bg-light: #f9f9f9; - --even-row-color-light: #CCC; - --form-bg-light: #ddd; - --header-color-light: #efefef; - --odd-row-color-light: #DDD; - --primary-color-light: #d4306e; - --text-color-light: #333; - --text-color-secondary-light: #555; - --threadpost-bg-light: #fff; - - /* Dark mode variables */ - --background-color-dark: #222; - --content-bg-dark: #2c2c2c; - --even-row-color-dark: #444; - --form-bg-dark: #333; - --header-color-dark: #2c2c2c; - --odd-row-color-dark: #333; - --primary-color-dark: #ff9eca; - --text-color-dark: #e0e0e0; - --text-color-secondary-dark: #b0b0b0; - --threadpost-bg-dark: #333; - - /* Default to light mode */ - --background-color: var(--background-color-light); - --content-bg: var(--content-bg-light); - --even-row-color: var(--even-row-color-light); - --form-bg: var(--form-bg-light); - --header-color: var(--header-color-light); - --odd-row-color: var(--odd-row-color-light); - --primary-color: var(--primary-color-light); - --text-color: var(--text-color-light); - --text-color-secondary: var(--text-color-secondary-light); - --threadpost-bg: var(--threadpost-bg-light); -} - -/* Element Selectors */ -a { - color: var(--primary-color); -} - -body { - background-color: var(--background-color); - color: var(--text-color); - font-family: Verdana, Geneva, Tahoma, sans-serif; - font-size: 100%; - margin: 0; - padding: 20px; -} - -table { - border: var(--border); - border-collapse: separate; - border-radius: 10px; - border-spacing: 0; - margin: 0 auto; - overflow: hidden; - table-layout: fixed; - width: 100%; -} - -td, -th { - padding: 5px; - vertical-align: top; -} - -th { - background-color: var(--header-color); - font-weight: 600; - text-align: left; -} - -/* Class Selectors */ -.col-date { - width: 170px; -} - -.col-posts { - width: 80px; -} - -.col-subject, -.thread-col-subject { - width: 100%; -} - -.col-user { - text-align: right; - width: 220px; -} - -.content-container { - box-sizing: border-box; - padding: 10px; - width: 100%; -} - -.content-row { - background-color: var(--content-bg); - border: 1px solid var(--text-color-secondary); - border-radius: 10px; - margin-bottom: 10px; - padding: 10px; -} - -.edit-profile { - display: flex; - flex-direction: column; - gap: 1rem; - max-width: 768px; -} - -.form-container { - background-color: var(--form-bg); - border-radius: 10px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - max-width: fit-content; - padding: 20px; - width: 100%; -} - -.form-group { - margin-bottom: 15px; -} - -.edit-profile button, -.form-group button { - background-color: var(--primary-color); - border: none; - border-radius: 5px; - color: white; - cursor: pointer; - font-size: 1em; - padding: 10px; - width: 25%; -} - -.edit-profile button:hover, -.form-group button:hover { - background-color: #b02758; -} - -.edit-profile input, -.form-group input, -.form-group textarea { - background-color: var(--background-color); - border: 1px solid var(--text-color-secondary); - border-radius: 5px; - box-sizing: border-box; - color: var(--text-color); - font-size: 0.9em; - padding: 10px; - resize: none; - width: 100%; -} - - -.edit-profile input:focus, -.form-group input:focus, -.form-group textarea:focus { - border-color: var(--primary-color); - box-shadow: 0 0 5px rgba(184, 92, 166, 0.5); - outline: none; -} - -.edit-profile label, -.form-group label { - color: var(--text-color-secondary); - display: block; - margin-bottom: 5px; -} - -.info-column { - padding: 10px; -} - -.profile-column { - background-color: var(--primary-color); - box-sizing: border-box; - padding: 20px; - width: 200px; -} - -.profile-photo { - background-color: var(--primary-color); - border: 0px; - border-radius: 50%; - height: 150px; - margin: 20px auto; - width: 150px; -} - -.profile-photo img { - border-radius: 50%; - height: 100%; - object-fit: cover; - width: 100%; -} - -.profile-table { - border: var(--border); - border-collapse: separate; - border-radius: 10px; - border-spacing: 0; - overflow: hidden; - width: 100%; -} - -.table-max-w { - width: 100%; -} - -.thread-content-row { - margin-bottom: 10px; -} - -.threadpost-body { - color: var(--text-color); - font-size: 1.1em; - width: 100%; -} - -.threadpost-bubble { - background-color: var(--threadpost-bg); - border-radius: 10px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); - margin: 10px 0; - overflow-wrap: break-word; - padding: 10px; - width: 90%; -} - -.threadpost-header { - border-bottom: 1px solid var(--text-color-secondary); - color: var(--text-color-secondary); - font-size: 1em; - margin-bottom: 5px; - padding-bottom: 5px; - width: 100%; -} - -.version { - color: var(--text-color-secondary); - font: x-small 'Courier New', Courier, monospace; - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.version svg { - width: 24px; - height: 24px; - fill: var(--primary-color) -} - -.version-text { - margin-bottom: 4px; -} - -/* Descendant Selectors */ -table>*>tr:not(:last-child)>*, -table>*:not(:last-child) { - border-bottom: var(--border); -} - -tbody>tr:nth-child(even) { - background-color: var(--even-row-color); -} - -tbody>tr:nth-child(odd) { - background-color: var(--odd-row-color); -} - -/* Media Queries */ -@media (prefers-color-scheme: dark) { + /* Variables */ :root { - --background-color: var(--background-color-dark); - --content-bg: var(--content-bg-dark); - --even-row-color: var(--even-row-color-dark); - --form-bg: var(--form-bg-dark); - --header-color: var(--header-color-dark); - --odd-row-color: var(--odd-row-color-dark); - --primary-color: var(--primary-color-dark); - --text-color: var(--text-color-dark); - --text-color-secondary: var(--text-color-secondary-dark); - --threadpost-bg: var(--threadpost-bg-dark); + /* Common variables */ + --border: 1px solid grey; + + /* Light mode variables */ + --background-color-light: #f0f4f8;; + --content-bg-light: #f9f9f9; + --even-row-color-light: #CCC; + --form-bg-light: #ddd; + --header-color-light: #efefef; + --odd-row-color-light: #DDD; + --primary-color-light: #1e6091; + --text-color-light: #333; + --text-color-secondary-light: #555; + --threadpost-bg-light: #fff; + + /* Dark mode variables */ + --background-color-dark: #1a2634;; + --content-bg-dark: #2c2c2c; + --even-row-color-dark: #444; + --form-bg-dark: #333; + --header-color-dark: #2c2c2c; + --odd-row-color-dark: #333; + --primary-color-dark: #64b5f6; + --text-color-dark: #e0e0e0; + --text-color-secondary-dark: #b0b0b0; + --threadpost-bg-dark: #333; + + /* Default to light mode */ + --background-color: var(--background-color-light); + --content-bg: var(--content-bg-light); + --even-row-color: var(--even-row-color-light); + --form-bg: var(--form-bg-light); + --header-color: var(--header-color-light); + --odd-row-color: var(--odd-row-color-light); + --primary-color: var(--primary-color-light); + --text-color: var(--text-color-light); + --text-color-secondary: var(--text-color-secondary-light); + --threadpost-bg: var(--threadpost-bg-light); } - .form-group button:hover { - background-color: #ffb8d9; + /* Element Selectors */ + a { + color: var(--primary-color); + } + + body { + background-color: var(--background-color); + color: var(--text-color); + font-family: Verdana, Geneva, Tahoma, sans-serif; + font-size: 100%; + margin: 0; + padding: 20px; + } + + table { + border: var(--border); + border-collapse: separate; + border-radius: 10px; + border-spacing: 0; + margin: 0 auto; + overflow: hidden; + table-layout: fixed; + width: 100%; } -} -@media (max-width: 768px) { + td, + th { + padding: 5px; + vertical-align: top; + } + + th { + background-color: var(--header-color); + font-weight: 600; + text-align: left; + } - .col-posts, + /* Class Selectors */ .col-date { - display: none; + width: 170px; } - .col-user { - text-align: right; - width: 30%; + .col-posts { + width: 80px; } .col-subject, .thread-col-subject { - width: 70%; + width: 100%; + } + + .col-user { + text-align: right; + width: 220px; + } + + .content-container { + box-sizing: border-box; + padding: 10px; + width: 100%; + } + + .content-row { + background-color: var(--content-bg); + border: 1px solid var(--text-color-secondary); + border-radius: 10px; + margin-bottom: 10px; + padding: 10px; + } + + .edit-profile { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 768px; + } + + .form-container { + background-color: var(--form-bg); + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + max-width: fit-content; + padding: 20px; + width: 100%; + } + + .form-group { + margin-bottom: 15px; + } + + .edit-profile button, + .form-group button { + background-color: var(--primary-color); + border: none; + border-radius: 5px; + color: white; + cursor: pointer; + font-size: 1em; + padding: 10px; + width: 25%; + } + + .edit-profile button:hover, + .form-group button:hover { + background-color: #b02758; } - .info-column, - .profile-column, - .profile-table, - .profile-table tbody, - .profile-table tr { + .edit-profile input, + .form-group input, + .form-group textarea { + background-color: var(--background-color); + border: 1px solid var(--text-color-secondary); + border-radius: 5px; + box-sizing: border-box; + color: var(--text-color); + font-size: 0.9em; + padding: 10px; + resize: none; + width: 100%; + } + + + .edit-profile input:focus, + .form-group input:focus, + .form-group textarea:focus { + border-color: var(--primary-color); + box-shadow: 0 0 5px rgba(184, 92, 166, 0.5); + outline: none; + } + + .edit-profile label, + .form-group label { + color: var(--text-color-secondary); display: block; + margin-bottom: 5px; + } + + .info-column { + padding: 10px; + } + + .profile-column { + background-color: var(--primary-color); + box-sizing: border-box; + padding: 20px; + width: 200px; + } + + .profile-photo { + background-color: var(--primary-color); + border: 0px; + border-radius: 50%; + height: 150px; + margin: 20px auto; + width: 150px; + } + + .profile-photo img { + border-radius: 50%; + height: 100%; + object-fit: cover; + width: 100%; + } + + .profile-table { + border: var(--border); + border-collapse: separate; + border-radius: 10px; + border-spacing: 0; + overflow: hidden; width: 100%; } @@ -328,59 +217,171 @@ tbody>tr:nth-child(odd) { width: 100%; } - td { - padding: 10px 5px; + .thread-content-row { + margin-bottom: 10px; } - table { - display: table; + .threadpost-body { + color: var(--text-color); + font-size: 1.1em; width: 100%; } - thead { - background-color: var(--header-color); - position: sticky; - top: 0; - z-index: 2; + .threadpost-bubble { + background-color: var(--threadpost-bg); + border-radius: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + margin: 10px 0; + overflow-wrap: break-word; + padding: 10px; + width: 90%; } - thead, - tbody, - tr { - display: table; + .threadpost-header { + border-bottom: 1px solid var(--text-color-secondary); + color: var(--text-color-secondary); + font-size: 1em; + margin-bottom: 5px; + padding-bottom: 5px; width: 100%; } - th, - td { - display: table-cell; + .version { + color: var(--text-color-secondary); + font: x-small 'Courier New', Courier, monospace; + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: 7px; } - .form-container { - padding: 15px; + .version svg { + width: 24px; + height: 24px; + fill: var(--primary-color) } - .form-group input, - .form-group textarea, - .form-group button { - font-size: 0.9em; - padding: 8px; - } -} - -svg a:link, -svg a:visited { - cursor: pointer; -} - -svg a text, -text svg a { - text-align: center; - fill: var(--primary-color); - text-decoration: underline; -} - -svg a:hover, -svg a:active { - outline: dotted 1px var(--primary-color); -} + .version-text { + margin-bottom: 4px; + } + + /* Descendant Selectors */ + table>*>tr:not(:last-child)>*, + table>*:not(:last-child) { + border-bottom: var(--border); + } + + tbody>tr:nth-child(even) { + background-color: var(--even-row-color); + } + + tbody>tr:nth-child(odd) { + background-color: var(--odd-row-color); + } + + /* Media Queries */ + @media (prefers-color-scheme: dark) { + :root { + --background-color: var(--background-color-dark); + --content-bg: var(--content-bg-dark); + --even-row-color: var(--even-row-color-dark); + --form-bg: var(--form-bg-dark); + --header-color: var(--header-color-dark); + --odd-row-color: var(--odd-row-color-dark); + --primary-color: var(--primary-color-dark); + --text-color: var(--text-color-dark); + --text-color-secondary: var(--text-color-secondary-dark); + --threadpost-bg: var(--threadpost-bg-dark); + } + + .form-group button:hover { + background-color: #ffb8d9; + } + } + + @media (max-width: 768px) { + + .col-posts, + .col-date { + display: none; + } + + .col-user { + text-align: right; + width: 30%; + } + + .col-subject, + .thread-col-subject { + width: 70%; + } + + .info-column, + .profile-column, + .profile-table, + .profile-table tbody, + .profile-table tr { + display: block; + width: 100%; + } + + .table-max-w { + width: 100%; + } + + td { + padding: 10px 5px; + } + + table { + display: table; + width: 100%; + } + + thead { + background-color: var(--header-color); + position: sticky; + top: 0; + z-index: 2; + } + + thead, + tbody, + tr { + display: table; + width: 100%; + } + + th, + td { + display: table-cell; + } + + .form-container { + padding: 15px; + } + + .form-group input, + .form-group textarea, + .form-group button { + font-size: 0.9em; + padding: 8px; + } + } + + svg a:link, + svg a:visited { + cursor: pointer; + } + + svg a text, + text svg a { + text-align: center; + fill: var(--primary-color); + text-decoration: underline; + } + + svg a:hover, + svg a:active { + outline: dotted 1px var(--primary-color); + } diff --git a/tmpl/edit-profile.html b/tmpl/edit-profile.html index e1b5cb2..7b203d5 100644 --- a/tmpl/edit-profile.html +++ b/tmpl/edit-profile.html @@ -5,6 +5,7 @@

Editing profile for {{ .CurrentUserEmail }}

+
diff --git a/tmpl/newthread.html b/tmpl/newthread.html index 86d8dad..5aff41d 100644 --- a/tmpl/newthread.html +++ b/tmpl/newthread.html @@ -4,6 +4,7 @@

Top serious throwback...

+
diff --git a/tmpl/thread.html b/tmpl/thread.html index 55902a6..12f52a7 100644 --- a/tmpl/thread.html +++ b/tmpl/thread.html @@ -15,6 +15,7 @@

{{ .Subject }}

+