Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / security_headers.go
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"html/template"
	"net/http"
	"net/url"
	"strings"
)

// scriptHash is the CSP source expression pinning the shell's inline script.
// It's computed from the *rendered* shell rather than the raw shellScriptJS
// constant: html/template rewrites some characters (e.g. "<") inside a
// <script> element as breakout protection, so only the emitted bytes are what
// the browser actually hashes. Deriving it here means it can never drift from
// what's served.
var scriptHash = "sha256-" + func() string {
	sum := sha256.Sum256([]byte(renderedShellScript()))
	return base64.StdEncoding.EncodeToString(sum[:])
}()

// renderedShellScript executes the shell template and returns the exact bytes
// between <script> and </script> — the content the CSP hash must cover.
func renderedShellScript() string {
	var buf bytes.Buffer
	_ = shellTpl.Execute(&buf, struct {
		Title, Domain, Active, Username, Version, Initials, SearchQuery, Lang, Theme, ThemeIcon, ThemeNext, NextPath string
		LoggedIn, IsAdmin                                                                                            bool
		Body, BrandMark, IconSprite                                                                                  template.HTML
	}{Lang: "en"})
	html := buf.String()
	open := strings.Index(html, "<script>")
	end := strings.Index(html, "</script>")
	if open < 0 || end < 0 || end < open {
		return ""
	}
	return html[open+len("<script>") : end]
}

// contentSecurityPolicy is deliberately strict: no 'unsafe-inline' for
// scripts (the one inline block is allowed by its hash), everything else
// same-origin, framing forbidden. style-src keeps 'unsafe-inline' because the
// templates rely on inline style="" attributes throughout — those carry no
// script and can't be nonce/hash-pinned, so this is the tightest workable
// policy without a template-wide refactor. img-src allows data: for the
// inline SVG favicons and markdown images.
func contentSecurityPolicy() string {
	return strings.Join([]string{
		"default-src 'self'",
		"script-src 'self' '" + scriptHash + "'",
		"style-src 'self' 'unsafe-inline'",
		// https: lets README images/badges load; still blocks http: and
		// other schemes. data: covers the inline SVG favicons.
		"img-src 'self' data: https:",
		"font-src 'self'",
		"connect-src 'self'",
		"object-src 'none'",
		"base-uri 'none'",
		"frame-ancestors 'none'",
		"form-action 'self'",
	}, "; ")
}

// securityHeaders sets the standard hardening response headers on every
// response. HSTS is safe to always emit: the app is only reachable over TLS
// in production (the ingress terminates it) and browsers ignore the header on
// plain-HTTP responses.
func securityHeaders(next http.Handler) http.Handler {
	csp := contentSecurityPolicy()
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := w.Header()
		h.Set("Content-Security-Policy", csp)
		h.Set("X-Frame-Options", "DENY")
		h.Set("X-Content-Type-Options", "nosniff")
		h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
		h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
		next.ServeHTTP(w, r)
	})
}

// sameOriginPOST rejects state-changing requests whose Origin (or, failing
// that, Referer) is not this same site. Combined with the SameSite=Lax
// session cookie, this is defense-in-depth against CSRF that needs no
// per-form token: browsers attach Origin to form POSTs, and a cross-site
// attacker cannot forge it. A request that carries neither header on an
// unsafe method is refused rather than trusted.
func (s *server) sameOriginPOST(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.Method {
		case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
			next.ServeHTTP(w, r)
			return
		}
		// The git-upload-pack POST (see handlers_git_http.go) is a real git
		// client, not a browser — it never sends Origin/Referer, so it
		// would always be refused otherwise. Exempting it isn't a CSRF
		// hole: it carries no session cookie, mutates no state, and only
		// ever reads a repo already re-checked as public.
		if strings.HasSuffix(r.URL.Path, "/git-upload-pack") {
			next.ServeHTTP(w, r)
			return
		}
		if !sameOrigin(r) {
			http.Error(w, "cross-origin request refused", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func sameOrigin(r *http.Request) bool {
	source := r.Header.Get("Origin")
	if source == "" {
		source = r.Header.Get("Referer")
	}
	if source == "" {
		return false
	}
	u, err := url.Parse(source)
	if err != nil || u.Host == "" {
		return false
	}
	return strings.EqualFold(u.Host, r.Host)
}