Seed from go-template-repo, renamed to simplexcalc

The template's files at a77fd30, without its history or LICENSE, after
script/rename simplexcalc.

Model: opus-5-5
This commit is contained in:
clawbot
2026-09-26 21:38:57 +00:00
parent e336f46e34
commit f8ce8cef83
79 changed files with 7131 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"log/slog"
"net/http"
"time"
)
// HTTP server hardening limits. These are the connection-level bounds;
// the per-request deadline is middleware.Timeout, driven by
// REQUEST_TIMEOUT.
const (
// readHeaderTimeout is the slowloris bound: request headers must
// arrive within it, and it starts on connection accept.
readHeaderTimeout = 5 * time.Second
// readTimeout covers headers plus body. Bodies are capped by
// MAX_REQUEST_BODY, which transfers well inside this even on a
// slow mobile link.
readTimeout = 30 * time.Second
// writeTimeout must exceed the per-request timeout: it starts when
// the headers are read, so it spans handler execution, and a
// smaller value would cut the connection instead of letting the
// request context deadline end the request with a status. The
// margin is added to whatever REQUEST_TIMEOUT is configured to.
writeTimeoutMargin = 15 * time.Second
// idleTimeout bounds how long an idle keep-alive connection is
// held; browsers reconnect transparently.
idleTimeout = 120 * time.Second
maxHeaderBytes = 1 << 20
)
// newHTTPServer builds the http.Server.
//
// ErrorLog is set on purpose: net/http internals (TLS handshake
// errors, request parse errors, panics net/http itself recovers) write
// through it, and unset they would emit plain text on stderr via the
// default log package — a few lines of unstructured output in the
// middle of a JSON log stream, which is exactly the kind of thing a log
// pipeline drops on the floor.
func (s *Server) newHTTPServer(listenAddr string) *http.Server {
return &http.Server{
Addr: listenAddr,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: s.params.Config.RequestTimeout + writeTimeoutMargin,
IdleTimeout: idleTimeout,
MaxHeaderBytes: maxHeaderBytes,
Handler: s,
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelError),
}
}
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"net/http"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/simplexcalc/static"
)
// staticPrefix is where the embedded assets are mounted.
const staticPrefix = "/static/"
// staticCacheControl is a year, because the asset set is baked into the
// binary: a new build is a new deployment, and a deployment is the only
// thing that can change these bytes. Bump the path if that stops being
// true.
const staticCacheControl = "public, max-age=31536000, immutable"
// SetupRoutes builds the router. The middleware order is the contract
// of this file, and it is this way for reasons:
//
// 1. RequestID first, so every later line of log and every captured
// error can name the request it came from.
// 2. Recoverer next-to-outermost, so it covers every handler and every
// middleware below it. Above the logger, so a panic still produces
// a logged request line.
// 3. RequestLogger and Metrics before the work, so both see the final
// status of every request including the 500 the recoverer wrote.
// 4. SecurityHeaders before anything can write a body — the headers
// have to be set before the first Write, and a 404 or a panic
// response needs them as much as a page does.
// 5. Timeout and BodyLimit before any handler reads a body.
// 6. CSRF innermost of the global chain, wrapping only the routes that
// can change state.
//
// /metrics and the healthcheck sit outside CSRF (neither is
// state-changing, and a scraper has no token) and outside nothing else.
func (s *Server) SetupRoutes() {
r := chi.NewRouter()
r.Use(s.mw.RequestID())
r.Use(s.mw.Recoverer())
r.Use(s.mw.RequestLogger())
r.Use(s.mw.Metrics())
r.Use(s.mw.SecurityHeaders())
r.Use(s.mw.Timeout())
r.Use(s.mw.BodyLimit())
r.NotFound(s.h.NotFound())
r.MethodNotAllowed(s.h.MethodNotAllowed())
// Operational endpoints: no CSRF, no session, no HTML.
r.Get("/.well-known/healthcheck.json", s.h.Healthcheck())
r.Method(http.MethodGet, "/metrics", s.metrics.Handler())
r.Handle(staticPrefix+"*", s.staticHandler())
// Everything a browser drives, behind CSRF. Safe methods are
// unaffected by it except that they are issued a token.
r.Group(func(r chi.Router) {
r.Use(s.mw.CSRF())
r.Get("/", s.h.Index())
r.Post("/widgets", s.h.CreateWidget())
if s.params.Config.Debug {
// Only with DEBUG=true: a route that panics on demand is a
// denial-of-service primitive in production.
r.Get("/debug/panic", s.h.Panic())
}
})
s.router = r
}
// staticHandler serves the embedded assets, without directory listings
// and with a long cache lifetime.
func (s *Server) staticHandler() http.Handler {
fileServer := http.FileServer(filesOnly{inner: http.FS(static.FS)})
return http.StripPrefix(staticPrefix, http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", staticCacheControl)
fileServer.ServeHTTP(w, r)
},
))
}
+180
View File
@@ -0,0 +1,180 @@
// Package server owns the HTTP server lifecycle and the route table.
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
"sneak.berlin/go/simplexcalc/internal/config"
"sneak.berlin/go/simplexcalc/internal/globals"
"sneak.berlin/go/simplexcalc/internal/handlers"
"sneak.berlin/go/simplexcalc/internal/logger"
"sneak.berlin/go/simplexcalc/internal/middleware"
"sneak.berlin/go/simplexcalc/internal/telemetry"
)
// Params defines dependencies for Server.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
Metrics *telemetry.Metrics
}
// Server is the HTTP server and its lifecycle state.
type Server struct {
params Params
log *slog.Logger
mw *middleware.Middleware
h *handlers.Handlers
metrics *telemetry.Metrics
httpServer *http.Server
listener net.Listener
router *chi.Mux
serveErr chan error
}
// New creates the Server and hooks it into the fx lifecycle.
//
// The listener is opened during OnStart, not in a goroutine after it:
// binding is the part that fails (port in use, permission denied), and
// a failure there has to be a failure to start. A server that logs
// "listen failed" from a goroutine while fx reports a successful
// startup is a process that is up and serving nothing.
func New(lc fx.Lifecycle, params Params) (*Server, error) {
s := &Server{
params: params,
log: params.Logger.Get(),
mw: params.Middleware,
h: params.Handlers,
metrics: params.Metrics,
serveErr: make(chan error, 1),
}
lc.Append(fx.Hook{
OnStart: s.start,
OnStop: s.stop,
})
return s, nil
}
// Addr returns the address actually bound, which is what a test that
// asked for port 0 needs.
func (s *Server) Addr() string {
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
// Handler exposes the router so tests can drive it with
// httptest.NewRecorder without binding a port.
func (s *Server) Handler() http.Handler {
return s.router
}
// ServeHTTP dispatches requests through the router.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
// start builds the routes, binds, and begins serving.
func (s *Server) start(ctx context.Context) error {
s.SetupRoutes()
addr := ":" + strconv.Itoa(s.params.Config.Port)
s.httpServer = s.newHTTPServer(addr)
// ListenConfig rather than net.Listen: the start context bounds
// name resolution and the bind, so a start that cannot bind fails
// within fx's start timeout instead of hanging inside it.
var lc net.ListenConfig
ln, err := lc.Listen(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("listening on %s: %w", addr, err)
}
s.listener = ln
s.log.Info("http listening",
"addr", ln.Addr().String(),
"debug", s.params.Config.Debug,
"metrics_protected", s.metrics.AuthRequired(),
)
go func() {
serveErr := s.httpServer.Serve(ln)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
s.log.Error("http serve failed", "error", serveErr)
s.serveErr <- serveErr
return
}
s.serveErr <- nil
}()
return nil
}
// stop drains in-flight requests, bounded by the stop context.
//
// The bound is whichever of the two comes first: the deadline fx gives
// this hook, or the configured grace period. Taking the minimum is the
// point — a shutdown that outlives the context it was given is a
// shutdown the supervisor kills with SIGKILL, which is precisely the
// abrupt termination the draining was supposed to avoid.
//
// Past the deadline, Shutdown returns and every remaining connection is
// closed. That is a deliberate choice of a bounded, reported failure
// over an unbounded wait.
func (s *Server) stop(ctx context.Context) error {
if s.httpServer == nil {
return nil
}
grace := s.params.Config.ShutdownGrace
stopCtx, cancel := context.WithTimeout(ctx, grace)
defer cancel()
s.log.Info("http shutting down", "grace", grace.String())
start := time.Now()
err := s.httpServer.Shutdown(stopCtx)
if err != nil {
// Connections were still open at the deadline. Report it —
// silence here would hide requests that were cut off — but do
// not fail the stop sequence: there is nothing left to retry
// and the rest of the graph still has to close cleanly.
s.log.Error("http shutdown did not drain in time",
"error", err,
"waited", time.Since(start).Round(time.Millisecond).String(),
)
return nil
}
s.log.Info("http shutdown complete",
"waited", time.Since(start).Round(time.Millisecond).String(),
)
return nil
}
+560
View File
@@ -0,0 +1,560 @@
package server_test
import (
"encoding/json"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/simplexcalc/internal/config"
"sneak.berlin/go/simplexcalc/internal/database"
"sneak.berlin/go/simplexcalc/internal/globals"
"sneak.berlin/go/simplexcalc/internal/handlers"
"sneak.berlin/go/simplexcalc/internal/logger"
"sneak.berlin/go/simplexcalc/internal/middleware"
"sneak.berlin/go/simplexcalc/internal/render"
"sneak.berlin/go/simplexcalc/internal/server"
"sneak.berlin/go/simplexcalc/internal/telemetry"
)
// formNameField is the widget form's name input; refererHeader is the
// header gorilla/csrf checks on every state-changing request.
const (
formNameField = "name"
refererHeader = "Referer"
)
// response is a finished exchange: status, headers and the body, read
// and closed before the helper returns. Handing the tests a value
// rather than an *http.Response means no test can leak a connection by
// forgetting to close one.
type response struct {
status int
header http.Header
body string
}
// instance is a running service: a real listener on an ephemeral port,
// a real sqlite database in a temp dir, and the whole middleware chain.
// Nothing is stubbed, because the things most likely to be wrong —
// middleware order, route registration, embedded assets — are exactly
// what a stub would paper over.
type instance struct {
base string
client *http.Client
}
// testConfig is the configuration every instance starts from.
func testConfig(t *testing.T) *config.Config {
t.Helper()
dir := t.TempDir()
return &config.Config{
// Port 0: the OS picks a free one, so parallel tests do not
// fight over a fixed port.
Port: 0,
DataDir: dir,
DBPath: filepath.Join(dir, "test.db"),
Debug: true,
HSTS: false,
MaxRequestBody: 1 << 20,
RequestTimeout: 5 * time.Second,
ShutdownGrace: 2 * time.Second,
CSRFKeyEphemeral: true,
}
}
// build constructs the graph by hand. fx would do the same wiring; done
// explicitly, a constructor that starts needing a new dependency shows
// up here as a compile error rather than as a runtime resolution
// failure inside a container.
func build(t *testing.T, lc *fxtest.Lifecycle, cfg *config.Config) *server.Server {
t.Helper()
g := &globals.Globals{Appname: "simplexcalc", Version: "test", Buildarch: "amd64"}
log, err := logger.New(lc, logger.Params{Globals: g, Output: io.Discard})
if err != nil {
t.Fatalf("logger: %v", err)
}
db, err := database.New(lc, database.Params{Config: cfg, Logger: log})
if err != nil {
t.Fatalf("database: %v", err)
}
sentry, err := telemetry.NewSentry(lc, telemetry.SentryParams{
Config: cfg, Globals: g, Logger: log,
})
if err != nil {
t.Fatalf("sentry: %v", err)
}
metrics, err := telemetry.NewMetrics(telemetry.MetricsParams{Config: cfg})
if err != nil {
t.Fatalf("metrics: %v", err)
}
renderer, err := render.New(lc, render.Params{Globals: g})
if err != nil {
t.Fatalf("render: %v", err)
}
mw, err := middleware.New(lc, middleware.Params{
Config: cfg, Logger: log, Sentry: sentry, Metrics: metrics,
})
if err != nil {
t.Fatalf("middleware: %v", err)
}
h, err := handlers.New(lc, handlers.Params{
Config: cfg, Globals: g, Logger: log,
Database: db, Renderer: renderer, Sentry: sentry,
})
if err != nil {
t.Fatalf("handlers: %v", err)
}
srv, err := server.New(lc, server.Params{
Logger: log, Globals: g, Config: cfg,
Middleware: mw, Handlers: h, Metrics: metrics,
})
if err != nil {
t.Fatalf("server: %v", err)
}
return srv
}
// newInstance starts the service and registers its shutdown.
func newInstance(t *testing.T, mutate func(*config.Config)) *instance {
t.Helper()
cfg := testConfig(t)
if mutate != nil {
mutate(cfg)
}
lc := fxtest.NewLifecycle(t)
srv := build(t, lc, cfg)
lc.RequireStart()
t.Cleanup(lc.RequireStop)
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("cookie jar: %v", err)
}
// The listener binds the unspecified address, so Addr() reads
// "[::]:PORT". Dialling that works, but a cookie jar keyed on "::"
// does not, and the CSRF flow depends on cookies coming back — so
// the tests talk to the loopback address explicitly.
_, port, err := net.SplitHostPort(srv.Addr())
if err != nil {
t.Fatalf("parsing listen address %q: %v", srv.Addr(), err)
}
return &instance{
base: "http://127.0.0.1:" + port,
client: &http.Client{
Jar: jar,
Timeout: 10 * time.Second,
// The redirect after a successful POST is under test, so
// it must not be followed silently.
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// host is the Host header value the service is reached by.
func (i *instance) host() string {
return strings.TrimPrefix(i.base, "http://")
}
func (i *instance) get(t *testing.T, path string) response {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, i.base+path, nil)
if err != nil {
t.Fatalf("building GET %s: %v", path, err)
}
return i.do(t, req)
}
// post submits a form the way a browser does — including the Referer,
// which gorilla/csrf checks against the request's Host on every
// state-changing request. Omitting it is itself a CSRF rejection, so a
// test that leaves it out proves nothing about the token.
func (i *instance) post(t *testing.T, path string, form url.Values) response {
t.Helper()
return i.postWithHeaders(t, path, form, map[string]string{
refererHeader: i.base + "/",
})
}
// postWithHeaders is post with the headers stated explicitly, for the
// reverse-proxy cases.
func (i *instance) postWithHeaders(
t *testing.T, path string, form url.Values, headers map[string]string,
) response {
t.Helper()
req, err := http.NewRequestWithContext(
t.Context(), http.MethodPost, i.base+path, strings.NewReader(form.Encode()),
)
if err != nil {
t.Fatalf("building POST %s: %v", path, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range headers {
req.Header.Set(k, v)
}
return i.do(t, req)
}
func (i *instance) do(t *testing.T, req *http.Request) response {
t.Helper()
resp, err := i.client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
}
body, readErr := io.ReadAll(resp.Body)
closeErr := resp.Body.Close()
if closeErr != nil {
t.Errorf("closing response body: %v", closeErr)
}
if readErr != nil {
t.Fatalf("reading %s %s: %v", req.Method, req.URL.Path, readErr)
}
return response{status: resp.StatusCode, header: resp.Header, body: string(body)}
}
// TestServesPageFromEmbeddedTemplate is one of the definition-of-done
// claims: a running process answers / from the template compiled into
// it.
func TestServesPageFromEmbeddedTemplate(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
resp := i.get(t, "/")
if resp.status != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.status)
}
for _, want := range []string{"<!doctype html>", "<nav>", "<footer>", "widgets ("} {
if !strings.Contains(resp.body, want) {
t.Errorf("page is missing %q", want)
}
}
if resp.header.Get("X-Content-Type-Options") != "nosniff" {
t.Error("security headers are missing from a normal page response")
}
if resp.header.Get(middleware.RequestIDHeader) == "" {
t.Error("no request id on the response")
}
}
// TestServesEmbeddedStaticAsset, with the cache policy the route sets.
func TestServesEmbeddedStaticAsset(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
resp := i.get(t, "/static/css/style.css")
if resp.status != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.status)
}
if !strings.Contains(resp.body, "--fg:") {
t.Error("the served asset is not the embedded stylesheet")
}
if !strings.Contains(resp.header.Get("Cache-Control"), "immutable") {
t.Errorf("Cache-Control = %q", resp.header.Get("Cache-Control"))
}
}
// TestStaticDirectoriesAreNotListed: enumerating what the binary ships
// is a capability no caller needs.
func TestStaticDirectoriesAreNotListed(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
for _, path := range []string{"/static/", "/static/css/"} {
resp := i.get(t, path)
if resp.status != http.StatusNotFound {
t.Errorf("GET %s: status = %d, want 404", path, resp.status)
}
if strings.Contains(resp.body, "style.css") {
t.Errorf("GET %s listed the directory contents", path)
}
}
}
// TestHealthcheckReportsMigratedSchema proves the embedded migrations
// ran during startup, from a directory that was empty a moment ago.
func TestHealthcheckReportsMigratedSchema(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
resp := i.get(t, "/.well-known/healthcheck.json")
if resp.status != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.status)
}
var health handlers.HealthResponse
err := json.Unmarshal([]byte(resp.body), &health)
if err != nil {
t.Fatalf("decoding healthcheck: %v (body %q)", err, resp.body)
}
if !health.OK || !health.DatabaseOK {
t.Errorf("healthcheck reports unhealthy: %+v", health)
}
if health.SchemaVersion != 1 {
t.Errorf("schema_version = %d, want 1", health.SchemaVersion)
}
}
// TestMetricsEndpointIsServedAndGated covers both halves of the metrics
// requirement in one running service.
func TestMetricsEndpointIsServedAndGated(t *testing.T) {
t.Parallel()
t.Run("open when unconfigured", func(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
// One request first: the middleware records a request after
// the response is written, so a scrape that is the very first
// request to the process legitimately has no HTTP series in it
// yet.
i.get(t, "/")
resp := i.get(t, "/metrics")
if resp.status != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.status)
}
want := `http_requests_total{code="200",method="GET",route="/"}`
if !strings.Contains(resp.body, want) {
t.Errorf("the scrape does not report the request that preceded it:\n%s", resp.body)
}
})
t.Run("gated when configured", func(t *testing.T) {
t.Parallel()
i := newInstance(t, func(c *config.Config) {
c.MetricsUser = "scraper"
c.MetricsPassword = "hunter2"
})
resp := i.get(t, "/metrics")
if resp.status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.status)
}
})
}
// TestUnknownRouteRendersErrorPage: a 404 still carries the site's
// headers and chrome rather than net/http's bare text.
func TestUnknownRouteRendersErrorPage(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
resp := i.get(t, "/no-such-page")
if resp.status != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.status)
}
if !strings.Contains(resp.body, "No such page") ||
!strings.Contains(resp.body, "<nav>") {
t.Errorf("404 is not the rendered error page:\n%s", resp.body)
}
}
// TestPanicIsAnsweredWith500 exercises the recovery middleware through
// the whole stack, on the debug-only route.
func TestPanicIsAnsweredWith500(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
resp := i.get(t, "/debug/panic")
if resp.status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", resp.status)
}
if strings.Contains(resp.body, "deliberate panic") {
t.Error("the panic value reached the client")
}
}
// TestDebugRouteAbsentWithoutDebug: the panic route is a denial-of-
// service primitive, so it must not exist in a normal deployment.
func TestDebugRouteAbsentWithoutDebug(t *testing.T) {
t.Parallel()
i := newInstance(t, func(c *config.Config) { c.Debug = false })
resp := i.get(t, "/debug/panic")
if resp.status != http.StatusNotFound {
t.Errorf("status = %d, want 404 with DEBUG off", resp.status)
}
}
// csrfField finds the hidden token in a rendered form.
var csrfField = regexp.MustCompile(
`<input type="hidden" name="gorilla\.csrf\.Token" value="([^"]+)"`,
)
// token fetches the index page and returns the CSRF token its form
// carries.
func (i *instance) token(t *testing.T) string {
t.Helper()
page := i.get(t, "/")
match := csrfField.FindStringSubmatch(page.body)
if match == nil {
t.Fatalf("no CSRF field in the rendered form:\n%s", page.body)
}
return match[1]
}
// TestCSRFProtectsStateChange is the whole point of the CSRF
// middleware: the same POST must be refused without a token and
// accepted with one.
func TestCSRFProtectsStateChange(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
// A well-formed cross-site request: everything a browser would
// send except the token.
resp := i.post(t, "/widgets", url.Values{formNameField: {"unauthorised"}})
if resp.status != http.StatusForbidden {
t.Fatalf("POST without a CSRF token: status = %d, want 403", resp.status)
}
resp = i.post(t, "/widgets", url.Values{
formNameField: {"authorised"},
"size": {"4KiB"},
"gorilla.csrf.Token": {i.token(t)},
})
if resp.status != http.StatusSeeOther {
t.Fatalf("POST with a CSRF token: status = %d, want 303", resp.status)
}
// And the write actually happened.
after := i.get(t, "/")
if !strings.Contains(after.body, "authorised") {
t.Error("the created widget is not on the page")
}
if !strings.Contains(after.body, "4.0 KiB") {
t.Error("the size was not parsed and rendered")
}
if strings.Contains(after.body, "unauthorised") {
t.Error("the rejected POST created a widget anyway")
}
}
// TestCSRFBehindTLSTerminatingProxy is the deployment shape this
// service actually runs in: the browser speaks HTTPS to a proxy and the
// proxy speaks plaintext HTTP here, saying so with X-Forwarded-Proto.
// The token must still be accepted, with the stricter TLS-side Referer
// rules applied to the browser's real scheme rather than to the
// connection's.
func TestCSRFBehindTLSTerminatingProxy(t *testing.T) {
t.Parallel()
i := newInstance(t, nil)
form := url.Values{
formNameField: {"through the proxy"},
"gorilla.csrf.Token": {i.token(t)},
}
// A cleartext Referer on a connection the proxy says was HTTPS is
// exactly the machine-in-the-middle case the strict check exists
// for, and must be refused.
resp := i.postWithHeaders(t, "/widgets", form, map[string]string{
"X-Forwarded-Proto": "https",
refererHeader: "http://" + i.host() + "/",
})
if resp.status != http.StatusForbidden {
t.Errorf("cleartext referer under X-Forwarded-Proto=https: status = %d, want 403",
resp.status)
}
// The genuine article: a same-origin HTTPS referer.
resp = i.postWithHeaders(t, "/widgets", form, map[string]string{
"X-Forwarded-Proto": "https",
refererHeader: "https://" + i.host() + "/",
})
if resp.status != http.StatusSeeOther {
t.Errorf("valid submission through a TLS-terminating proxy: status = %d, want 303",
resp.status)
}
}
// TestOversizedRequestIsRefused: the body cap is wired into the running
// chain, not just unit-tested in isolation.
func TestOversizedRequestIsRefused(t *testing.T) {
t.Parallel()
i := newInstance(t, func(c *config.Config) { c.MaxRequestBody = 1024 })
// The cap is enforced ahead of CSRF in the chain, so an oversized
// body is refused as oversized rather than as untokened.
resp := i.post(t, "/widgets",
url.Values{formNameField: {strings.Repeat("x", 4096)}})
if resp.status != http.StatusRequestEntityTooLarge {
t.Errorf("status = %d, want 413", resp.status)
}
}
+48
View File
@@ -0,0 +1,48 @@
package server
import (
"io/fs"
"net/http"
)
// filesOnly wraps an http.FileSystem so that directories do not exist as
// far as http.FileServer is concerned. FileServer asks the filesystem for
// the directory first and generates its index from what it gets back;
// refusing the Open is therefore the whole control, and it leaves the
// path handling, content sniffing and range support of FileServer intact.
//
// Enumerating what a binary ships is a capability no caller needs, and
// the embedded set grows as a project seeded from this template adds to
// it.
type filesOnly struct {
inner http.FileSystem
}
// Open serves a file and refuses a directory with fs.ErrNotExist, which
// http.FileServer maps to 404 — the same answer a path that is not
// embedded at all gets.
func (f filesOnly) Open(name string) (http.File, error) {
file, err := f.inner.Open(name)
if err != nil {
// Unwrapped on purpose: http.FileServer inspects this error,
// and wrapping hides fs.ErrNotExist from it.
//nolint:wrapcheck // see above.
return nil, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
//nolint:wrapcheck // as above.
return nil, err
}
if info.IsDir() {
_ = file.Close()
return nil, fs.ErrNotExist
}
return file, nil
}