Files
member-console/internal/httplimit/httplimit_test.go
T
cgalo5758 b245bdb0a9 Fix security audit findings from 2026-09-21 scan
Remediate six confirmed security issues: deployment-only config keys,
bounded provider responses, short-lived registration sessions, private
init file mode, FedWiki workflow authorization, and switch preview
gates.

- Add DeploymentOnly config key declaration; refuse runtime overrides
  for keys that decide where secrets are sent
- Create httplimit package; bound all provider response reads at 8 MiB
- Set fifteen-minute deadline on /register sessions
- Write mc-config.yaml with 0600 permissions
- Derive FedWiki workflow IDs from site IDs; re-authorize sites before
  mutating activities
- Apply switch authorization gates to the proration preview
2026-09-21 13:57:57 -05:00

132 lines
4.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package httplimit
import (
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// closeRecorder wraps a body and records Close, so a test can assert the
// reader closes it on every path.
type closeRecorder struct {
io.Reader
closed bool
}
func (c *closeRecorder) Close() error { c.closed = true; return nil }
func response(body string) (*http.Response, *closeRecorder) {
rec := &closeRecorder{Reader: strings.NewReader(body)}
u, _ := url.Parse("https://farm.example.test/plugin/farmmanager/sites")
return &http.Response{StatusCode: 200, Body: rec, Request: &http.Request{URL: u}}, rec
}
func TestReadResponseWithinBoundIsWhole(t *testing.T) {
resp, rec := response("exactly-32-bytes-of-body-text!!!")
body, err := ReadResponse(resp, 32)
if err != nil {
t.Fatalf("body of bound size must be read whole, got %v", err)
}
if string(body) != "exactly-32-bytes-of-body-text!!!" {
t.Fatalf("body = %q", body)
}
if !rec.closed {
t.Fatal("body must be closed after a successful read")
}
}
func TestReadResponseOneByteOverIsRefusedNamingBoundAndHost(t *testing.T) {
resp, rec := response(strings.Repeat("x", 33))
body, err := ReadResponse(resp, 32)
if body != nil {
t.Fatalf("an over-bound response must return no bytes, got %d", len(body))
}
var tl *TooLargeError
if !errors.As(err, &tl) {
t.Fatalf("want *TooLargeError, got %T %v", err, err)
}
if tl.Bound != 32 || tl.Host != "farm.example.test" {
t.Fatalf("error must name the bound and the host, got %+v", tl)
}
if !strings.Contains(err.Error(), "farm.example.test") || !strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("message must name host and bound: %q", err.Error())
}
if !IsTooLarge(err) {
t.Fatal("IsTooLarge must recognise its own error")
}
if !rec.closed {
t.Fatal("body must be closed after a refusal")
}
}
// The reader never asks for more than max+1 bytes, so a stream with no end
// costs one byte over the bound, not the stream.
func TestReadResponseStopsReadingAtTheBound(t *testing.T) {
endless := &countingReader{}
u, _ := url.Parse("http://forum.example.test/")
resp := &http.Response{Body: io.NopCloser(endless), Request: &http.Request{URL: u}}
if _, err := ReadResponse(resp, 1024); !IsTooLarge(err) {
t.Fatalf("want TooLargeError, got %v", err)
}
if endless.n > 1024+1 {
t.Fatalf("read %d bytes from an endless body; the bound plus one is the ceiling", endless.n)
}
}
type countingReader struct{ n int64 }
func (c *countingReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 'y'
}
c.n += int64(len(p))
return len(p), nil
}
func TestDecodeResponse(t *testing.T) {
t.Run("a document within the bound decodes", func(t *testing.T) {
resp, _ := response(`{"name":"a"}`)
var v struct{ Name string }
if err := DecodeResponse(resp, DefaultResponseBytes, &v); err != nil || v.Name != "a" {
t.Fatalf("decode: %v, %+v", err, v)
}
})
t.Run("a document over the bound is refused, not truncated", func(t *testing.T) {
resp, _ := response(`{"name":"` + strings.Repeat("a", 64) + `"}`)
var v struct{ Name string }
err := DecodeResponse(resp, 32, &v)
if !IsTooLarge(err) {
t.Fatalf("want TooLargeError, got %v", err)
}
if v.Name != "" {
t.Fatal("nothing must be decoded from a refused body")
}
})
}
// Against a live server: the bound holds for a real body on the wire, and
// the host in the error is the one the request went to.
func TestReadResponseAgainstServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("z"), 4096))
}))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
_, err = ReadResponse(resp, 1000)
var tl *TooLargeError
if !errors.As(err, &tl) || tl.Host != strings.TrimPrefix(srv.URL, "http://") {
t.Fatalf("want TooLargeError for %s, got %v", srv.URL, err)
}
}