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
132 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|