From 2ae058de8cac9aba1722e252f06d8bcae6733c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yannik=20R=C3=B6del?= Date: Mon, 5 Jan 2026 13:14:27 +0100 Subject: [PATCH] [wip] Go coooode --- server/angestoepselt.go | 66 ++++++++++ server/antispam/antispam.go | 56 ++++++++ server/antispam/hcaptcha.go | 83 ++++++++++++ server/antispam/hcaptcha_test.go | 80 ++++++++++++ server/antispam/noop.go | 27 ++++ server/csp/csp.go | 76 +++++++++++ server/csp/request.go | 23 ++++ server/csp/schema.go | 25 ++++ server/csrf.go | 179 ++++++++++++++++++++++++++ server/csrf_test.go | 134 +++++++++++++++++++ server/forms.go | 212 +++++++++++++++++++++++++++++++ server/go.mod | 13 ++ server/go.sum | 10 ++ server/main.go | 73 +++++++++++ 14 files changed, 1057 insertions(+) create mode 100644 server/angestoepselt.go create mode 100644 server/antispam/antispam.go create mode 100644 server/antispam/hcaptcha.go create mode 100644 server/antispam/hcaptcha_test.go create mode 100644 server/antispam/noop.go create mode 100644 server/csp/csp.go create mode 100644 server/csp/request.go create mode 100644 server/csp/schema.go create mode 100644 server/csrf.go create mode 100644 server/csrf_test.go create mode 100644 server/forms.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go diff --git a/server/angestoepselt.go b/server/angestoepselt.go new file mode 100644 index 0000000..9c39d71 --- /dev/null +++ b/server/angestoepselt.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "net/http" +) +import "github.com/gorilla/schema" + +var decoder = schema.NewDecoder() + +func setupSite(mux *http.ServeMux) { + form{ + path: "/kontakt", + toSubmission: func(r *http.Request) (submission, error) { + var data struct { + Name string `schema:"contactname"` + Email string `schema:"contactemail"` + Message string `schema:"message"` + } + + err := decoder.Decode(&data, r.PostForm) + if err != nil { + return submission{}, fmt.Errorf("POST decode error: %w", err) + } + + return submission{ + group: "", + category: "", + message: "", + information: nil, + }, nil + }, + }.setup(mux) + + form{ + path: "/computer-beantragen/privat", + toSubmission: func(r *http.Request) (submission, error) { + var data struct { + Name string `schema:"contactname"` + Email string `schema:"contactemail"` + Message string `schema:"message"` + Addressline string `schema:"addressline"` + Postalcode string `schema:"postalcode"` + City string `schema:"city"` + } + + err := decoder.Decode(&data, r.PostForm) + if err != nil { + return submission{}, fmt.Errorf("POST decode error: %w", err) + } + + //documentFile, documentHeader, err := r.FormFile("document") + //if err != nil { + // return submission{}, fmt.Errorf("could not extract document upload: %w", err) + //} + //documentType := mime.TypeByExtension(documentHeader.Filename) + + return submission{ + group: "", + category: "", + message: "", + information: nil, + }, nil + }, + }.setup(mux) +} diff --git a/server/antispam/antispam.go b/server/antispam/antispam.go new file mode 100644 index 0000000..33333b1 --- /dev/null +++ b/server/antispam/antispam.go @@ -0,0 +1,56 @@ +// Package antispam defines HTML templates and the corresponding server-side +// logic for integrating captcha providers. +package antispam + +import ( + "context" + "html/template" + "log/slog" + "net/http" + "os" +) + +type Antispam interface { + // Script contains HTML `` +} + +func (hcaptcha *HCaptcha) FormField() template.HTML { + return hcaptcha.formField +} + +func (hcaptcha *HCaptcha) CheckRequest(r *http.Request) (bool, error) { + err := r.ParseForm() + if err != nil { + return false, fmt.Errorf("error parsing form: %w", err) + } + + token := r.PostForm.Get("x-captcha-response") + if token == "" { + slog.Debug("No hCaptcha response token in request") + return false, nil + } + + form := url.Values{} + form.Set("secret", hcaptcha.secretKey) + form.Set("response", token) + res, err := http.PostForm(hcaptcha.siteverifyEndpoint, form) + if err != nil { + return false, fmt.Errorf("hCaptcha site verify error: %w", err) + } + + var data struct { + Success bool `json:"success"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes"` + } + err = json.NewDecoder(res.Body).Decode(&data) + if err != nil { + return false, fmt.Errorf("hCaptcha site verify: JSON decode error: %w", err) + } + if res.StatusCode != http.StatusOK { + return false, fmt.Errorf("hCaptcha site verify: error codes %s, status %d", strings.Join(data.ErrorCodes, ", "), res.StatusCode) + } + + result := data.Success && data.Hostname == r.URL.Hostname() + return result, nil +} + +// NewHcaptcha returns an antispam implementation using the hCaptcha service. +func NewHcaptcha(siteKey string, secretKey string) *HCaptcha { + return newHcaptcha(siteKey, secretKey, "https://hcaptcha.com") +} + +func newHcaptcha(siteKey string, secretKey string, endpoint string) *HCaptcha { + formField := `
` + + siteverifyEndpoint, err := url.JoinPath(endpoint, "siteverify") + if err != nil { + log.Panicf("error constructing siteverify endpoint: %v", err) + } + + return &HCaptcha{ + formField: template.HTML(formField), + secretKey: secretKey, + siteverifyEndpoint: siteverifyEndpoint, + } +} diff --git a/server/antispam/hcaptcha_test.go b/server/antispam/hcaptcha_test.go new file mode 100644 index 0000000..4c19c8d --- /dev/null +++ b/server/antispam/hcaptcha_test.go @@ -0,0 +1,80 @@ +package antispam + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func setupHcaptchaTest(t *testing.T) (*httptest.Server, *HCaptcha) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/siteverify" { + t.Errorf("Expecting call to `POST /siteverify`, got: %s %s", r.Method, r.URL.Path) + } + + err := r.ParseForm() + if err != nil { + t.Errorf("Form parsing error: %s", err) + } + if secret := r.PostForm.Get("secret"); secret != "test" { + t.Errorf("Expecting hCaptcha secret key `test`, got: %s", secret) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if r.PostForm.Get("response") == "test" { + _, _ = w.Write([]byte(`{"success": true, "hostname": "localhost"}`)) + } else { + _, _ = w.Write([]byte(`{"success": false, "hostname": "localhost", "error-codes": ["invalid-input-response"]}`)) + } + })) + hcaptcha := newHcaptcha("test", "test", server.URL) + return server, hcaptcha +} + +// TestHcaptchaEmpty checks that empty requests (without the hCaptcha token) are blocked. +func TestHcaptchaEmpty(t *testing.T) { + server, hcaptcha := setupHcaptchaTest(t) + defer server.Close() + + req := httptest.NewRequest(http.MethodPost, "http://localhost/form", nil) + result, err := hcaptcha.CheckRequest(req) + if result || err != nil { + t.Errorf("hcaptcha.CheckRequest(req) = %v, %v, expecting false, nil", result, err) + } +} + +// TestHcaptchaBadToken checks that invalid hCaptcha tokens are blocked. +func TestHcaptchaBadToken(t *testing.T) { + server, hcaptcha := setupHcaptchaTest(t) + defer server.Close() + + form := url.Values{} + form.Add("x-captcha-response", "wrong") + req := httptest.NewRequest(http.MethodPost, "http://localhost/form", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + result, err := hcaptcha.CheckRequest(req) + if result || err != nil { + t.Errorf("hcaptcha.CheckRequest(req) = %v, %v, want false, nil", result, err) + } +} + +// TestHcaptchaSuccess mocks a successful hCaptcha verification run. +func TestHcaptchaSuccess(t *testing.T) { + server, hcaptcha := setupHcaptchaTest(t) + defer server.Close() + + form := url.Values{} + form.Add("x-captcha-response", "test") + req := httptest.NewRequest(http.MethodPost, "http://localhost/form", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + result, err := hcaptcha.CheckRequest(req) + if !result || err != nil { + t.Errorf("hcaptcha.CheckRequest(req) = %v, %v, want true, nil", result, err) + } +} diff --git a/server/antispam/noop.go b/server/antispam/noop.go new file mode 100644 index 0000000..581d7a3 --- /dev/null +++ b/server/antispam/noop.go @@ -0,0 +1,27 @@ +package antispam + +import ( + "html/template" + "net/http" +) + +type Noop struct{} + +func (_ *Noop) Script() template.HTML { + return "" +} + +func (_ *Noop) FormField() template.HTML { + return "" +} + +func (_ *Noop) CheckRequest(_ *http.Request) (bool, error) { + return true, nil +} + +var globalNoop = &Noop{} + +// NewNoop returns an antispam implementation that allows every request. +func NewNoop() *Noop { + return globalNoop +} diff --git a/server/csp/csp.go b/server/csp/csp.go new file mode 100644 index 0000000..b699a61 --- /dev/null +++ b/server/csp/csp.go @@ -0,0 +1,76 @@ +package csp + +import ( + "crypto/rand" + "encoding/base64" + "log" + "net/http" +) + +type cspKeyword uint + +//go:generate stringer -type=cspKeyword -linecomment +const ( + Self cspKeyword = iota // self + TrustedTypesEval // trusted-types-eval + InlineSpeculationRules // inline-speculation-rules + StrictDynamic // strict-dynamic + ReportSample // report-sample +) + +type cspDirective struct { + values []string + keywords map[cspKeyword]struct{} +} + +type Builder struct { + directives map[string]*cspDirective + nonce string + request *http.Request +} + +func (csp *Builder) getDirective(directive string) *cspDirective { + d, ok := csp.directives[directive] + if !ok { + d = &cspDirective{ + values: []string{}, + keywords: map[cspKeyword]struct{}{}, + } + csp.directives[directive] = d + } + return d +} + +// WithValue adds a directive to the policy. +func (csp *Builder) WithValue(directive string, values ...string) *Builder { + csp.getDirective(directive).withValue(values...) + return csp +} + +func (d *cspDirective) withValue(values ...string) *cspDirective { + d.values = append(d.values, values...) + return d +} + +// WithKeyword adds a directive to the policy. +func (csp *Builder) WithKeyword(directive string, keyword cspKeyword) *Builder { + csp.getDirective(directive).keywords[keyword] = struct{}{} + return csp +} + +// WithNonce adds a `nonce-…` value to the directive. The actual nonce value is +// generated when the policy is built. +func (csp *Builder) WithNonce(directive string) *Builder { + if csp.nonce != "" { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + log.Fatalf("Failed to generate nonce: %v", err) + } + csp.nonce = base64.RawURLEncoding.EncodeToString(b[:]) + } + return csp.WithValue(directive, "nonce-"+csp.nonce) +} + +func (csp *Builder) Build() string { + panic("TODO") +} diff --git a/server/csp/request.go b/server/csp/request.go new file mode 100644 index 0000000..a4c3611 --- /dev/null +++ b/server/csp/request.go @@ -0,0 +1,23 @@ +package csp + +import ( + "context" + "net/http" +) + +type cspKey struct{} + +func Csp(r *http.Request) (*http.Request, *Builder) { + csp, ok := r.Context().Value(cspKey{}).(*Builder) + if !ok { + csp = &Builder{ + request: r, + } + r = r.WithContext(context.WithValue(r.Context(), cspKey{}, csp)) + } + return r, csp +} + +func (csp *Builder) Write() { + csp.request.Header.Set("Content-Security-Policy", csp.Build()) +} diff --git a/server/csp/schema.go b/server/csp/schema.go new file mode 100644 index 0000000..f6708d6 --- /dev/null +++ b/server/csp/schema.go @@ -0,0 +1,25 @@ +package csp + +const ( + ScriptSrc = "script-src" +) + +func (csp *Builder) WithScript(src ...string) *Builder { + return csp.WithValue(ScriptSrc, src...) +} + +func (csp *Builder) WithScriptSelf() *Builder { + return csp.WithKeyword(ScriptSrc, Self) +} + +func (csp *Builder) WithScriptTrustedTypesEval() *Builder { + return csp.WithKeyword(ScriptSrc, TrustedTypesEval) +} + +func (csp *Builder) WithScriptInlineSpeculationRules() *Builder { + return csp.WithKeyword(ScriptSrc, InlineSpeculationRules) +} + +func (csp *Builder) WithScriptNonce() *Builder { + return csp.WithNonce(ScriptSrc) +} diff --git a/server/csrf.go b/server/csrf.go new file mode 100644 index 0000000..25a61f7 --- /dev/null +++ b/server/csrf.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "html/template" + "log/slog" + "net/http" + "slices" + + "github.com/gorilla/csrf" +) + +func csrfError(w http.ResponseWriter, r *http.Request) { + slog.Debug("CSRF error", "reason", csrf.FailureReason(r)) + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) +} + +type fetchMetadataKey struct{} + +type FetchMetadataSite string +type FetchMetadataMode string +type FetchMetadataDest string + +const ( + SiteCrossSite FetchMetadataSite = "cross-site" + SiteNone FetchMetadataSite = "none" + SiteSameOrigin FetchMetadataSite = "same-origin" + SiteSameSite FetchMetadataSite = "same-site" + + ModeCors FetchMetadataMode = "cors" + ModeNavigate FetchMetadataMode = "navigate" + ModeNoCors FetchMetadataMode = "no-cors" + ModeSameOrigin FetchMetadataMode = "same-origin" + ModeWebSocket FetchMetadataMode = "websocket" + + DestAudio FetchMetadataDest = "audio" + DestAudioworklet FetchMetadataDest = "audioworklet" + DestDocument FetchMetadataDest = "document" + DestEmbed FetchMetadataDest = "embed" + DestEmpty FetchMetadataDest = "empty" + DestFencedframe FetchMetadataDest = "fencedframe" + DestFont FetchMetadataDest = "font" + DestFrame FetchMetadataDest = "frame" + DestIframe FetchMetadataDest = "iframe" +) + +var ( + fetchMetadataSites = []FetchMetadataSite{SiteCrossSite, SiteNone, SiteSameOrigin, SiteSameSite} + fetchMetadataModes = []FetchMetadataMode{ModeCors, ModeNavigate, ModeNoCors, ModeSameOrigin, ModeWebSocket} + fetchMetadataDests = []FetchMetadataDest{DestAudio, DestAudioworklet, DestDocument, DestEmbed, DestEmpty, DestFencedframe, DestFont, DestFrame, DestIframe} +) + +type FetchMetadata struct { + // Site is the pre-validated value of the `Sec-Fetch-Site` header. + Site FetchMetadataSite + // Mode is the pre-validated value of the `Sec-Fetch-Mode` header. + Mode FetchMetadataMode + // Dest is the pre-validated value of the `Sec-Fetch-Dest` header. + Dest FetchMetadataDest +} + +func (fm *FetchMetadata) IsSameOrigin() bool { + return fm != nil && fm.Site == "same-origin" +} + +// IsFetch checks whether the request originated from a client- side `fetch()` +// (or similar) call. +func (fm *FetchMetadata) IsFetch() bool { + return fm != nil && fm.Site == "same-origin" && fm.Mode == "cors" && fm.Dest == "empty" +} + +// IsLocalNavigation checks if the request originated from a top-level +// navigation, while optionally allowing frame embedding. +func (fm *FetchMetadata) IsLocalNavigation(allowFrames bool) bool { + if fm == nil || fm.Site != "same-origin" || fm.Mode != "navigate" { + return false + } + if allowFrames { + return fm.Dest == "document" || fm.Dest == "fencedframe" || fm.Dest == "frame" || fm.Dest == "iframe" + } else { + return fm.Dest == "document" + } +} + +func validateFetchMetadata(r *http.Request) *FetchMetadata { + site := FetchMetadataSite(r.Header.Get("Sec-Fetch-Site")) + if site == "" { + return nil + } + + if !slices.Contains(fetchMetadataSites, site) { + slog.Debug("Invalid Sec-Metadata-Site header value", "fetchMetadataSite", site) + return &FetchMetadata{Valid: false} + } + + mode := r.Header.Get("Sec-Fetch-Mode") + if !slices.Contains(fetchMetadataModes, mode) { + slog.Debug("Invalid Sec-Metadata-Mode header value", "fetchMetadataMode", mode) + return &FetchMetadata{Valid: false} + } + + dest := r.Header.Get("Sec-Fetch-Dest") + if _, ok := slices.BinarySearch(fetchMetadataDests, dest); !ok { + slog.Debug("Invalid Sec-Metadata-Dest header value", "fetchMetadataDest", dest) + return &FetchMetadata{Valid: false} + } + + return &FetchMetadata{ + Valid: true, + Site: site, + Mode: mode, + Dest: dest, + } +} + +// GetFetchMetadata returns the validate fetch metadata headers of the request. +// This may be nil if there were none (for old browsers and other user agents) +// or they were invalid. +// +// Invalid headers should be ignored according to the specification: +// https://w3c.github.io/webappsec-fetch-metadata +func GetFetchMetadata(r *http.Request) *FetchMetadata { + fm, _ := r.Context().Value(fetchMetadataKey{}).(*FetchMetadata) + return fm +} + +// AbortFetchMetadata terminates the request with a CSRF error indicating that +// fetch metadata headers did not comply. +func AbortFetchMetadata(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + r = r.WithContext(context.WithValue(ctx, "gorilla.csrf.Error", "CSRF metadata headers did not comply")) + csrfError(w, r) +} + +// ProtectCsrf is an HTTP middleware for CSRF protection. +// +// It defaults to using Fetch Metadata Request Headers (prefixed with `Sec-Fetch`) +// to check whether requests were in fact first-party requests coming from the +// same HTTP origin. Only the site header is used – other metadata must be checked +// in route implementations. +// +// Failing support for Fetch Metadata Request Headers, CSRF tokens from hidden +// form fields (see CsrfTemplateField) are used as a fallback. +func ProtectCsrf(next http.Handler, authKey []byte, middlewareOpts ...csrf.Option) http.HandlerFunc { + middleware := csrf.Protect( + authKey, + append(middlewareOpts, csrf.ErrorHandler(http.HandlerFunc(csrfError)))..., + )(next) + + return func(w http.ResponseWriter, r *http.Request) { + fetchMetadata := validateFetchMetadata(r) + if fetchMetadata != nil { + /* + switch r.Method { + case "GET", "HEAD", "OPTIONS", "TRACE": // Idempotent (safe) methods [RFC7231, 4.2.2] + default: + if secFetchSiteHeader != "same-origin" { + ctx := r.Context() + r = r.WithContext(context.WithValue(ctx, "gorilla.csrf.Error", "fetch metadata headers indicate cross-origin request")) + csrfError(w, r) + return + } + }*/ + r = r.WithContext(context.WithValue(r.Context(), fetchMetadataKey{}, fetchMetadata)) + next.ServeHTTP(w, r) + } else { + middleware.ServeHTTP(w, r) + } + } +} + +// CsrfTemplateField is a template helper for CSRF protection. Stick it into +// any form that submits to a CSRF-protected endpoint. +func CsrfTemplateField(r *http.Request) template.HTML { + if r.Header.Get("Sec-Fetch-Site") != "" { + return "" + } + return csrf.TemplateField(r) +} diff --git a/server/csrf_test.go b/server/csrf_test.go new file mode 100644 index 0000000..abe9c91 --- /dev/null +++ b/server/csrf_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" +) + +var emptyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +}) + +type fetchMetadata struct { + site string + mode string + dest string + // Sec-Fetch-User is not tested here because it has limited availability: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-User#browser_compatibility +} + +func addFetchMetadataHeaders(req *http.Request, m fetchMetadata) { + req.Header.Set("Sec-Fetch-Site", m.site) + req.Header.Set("Sec-Fetch-Mode", m.mode) + req.Header.Set("Sec-Fetch-Dest", m.dest) +} + +// TestLookups makes sure that the lookup tables for valid fetch metadata header +// values are sorted correctly, so they can be used in binary searches. +func TestLookups(t *testing.T) { + t.Run("sites", func(t *testing.T) { + var s = slices.Sorted(slices.Values(fetchMetadataSites)) + if slices.Compare(s, fetchMetadataSites) != 0 { + t.Error("fetchMetadataSites is not sorted") + } + }) + t.Run("modes", func(t *testing.T) { + var s = slices.Sorted(slices.Values(fetchMetadataModes)) + if slices.Compare(s, fetchMetadataModes) != 0 { + t.Error("fetchMetadataModes is not sorted") + } + }) + t.Run("dests", func(t *testing.T) { + var s = slices.Sorted(slices.Values(fetchMetadataDests)) + if slices.Compare(s, fetchMetadataDests) != 0 { + t.Error("fetchMetadataDests is not sorted") + } + }) +} + +// TestCsrfFetchHeaderCookies sees that the response headers and (crucially) +// cookies don't change when the client supports fetch metadata request +// headers. +func TestCsrfFetchHeaderCookies(t *testing.T) { + handler := ProtectCsrf(emptyHandler, []byte("test")) + req := httptest.NewRequest(http.MethodGet, "/", nil) + addFetchMetadataHeaders(req, fetchMetadata{"none", "navigate", "document"}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + resp := w.Result() + if len(resp.Header) != 0 { + t.Errorf("Fetch metadata CSRF protection changed headers: %v", resp.Header) + } +} + +func TestCsrfFetchHeaderAllows(t *testing.T) { + handler := ProtectCsrf(emptyHandler, []byte("test")) + + run := func(t *testing.T, m string, fm fetchMetadata) { + req := httptest.NewRequest(m, "/", nil) + addFetchMetadataHeaders(req, fm) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Errorf("CSRF protection false block (status %d %s)", resp.StatusCode, resp.Status) + } + } + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + for _, fm := range []fetchMetadata{ + {"same-origin", "navigate", "document"}, // Same origin top-level navigation / form submit + {"same-origin", "cors", "empty"}, // Same origin fetch() request + } { + t.Run(fmt.Sprintf("site-%s,mode-%s,dest-%s", fm.site, fm.mode, fm.dest), func(t *testing.T) { + run(t, method, fm) + }) + } + }) + } + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + for _, fm := range []fetchMetadata{ + {"same-origin", "navigate", "document"}, // Same origin top-level navigation / form submit + {"same-origin", "cors", "empty"}, // Same origin fetch() request + } { + t.Run(fmt.Sprintf("site-%s,mode-%s,dest-%s", fm.site, fm.mode, fm.dest), func(t *testing.T) { + run(t, method, fm) + }) + } + }) + } +} + +func TestCsrfFetchHeaderBlocks(t *testing.T) { + handler := ProtectCsrf(emptyHandler, []byte("test")) + + run := func(t *testing.T, m string, fm fetchMetadata) { + req := httptest.NewRequest(m, "/", nil) + addFetchMetadataHeaders(req, fm) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + resp := w.Result() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("CSRF protection false allow (status %d %s)", resp.StatusCode, resp.Status) + } + } + + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + t.Run(method, func(t *testing.T) { + for _, fm := range []fetchMetadata{ + {"none", "navigate", "document"}, // Manual top-level navigation + {"cross-site", "navigate", "document"}, // Cross-site navigation + {"same-site", "navigate", "document"}, // eTLD-1 same-site navigation + } { + t.Run(fmt.Sprintf("site-%s,mode-%s,dest-%s", fm.site, fm.mode, fm.dest), func(t *testing.T) { + run(t, method, fm) + }) + } + }) + } +} diff --git a/server/forms.go b/server/forms.go new file mode 100644 index 0000000..c038200 --- /dev/null +++ b/server/forms.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "homepage/antispam" + "log/slog" + "mime/multipart" + "net/http" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + "unicode" + + "github.com/AlessandroSechi/zammad-go" +) + +const formMarker = "" +var namesRegex = regexp.MustCompile("^ *(.*) *([^ ]+) *$") +var multiSpaceRegex = regexp.MustCompile("\\s+") + +type submission struct { + group string + category string + name string + email string + message string + attachments []submissionAttachment + information []struct { + key string + value string + } +} + +type submissionAttachment struct { + multipart.File + *multipart.FileHeader +} + +type toSubmission func(r *http.Request) (submission, error) +type form struct { + path string + toSubmission toSubmission +} + +// CleanupInput removes unprintable characters like newlines. +func CleanupInput(s string) string { + s = strings.TrimSpace(s) + s = multiSpaceRegex.ReplaceAllString(s, " ") + return strings.Map(func(r rune) rune { + if unicode.IsPrint(r) { + return r + } + return -1 + }, s) +} + +func (form form) handleGet(w http.ResponseWriter, r *http.Request) { + logger := slog.Default().With("formPath", form.path) + ctx := r.Context() + + templateBytes, err := os.ReadFile(path.Join(getRootPath(r), form.path, "index.html")) + if err != nil { + logger.ErrorContext(ctx, "Failed to read form template", "error", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + output := string(templateBytes) + + markerIndex := strings.Index(output, formMarker) + if markerIndex != -1 { + csrfField := CsrfTemplateField(r) + antispamInstance := antispam.GetAntispam(r) + antispamField := string(antispamInstance.Script() + antispamInstance.FormField()) + output = output[:markerIndex] + string(csrfField) + antispamField + output[markerIndex+len(formMarker):] + } + + http.ServeContent(w, r, "form.html", time.Now(), strings.NewReader(output)) +} + +// getCustomerId uses the Zammad API to find a user ID to use as the +// submission's customer. +// +// Note the API also supports using `guess:{email}` as the custom ID when creating +// a ticket, according to the documentation [1]. However, that doesn't seem to +// work very reliably [2]. +// [1]: https://docs.zammad.org/en/latest/api/ticket/index.html#create +// [2]: https://codeberg.org/angestoepselt/homepage/issues/141 +func getCustomerId(submission *submission, zClient *zammad.Client) (int, error) { + nameParts := strings.Split(submission.name, " ") + if len(nameParts) == 1 { + nameParts = append([]string{"?"}, nameParts...) + } + + // Create a new user. This will fail if it already exists. + user, err := zClient.UserCreate(zammad.User{ + Firstname: strings.Join(nameParts[:len(nameParts)-1], " "), + Lastname: nameParts[len(nameParts)-1], + Email: submission.email, + }) + if err == nil { + return user.ID, nil + } + + var errResp *zammad.ErrorResponse + if !errors.As(err, &errResp) || !strings.Contains(errResp.Description, "already used") { + return 0, fmt.Errorf("creating new Zammad user failed: %w", err) + } + + // Email is already associated with an existing user account. Find that one + // instead and use its ID. + userCandidates, err := zClient.UserSearch("email:"+submission.email, 1) + if err != nil { + return 0, fmt.Errorf("search for existing Zammad user failed: %w", err) + } + if len(userCandidates) == 0 { + return 0, errors.New("search for existing Zammad user yielded no results") + } + if len(userCandidates) > 1 { + return 0, errors.New("search for existing Zammad user yielded more than one result") + } + return userCandidates[0].ID, nil +} + +func (form form) handlePost(w http.ResponseWriter, r *http.Request) { + logger := slog.Default().With("formPath", form.path) + ctx := r.Context() + + if !GetFetchMetadata(r).IsLocalNavigation(false) { + AbortFetchMetadata(w, r) + return + } + + err := r.ParseMultipartForm(128 << 20) // 128 KiB + if err != nil { + logger.DebugContext(ctx, "Failed to parse form template", "error", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + submission, err := form.toSubmission(r) + if err != nil { + logger.DebugContext(ctx, "Could not convert form content to submission", "error", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + contactNames := strings.Split(submission.name, " ") + + type zammadUsersBody struct { + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + Email string `json:"email"` + } + body := zammadUsersBody{ + FirstName: "?", + LastName: submission., + } + body, _ := json.Marshal(zammadUsersBody{ + FirstName: "?", + LastName: contactNames[len(contactNames)-1], + Email: "", + }) + resp = http.Post("TODO", "application/json", bytes.NewReader(body)) + + zClient, ok := r.Context().Value(zammadKey{}).(*zammad.Client) + if !ok { + logger.DebugContext(ctx, "Zammad integration unavailable") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + customerId, err := getCustomerId(&submission, zClient) + if err != nil { + logger.DebugContext(ctx, "Failed to match Zammad user as customer for submission", "error", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + zClientFrom := zammad.Client{ + Client: zClient.Client, + Token: zClient.Token, + Url: zClient.Url, + FromFunc: func() string { + return strconv.Itoa(customerId) + }, + } + zClientFrom.TicketCreate(zammad.Ticket{ + Title: fmt.Sprintf("Kontaktformular %s – %s", submission.name, form.path), // TODO form.path + Group: "", // TODO group + CustomerID: customerId, + Article: zammad.TicketArticle{ + Type: "web", + Internal: false, // TODO this was true + ContentType: "text/plain", + Subject: "Kontaktformular-Anfrage " + submission.name, + Body: submission.message, + Attachments: []zammad.TicketArticleAttachment{}, + }, + }) + + http.ServeFile(w, r, path.Join(getRootPath(r), "kontakt/fertig/index.html")) +} + +func (form form) setup(mux *http.ServeMux) { + mux.HandleFunc(http.MethodGet+" "+form.path+"/{$}", form.handleGet) + mux.HandleFunc(http.MethodPost+" "+form.path+"/{$}", form.handlePost) +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..e4bb663 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,13 @@ +module homepage + +go 1.25.4 + +require ( + github.com/AlessandroSechi/zammad-go v0.0.0-20241027101934-e9e7d13e8bd5 + github.com/gorilla/csrf v1.7.3 + github.com/gorilla/schema v1.4.1 +) + +require github.com/gorilla/securecookie v1.1.2 // indirect + +replace github.com/AlessandroSechi/zammad-go => /home/yannik/Repos/angestoepselt/zammad-go diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..b42a9d4 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,10 @@ +github.com/AlessandroSechi/zammad-go v0.0.0-20241027101934-e9e7d13e8bd5 h1:XKu2uuGhLEgqVxtUmRLevCb9ZM8JGYG4o3HRGRDvz04= +github.com/AlessandroSechi/zammad-go v0.0.0-20241027101934-e9e7d13e8bd5/go.mod h1:MnpvRP3H3st5kiSEHoOhBtgb7tHYSIOTD/JGGoz+sao= +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/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..0b58a00 --- /dev/null +++ b/server/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "homepage/antispam" + "log/slog" + "net" + "net/http" + "os" + "path" + + "github.com/AlessandroSechi/zammad-go" +) + +type rootPathKey struct{} +type zammadKey struct{} + +func getRootPath(r *http.Request) string { + if rootPath, ok := r.Context().Value(rootPathKey{}).(string); ok { + return rootPath + } + panic("missing root path in request context") +} + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + rootPath := path.Clean("../dist") + slog.Info("Found root path for static assets", "path", rootPath) + + var zammadClient *zammad.Client + if zammadUrl := os.Getenv("ZAMMAD_URL"); zammadUrl != "" { + zammadClient = zammad.New(zammadUrl) + token := os.Getenv("ZAMMAD_TOKEN") + if token == "" { + slog.Error("No Zammad token available, set ZAMMAD_TOKEN accordingly") + os.Exit(1) + } + slog.Info("Using Zammad integration", "url", zammadUrl) + zammadClient.Token = token + } else { + slog.Warn("Zammad integration disabled, set ZAMMAD_URL to enable") + } + + mux := http.NewServeMux() + + setupSite(mux) + mux.Handle("/", http.FileServer(http.Dir(rootPath))) + + var handler http.Handler + handler = ProtectCsrf(mux, []byte("fasdf")) + handler = antispam.WithAntispam(handler) + + server := http.Server{ + Addr: "localhost:8080", + Handler: handler, + BaseContext: func(_ net.Listener) context.Context { + ctx := context.Background() + ctx = context.WithValue(ctx, rootPathKey{}, rootPath) + if zammadClient != nil { + ctx = context.WithValue(ctx, zammadKey{}, zammadClient) + } + return ctx + }, + } + + slog.Info("Starting HTTP server", "addr", server.Addr) + if err := server.ListenAndServe(); err != nil { + slog.Error("Failed to start HTTP server", "error", err) + } +}