Browse Source

chore: implementing CSRF

security-updates
nic 3 months ago
parent
commit
bd15d71f00
  1. 82
      apps/web/main.go
  2. 11
      go.mod
  3. 10
      go.sum
  4. 4
      internal/handlers/web/documents.go
  5. 4
      internal/handlers/web/login.go
  6. 11
      templates/layout.html

82
apps/web/main.go

@ -4,12 +4,15 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/handlers/web"
"marmic/servicetrade-toolbox/internal/middleware"
"github.com/gorilla/csrf"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
)
@ -20,6 +23,12 @@ func main() {
}
r := mux.NewRouter()
// Make app proxy-aware (X-Forwarded-Proto/Host) so downstream middlewares see correct scheme/host
r.Use(handlers.ProxyHeaders)
// Global security headers middleware
r.Use(middleware.SecurityHeaders)
// Serve embedded static files
staticFS, err := root.GetStaticFS()
if err != nil {
@ -30,15 +39,84 @@ func main() {
// Serve static files
// r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Auth routes
// Auth routes (login remains outside CSRF)
r.HandleFunc("/login", web.LoginHandler).Methods("GET", "POST")
r.HandleFunc("/logout", web.LogoutHandler).Methods("GET", "POST")
// CSRF protection for state-changing routes
csrfKey := os.Getenv("CSRF_AUTH_KEY")
if len(csrfKey) < 32 {
log.Println("WARNING: CSRF_AUTH_KEY is not set or too short; using insecure default for development only")
csrfKey = "this-is-a-very-insecure-dev-key-please-set-env"
}
secureCookies := os.Getenv("APP_SECURE_COOKIES") != "false"
disableCSRF := os.Getenv("CSRF_DISABLE") == "true"
// Trusted origins for reverse-proxy/HTTPS setups (comma-separated env)
var trustedOrigins []string
if envTO := os.Getenv("CSRF_TRUSTED_ORIGINS"); envTO != "" {
for _, part := range strings.Split(envTO, ",") {
o := strings.TrimSpace(part)
if o != "" {
trustedOrigins = append(trustedOrigins, o)
}
}
} else {
// Sensible defaults for your known hosts; override via CSRF_TRUSTED_ORIGINS in prod
trustedOrigins = []string{
"https://dev.toolbox.nicpatterson.info",
"https://toolbox.nicpatterson.info",
"http://localhost:8080",
"http://127.0.0.1:8080",
}
}
csrfOpts := []csrf.Option{
csrf.Secure(secureCookies),
csrf.SameSite(csrf.SameSiteLaxMode),
csrf.CookieName("XSRF-TOKEN"),
csrf.HttpOnly(false), // allow htmx script to read and send the token
csrf.Path("/"),
csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Log the specific CSRF failure reason to help debugging
origin := r.Header.Get("Origin")
referer := r.Header.Get("Referer")
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if xfProto := r.Header.Get("X-Forwarded-Proto"); xfProto != "" {
scheme = xfProto
}
if reason := csrf.FailureReason(r); reason != nil {
log.Printf("CSRF validation failed: %v | host=%s scheme=%s origin=%s referer=%s", reason, r.Host, scheme, origin, referer)
} else {
log.Printf("CSRF validation failed: unknown reason | host=%s scheme=%s origin=%s referer=%s", r.Host, scheme, origin, referer)
}
http.Error(w, "Forbidden - CSRF", http.StatusForbidden)
})),
}
if len(trustedOrigins) > 0 {
log.Printf("CSRF trusted origins: %v", trustedOrigins)
csrfOpts = append(csrfOpts, csrf.TrustedOrigins(trustedOrigins))
}
csrfMw := csrf.Protect([]byte(csrfKey), csrfOpts...)
// Protected routes
protected := r.PathPrefix("/").Subrouter()
protected.Use(middleware.AuthMiddleware)
if disableCSRF {
log.Println("WARNING: CSRF is DISABLED by environment (CSRF_DISABLE=true) - DO NOT USE IN PRODUCTION")
} else {
// Use standard gorilla/csrf; if origin checks are problematic in certain environments,
// fallback to simple double-submit cookie middleware via CSRF_MODE=simple
if os.Getenv("CSRF_MODE") == "simple" {
protected.Use(middleware.CSRFSimple)
} else {
protected.Use(csrfMw)
}
}
protected.HandleFunc("/", web.DashboardHandler).Methods("GET")
protected.HandleFunc("/logout", web.LogoutHandler).Methods("POST")
protected.HandleFunc("/jobs", web.JobsHandler).Methods("GET", "POST")
protected.HandleFunc("/invoices", web.InvoicesHandler).Methods("GET", "POST")
protected.HandleFunc("/ok-invoice/{id}", web.UpdateInvoiceStatusHandler).Methods("PUT")

11
go.mod

@ -2,4 +2,13 @@ module marmic/servicetrade-toolbox
go 1.22.1
require github.com/gorilla/mux v1.8.1
require (
github.com/gorilla/csrf v1.7.3
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
)

10
go.sum

@ -1,2 +1,12 @@
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=

4
internal/handlers/web/documents.go

@ -18,6 +18,8 @@ import (
"marmic/servicetrade-toolbox/internal/api"
"marmic/servicetrade-toolbox/internal/middleware"
"marmic/servicetrade-toolbox/internal/utils"
"github.com/gorilla/csrf"
)
// UploadResult represents the result of a single file upload
@ -70,6 +72,8 @@ func DocumentsHandler(w http.ResponseWriter, r *http.Request) {
"Title": "Document Uploads",
"Session": session,
}
// Include CSRF token for htmx headers in partials
data["CSRFToken"] = csrf.Token(r)
if r.Header.Get("HX-Request") == "true" {
// For HTMX requests, just send the document_upload partial

4
internal/handlers/web/login.go

@ -1,6 +1,7 @@
package web
import (
"html"
"log"
root "marmic/servicetrade-toolbox"
"marmic/servicetrade-toolbox/internal/api"
@ -23,7 +24,8 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
err := session.Login(email, password)
if err != nil {
if r.Header.Get("HX-Request") == "true" {
w.Write([]byte("<div class='error'>Login failed: " + err.Error() + "</div>"))
safeMsg := html.EscapeString(err.Error())
w.Write([]byte("<div class='error'>Login failed: " + safeMsg + "</div>"))
} else {
http.Error(w, "Login failed", http.StatusUnauthorized)
}

11
templates/layout.html

@ -6,6 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ServiceTrade Tools</title>
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<script>
// Read XSRF-TOKEN cookie and send as X-CSRF-Token on all htmx requests
document.addEventListener('DOMContentLoaded', function () {
document.body.addEventListener('htmx:configRequest', function (evt) {
var m = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/);
if (m) {
try { evt.detail.headers['X-CSRF-Token'] = decodeURIComponent(m[1]); } catch (e) { }
}
});
});
</script>
<link rel="stylesheet" href="/static/css/styles.css" />
<link rel="stylesheet" href="/static/css/upload.css" />
<script src="/static/js/dashboard-drag.js"></script>

Loading…
Cancel
Save