all repos — weimar @ a213da87e3a6d37de1c358472e176aaf8835b995

Unnamed repository; edit this file 'description' to name the repository.

fix: security hardening — bcrypt cost, secure cookies, security headers, avatar auth

Bump bcrypt cost from 10 to 12 for adequate brute-force resistance in 2026.
Add behind_proxy config option that enables Secure flag on session cookies
for deployments behind a TLS-terminating reverse proxy (Caddy, nginx).
Add X-Content-Type-Options, X-Frame-Options, and Referrer-Policy security
headers to every response. Fix avatar endpoint auth mismatch — avatars are
now served publicly since their URLs are exposed in gallery listings anyway.

Co-authored-by: Crush
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 19:56:35 +0200
commit

a213da87e3a6d37de1c358472e176aaf8835b995

parent

87601e8f1973d0b01106ea99ce63228c88130e1a

M AGENTS.mdAGENTS.md

@@ -205,6 +205,7 @@ ```toml

[server] host = "0.0.0.0" port = 8080 +# behind_proxy = true # set when behind Caddy/nginx (enables Secure cookies) [database] path = "./data/weimar.db"
M README.mdREADME.md

@@ -22,11 +22,14 @@ The core loop is dead simple. Register an account (or have an admin make one

for you), upload files through the web form with whatever tags make sense, and they appear in the gallery. Click any thumbnail to open the full image in an overlay viewer: arrow keys to browse, `Esc` to close, click outside to dismiss. +The URL updates to a shareable link (`/browse?image=<id>`) that you can send to +anyone — even people without an account, because browsing is public by default. Tags are how you find things later. Type a few words when uploading and the autocomplete will suggest tags other people have used. Filter the gallery by -clicking tags or adding `?tag=funny` to the URL. Each image gets a permanent -page you can share, a download link, and an auto-generated thumbnail. +clicking tags or adding `?tag=funny` to the URL. If you're the uploader, you +can edit tags after the fact: a text field with the same autocomplete and ADD +and REMOVE buttons appears below the tag display in the viewer. ## Where data lives on disk

@@ -67,6 +70,26 @@ max_size_mb = 50

[auth] allow_registration = true +``` + +If you're putting weimar behind a TLS-terminating reverse proxy like Caddy or +nginx (which you should if facing the Internet), add this: + +```toml +[server] +behind_proxy = true +``` + +This enables the `Secure` flag on session cookies so your browser doesn't refuse +to send them over the HTTPS connection to the proxy. The security headers +(`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, +`Referrer-Policy: same-origin`) are always set regardless. + +You can also require authentication to browse the gallery: + +```toml +[auth] +require_auth_for_browse = true ``` If you don't want to bother with a config file at all, you can set every option
M cmd/weimar/root.gocmd/weimar/root.go

@@ -75,6 +75,7 @@

func setDefaults() { viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.port", 8080) + viper.SetDefault("server.behind_proxy", false) viper.SetDefault("database.path", "./data/weimar.db") viper.SetDefault("storage.path", "./data/images") viper.SetDefault("upload.max_size_mb", 50)
M cmd/weimar/serve.gocmd/weimar/serve.go

@@ -78,6 +78,8 @@ func init() {

rootCmd.AddCommand(serveCmd) serveCmd.Flags().StringVar(&adminEmail, "admin-email", "", "admin email for first-run setup") serveCmd.Flags().StringVar(&adminPassword, "admin-password", "", "admin password for first-run setup") + serveCmd.Flags().Bool("behind-proxy", false, "set Secure flag on cookies and trust X-Forwarded headers") + viper.BindPFlag("server.behind_proxy", serveCmd.Flags().Lookup("behind-proxy")) viper.BindPFlag("server.host", serveCmd.Flags().Lookup("host")) viper.BindPFlag("server.port", serveCmd.Flags().Lookup("port")) }
M internal/api/auth.gointernal/api/auth.go

@@ -43,6 +43,7 @@ Name: auth.SessionCookieName,

Value: token, Path: "/", HttpOnly: true, + Secure: a.Cfg.Server.SecureCookie(), SameSite: http.SameSiteLaxMode, })

@@ -81,6 +82,7 @@ Name: auth.SessionCookieName,

Value: token, Path: "/", HttpOnly: true, + Secure: a.Cfg.Server.SecureCookie(), SameSite: http.SameSiteLaxMode, }) }
M internal/auth/register.gointernal/auth/register.go

@@ -20,7 +20,7 @@

const ( MinPasswordLength = 8 MinUsernameLength = 3 - bcryptCost = 10 + bcryptCost = 12 ) // Register creates a new user account. If allowRegistration is false it
M internal/config/config.gointernal/config/config.go

@@ -11,8 +11,13 @@ Auth AuthConfig

} type ServerConfig struct { - Host string - Port int + Host string + Port int + BehindProxy bool `mapstructure:"behind_proxy"` +} + +func (s ServerConfig) SecureCookie() bool { + return s.BehindProxy } type DatabaseConfig struct {

@@ -36,8 +41,9 @@

func FromViper() Config { return Config{ Server: ServerConfig{ - Host: viper.GetString("server.host"), - Port: viper.GetInt("server.port"), + Host: viper.GetString("server.host"), + Port: viper.GetInt("server.port"), + BehindProxy: viper.GetBool("server.behind_proxy"), }, Database: DatabaseConfig{ Path: viper.GetString("database.path"),
M internal/server/server.gointernal/server/server.go

@@ -54,7 +54,7 @@

// Users (authenticated). mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe)) mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar)) - mux.HandleFunc("GET /api/users/{id}/avatar", s.requireAuth(h.HandleServeAvatar)) + mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar) // SPA — serve index.html for all non-API routes (client-side routing). mux.Handle("/", s.spaHandler(webFS))

@@ -70,7 +70,11 @@ }

func (s *Server) Start(ctx context.Context) error { addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port) - srv := &http.Server{Addr: addr, Handler: s.mux} + + // Wrap the mux with a handler that sets security headers. + handler := securityHeaders(s.mux) + + srv := &http.Server{Addr: addr, Handler: handler} go func() { <-ctx.Done()

@@ -124,3 +128,13 @@ func handleHealth(w http.ResponseWriter, r *http.Request) {

w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) } + +// securityHeaders wraps a handler with security-related HTTP headers. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "same-origin") + next.ServeHTTP(w, r) + }) +}