mirror of
https://codeberg.org/angestoepselt/homepage.git
synced 2026-03-21 22:32:17 +00:00
[wip] Go coooode
This commit is contained in:
parent
48137bf72b
commit
2ae058de8c
14 changed files with 1057 additions and 0 deletions
66
server/angestoepselt.go
Normal file
66
server/angestoepselt.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
56
server/antispam/antispam.go
Normal file
56
server/antispam/antispam.go
Normal file
|
|
@ -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 `<script>` tag(s) that need to be injected into the
|
||||
// form content or document head.
|
||||
Script() template.HTML
|
||||
// FormField is the template that needs to be injected into the `<form>`.
|
||||
FormField() template.HTML
|
||||
// CheckRequest uses the captcha provider to determine if request should be
|
||||
// allowed to proceed.
|
||||
//
|
||||
// Implementations using HTTP forms may require r.ParseForm() to have been
|
||||
// called.
|
||||
CheckRequest(r *http.Request) (bool, error)
|
||||
}
|
||||
|
||||
type antispamKey struct{}
|
||||
|
||||
// GetAntispam returns the active antispam implementation from request context.
|
||||
func GetAntispam(r *http.Request) Antispam {
|
||||
if antispam, ok := r.Context().Value(antispamKey{}).(Antispam); ok {
|
||||
return antispam
|
||||
}
|
||||
return NewNoop()
|
||||
}
|
||||
|
||||
// WithAntispam chooses a suitable antispam implementation sets up request
|
||||
// context accordingly.
|
||||
func WithAntispam(next http.Handler) http.HandlerFunc {
|
||||
hCaptchaSiteKey := os.Getenv("HCAPTCHA_SITE_KEY")
|
||||
hCaptchaSecretKey := os.Getenv("HCAPTCHA_SECRET_KEY")
|
||||
if hCaptchaSiteKey != "" && hCaptchaSecretKey != "" {
|
||||
slog.Info("antispam: using hCaptcha implementation")
|
||||
return withAntispam(next, NewHcaptcha(hCaptchaSiteKey, hCaptchaSecretKey))
|
||||
}
|
||||
|
||||
slog.Info("antispam: falling back to noop implementation")
|
||||
return next.ServeHTTP
|
||||
}
|
||||
|
||||
func withAntispam(next http.Handler, impl Antispam) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), antispamKey{}, impl)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
83
server/antispam/hcaptcha.go
Normal file
83
server/antispam/hcaptcha.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package antispam
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HCaptcha struct {
|
||||
formField template.HTML
|
||||
secretKey string
|
||||
siteverifyEndpoint string
|
||||
}
|
||||
|
||||
func (hcaptcha *HCaptcha) Script() template.HTML {
|
||||
return `<script src="https://js.hcaptcha.com/1/api.js" async defer></script>`
|
||||
}
|
||||
|
||||
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 := `<div class="h-captcha" data-sitekey="` + template.HTMLEscapeString(siteKey) + `"></div>`
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
80
server/antispam/hcaptcha_test.go
Normal file
80
server/antispam/hcaptcha_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
27
server/antispam/noop.go
Normal file
27
server/antispam/noop.go
Normal file
|
|
@ -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
|
||||
}
|
||||
76
server/csp/csp.go
Normal file
76
server/csp/csp.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
23
server/csp/request.go
Normal file
23
server/csp/request.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
25
server/csp/schema.go
Normal file
25
server/csp/schema.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
179
server/csrf.go
Normal file
179
server/csrf.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
134
server/csrf_test.go
Normal file
134
server/csrf_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
212
server/forms.go
Normal file
212
server/forms.go
Normal file
|
|
@ -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 = "<!-- FORM -->"
|
||||
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)
|
||||
}
|
||||
13
server/go.mod
Normal file
13
server/go.mod
Normal file
|
|
@ -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
|
||||
10
server/go.sum
Normal file
10
server/go.sum
Normal file
|
|
@ -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=
|
||||
73
server/main.go
Normal file
73
server/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue