Files
pixa/internal/handlers/auth.go
T
clawbot a96eba8083
check / check (push) Failing after 1s
CSRF protection on the login and URL-generator forms (closes #93)
Adds CSRF protection to the two cookie-authenticated form posts, POST / (login) and POST /generate, using github.com/gorilla/csrf, the recorded default for this job.

The token key is derived from signing_key with its own HKDF salt, so it needs no new config and survives restarts. The token cookie is separate from the session cookie, which also covers login CSRF, where no session exists yet. Both templates carry the hidden token field.

What a reader would trip over: outside debug mode the library enforces its https Referer origin check, so the TLS-terminating proxy must preserve the Host and Referer headers from the browser or form posts are rejected.

Disclosure: one nolint:gosec on a test constant holding the library field name (G101 false positive).

Model: opus-4-8 (implementation, review); fable-5-1 (landing message)
2026-09-21 19:26:18 +02:00

261 lines
6.3 KiB
Go

package handlers
import (
"crypto/subtle"
"html/template"
"net/http"
"net/url"
"strconv"
"time"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
"sneak.berlin/go/pixa/internal/templates"
)
// HandleRoot serves the login page or generator page based on authentication state.
func (s *Handlers) HandleRoot() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
s.handleLoginPost(w, r)
return
}
// Check if authenticated
if s.sessMgr.IsAuthenticated(r) {
s.renderGenerator(w, r, nil)
return
}
// Show login page
s.renderLogin(w, r, "")
}
}
// handleLoginPost handles login form submission.
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
s.renderLogin(w, r, "Invalid form data")
return
}
submittedKey := r.FormValue("key")
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
s.renderLogin(w, r, "Invalid signing key")
return
}
// Create session
err = s.sessMgr.CreateSession(w)
if err != nil {
s.log.Error("failed to create session", "error", err)
s.renderLogin(w, r, "Failed to create session")
return
}
s.log.Info("successful login", "remote_addr", r.RemoteAddr)
// Redirect to generator page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// HandleLogout clears the session and redirects to login.
func (s *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.sessMgr.ClearSession(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// HandleGenerateURL handles the URL generation form submission.
func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check authentication
if !s.sessMgr.IsAuthenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
err := r.ParseForm()
if err != nil {
s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"})
return
}
sourceURL := r.FormValue("url")
// Validate source URL
parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" {
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
return
}
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
// Generate encrypted token
token, err := s.encGen.Generate(payload)
if err != nil {
s.log.Error("failed to generate encrypted URL", "error", err)
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
return
}
generatedURL := s.buildGeneratedURL(r, token, r.FormValue("format"))
// Format expiry for display
expiresAtStr := "Never"
if ttl > 0 {
expiresAtStr = expiresAt.Format(time.RFC3339)
}
s.renderGenerator(w, r, &generatorData{
GeneratedURL: generatedURL,
ExpiresAt: expiresAtStr,
FormURL: sourceURL,
FormWidth: r.FormValue("width"),
FormHeight: r.FormValue("height"),
FormFormat: r.FormValue("format"),
FormQuality: r.FormValue("quality"),
FormFit: r.FormValue("fit"),
FormTTL: r.FormValue("ttl"),
})
}
}
// buildGeneratePayload parses the numeric form fields and assembles the
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
func buildGeneratePayload(
parsed *url.URL, form url.Values,
) (*encurl.Payload, time.Time, int) {
width, _ := strconv.Atoi(form.Get("width"))
height, _ := strconv.Atoi(form.Get("height"))
quality, _ := strconv.Atoi(form.Get("quality"))
ttl, _ := strconv.Atoi(form.Get("ttl"))
if quality <= 0 {
quality = 85
}
var (
expiresAt time.Time
expiresAtUnix int64
)
if ttl > 0 {
expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
expiresAtUnix = expiresAt.Unix()
}
payload := &encurl.Payload{
SourceHost: parsed.Host,
SourcePath: parsed.Path,
SourceQuery: parsed.RawQuery,
Width: width,
Height: height,
Format: imgcache.ImageFormat(form.Get("format")),
Quality: quality,
FitMode: imgcache.FitMode(form.Get("fit")),
ExpiresAt: expiresAtUnix,
}
return payload, expiresAt, ttl
}
// generatorData holds template data for the generator page.
type generatorData struct {
GeneratedURL string
ExpiresAt string
Error string
FormURL string
FormWidth string
FormHeight string
FormFormat string
FormQuality string
FormFit string
FormTTL string
CSRFField template.HTML
}
func (s *Handlers) renderLogin(
w http.ResponseWriter, r *http.Request, errorMsg string,
) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Error string
CSRFField template.HTML
}{
Error: errorMsg,
CSRFField: csrfField(r),
}
err := templates.Render(w, "login.html", data)
if err != nil {
s.log.Error("failed to render login template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGenerator(
w http.ResponseWriter, r *http.Request, data *generatorData,
) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if data == nil {
data = &generatorData{}
}
data.CSRFField = csrfField(r)
err := templates.Render(w, "generator.html", data)
if err != nil {
s.log.Error("failed to render generator template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (s *Handlers) renderGeneratorWithForm(
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
) {
s.renderGenerator(w, r, &generatorData{
Error: errorMsg,
FormURL: form.Get("url"),
FormWidth: form.Get("width"),
FormHeight: form.Get("height"),
FormFormat: form.Get("format"),
FormQuality: form.Get("quality"),
FormFit: form.Get("fit"),
FormTTL: form.Get("ttl"),
})
}
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
// Build full URL (URL-encode the token for safety)
scheme := "https"
if s.config.Debug {
scheme = "http"
}
// Determine file extension for the trailing filename
ext := format
if ext == "" || ext == "orig" {
ext = "jpg" // Default extension
}
return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext
}