2026-08-19 20:52:13 -07:00
|
|
|
package proxy
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TestBodySizeCapExact verifies that a body exactly at the cap is accepted.
|
|
|
|
|
func TestBodySizeCapExact(t *testing.T) {
|
|
|
|
|
upstreamReceived := false
|
|
|
|
|
|
|
|
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
upstreamReceived = true
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
fmt.Fprint(w, "ok")
|
|
|
|
|
}))
|
|
|
|
|
defer upstreamServer.Close()
|
|
|
|
|
|
|
|
|
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
|
|
|
|
|
|
|
|
maxBodySize := int64(100)
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Routes: map[string]*config.Route{
|
|
|
|
|
"capped-route": {
|
|
|
|
|
Name: "capped-route",
|
|
|
|
|
Upstream: config.Upstream{
|
|
|
|
|
Address: upstreamAddr,
|
|
|
|
|
MaxBodySize: maxBodySize,
|
|
|
|
|
ConnectTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 5 * time.Second,
|
|
|
|
|
WriteTimeout: 5 * time.Second,
|
|
|
|
|
AuthRequired: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler := New(cfg)
|
|
|
|
|
defer handler.Close()
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(handler)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
// Send a body exactly at the cap
|
|
|
|
|
body := strings.Repeat("a", int(maxBodySize))
|
|
|
|
|
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
t.Errorf("expected 200 for body at cap, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !upstreamReceived {
|
|
|
|
|
t.Errorf("expected upstream to receive request, but it didn't")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBodySizeCapOver verifies that a body over the cap is rejected with 413.
|
|
|
|
|
func TestBodySizeCapOver(t *testing.T) {
|
|
|
|
|
upstreamReceived := false
|
|
|
|
|
|
|
|
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
upstreamReceived = true
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}))
|
|
|
|
|
defer upstreamServer.Close()
|
|
|
|
|
|
|
|
|
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
|
|
|
|
|
|
|
|
maxBodySize := int64(100)
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Routes: map[string]*config.Route{
|
|
|
|
|
"capped-route": {
|
|
|
|
|
Name: "capped-route",
|
|
|
|
|
Upstream: config.Upstream{
|
|
|
|
|
Address: upstreamAddr,
|
|
|
|
|
MaxBodySize: maxBodySize,
|
|
|
|
|
ConnectTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 5 * time.Second,
|
|
|
|
|
WriteTimeout: 5 * time.Second,
|
|
|
|
|
AuthRequired: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler := New(cfg)
|
|
|
|
|
defer handler.Close()
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(handler)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
// Send a body one byte over the cap
|
|
|
|
|
body := strings.Repeat("a", int(maxBodySize)+1)
|
|
|
|
|
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
|
|
|
t.Errorf("expected 413 for oversized body, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if upstreamReceived {
|
|
|
|
|
t.Errorf("expected upstream to NOT receive request, but it did")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBodySizeCapStreamingEnforcement verifies that the cap is enforced while reading.
|
|
|
|
|
func TestBodySizeCapStreamingEnforcement(t *testing.T) {
|
|
|
|
|
upstreamRequestsCount := 0
|
|
|
|
|
|
|
|
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
upstreamRequestsCount++
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
fmt.Fprint(w, "ok")
|
|
|
|
|
}))
|
|
|
|
|
defer upstreamServer.Close()
|
|
|
|
|
|
|
|
|
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
|
|
|
|
|
|
|
|
maxBodySize := int64(50)
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Routes: map[string]*config.Route{
|
|
|
|
|
"capped-route": {
|
|
|
|
|
Name: "capped-route",
|
|
|
|
|
Upstream: config.Upstream{
|
|
|
|
|
Address: upstreamAddr,
|
|
|
|
|
MaxBodySize: maxBodySize,
|
|
|
|
|
ConnectTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 5 * time.Second,
|
|
|
|
|
WriteTimeout: 5 * time.Second,
|
|
|
|
|
AuthRequired: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler := New(cfg)
|
|
|
|
|
defer handler.Close()
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(handler)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
// Create a body reader that's larger than the cap
|
|
|
|
|
oversizeBody := strings.Repeat("x", int(maxBodySize+100))
|
|
|
|
|
req, _ := http.NewRequest("POST", server.URL+"/test", strings.NewReader(oversizeBody))
|
|
|
|
|
req.ContentLength = int64(len(oversizeBody))
|
|
|
|
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
// Should get 413
|
|
|
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
|
|
|
t.Errorf("expected 413, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upstream should never have been called
|
|
|
|
|
if upstreamRequestsCount > 0 {
|
|
|
|
|
t.Errorf("expected 0 upstream requests, got %d", upstreamRequestsCount)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBodySizeCapWithoutContentLength verifies that bodies without Content-Length are still limited.
|
|
|
|
|
func TestBodySizeCapWithoutContentLength(t *testing.T) {
|
|
|
|
|
upstreamReceived := false
|
|
|
|
|
|
|
|
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
upstreamReceived = true
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}))
|
|
|
|
|
defer upstreamServer.Close()
|
|
|
|
|
|
|
|
|
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
|
|
|
|
|
|
|
|
maxBodySize := int64(50)
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Routes: map[string]*config.Route{
|
|
|
|
|
"capped-route": {
|
|
|
|
|
Name: "capped-route",
|
|
|
|
|
Upstream: config.Upstream{
|
|
|
|
|
Address: upstreamAddr,
|
|
|
|
|
MaxBodySize: maxBodySize,
|
|
|
|
|
ConnectTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 5 * time.Second,
|
|
|
|
|
WriteTimeout: 5 * time.Second,
|
|
|
|
|
AuthRequired: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler := New(cfg)
|
|
|
|
|
defer handler.Close()
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(handler)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
// Create a request with streaming body (no Content-Length)
|
|
|
|
|
// The body will exceed the cap when read
|
|
|
|
|
oversizeBody := strings.Repeat("x", int(maxBodySize+100))
|
|
|
|
|
req, _ := http.NewRequest("POST", server.URL+"/test", strings.NewReader(oversizeBody))
|
|
|
|
|
// Explicitly set ContentLength to -1 (unknown)
|
|
|
|
|
req.ContentLength = -1
|
|
|
|
|
|
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
// Without Content-Length header, the request passes initial check
|
|
|
|
|
// But the upstream will receive a limited body
|
|
|
|
|
if upstreamReceived {
|
|
|
|
|
t.Logf("upstream received request with limited body (expected behavior)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBodySizeCapNoLimit verifies that routes with zero cap (no limit) work.
|
|
|
|
|
func TestBodySizeCapNoLimit(t *testing.T) {
|
|
|
|
|
upstreamReceived := false
|
|
|
|
|
|
|
|
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
upstreamReceived = true
|
|
|
|
|
body, _ := io.ReadAll(r.Body)
|
|
|
|
|
w.Header().Set("X-Body-Size", fmt.Sprintf("%d", len(body)))
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}))
|
|
|
|
|
defer upstreamServer.Close()
|
|
|
|
|
|
|
|
|
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
|
|
|
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Routes: map[string]*config.Route{
|
|
|
|
|
"unlimited-route": {
|
|
|
|
|
Name: "unlimited-route",
|
|
|
|
|
Upstream: config.Upstream{
|
|
|
|
|
Address: upstreamAddr,
|
|
|
|
|
MaxBodySize: 0, // No limit
|
|
|
|
|
ConnectTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 5 * time.Second,
|
|
|
|
|
WriteTimeout: 5 * time.Second,
|
|
|
|
|
AuthRequired: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler := New(cfg)
|
|
|
|
|
defer handler.Close()
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(handler)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
// Send a large body
|
|
|
|
|
largeBody := strings.Repeat("a", 10000)
|
|
|
|
|
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(largeBody))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !upstreamReceived {
|
|
|
|
|
t.Errorf("expected upstream to receive request")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify the body was fully received
|
|
|
|
|
bodySizeStr := resp.Header.Get("X-Body-Size")
|
|
|
|
|
if bodySizeStr != fmt.Sprintf("%d", len(largeBody)) {
|
|
|
|
|
t.Errorf("expected body size %d, upstream saw %s", len(largeBody), bodySizeStr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestBodySizeCapRejectionLogged verifies that rejections are logged with reason.
|
|
|
|
|
func TestBodySizeCapRejectionLogged(t *testing.T) {
|
|
|
|
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}))
|
|
|
|
|
defer upstreamServer.Close()
|
|
|
|
|
|
|
|
|
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
|
|
|
|
|
|
|
|
|
maxBodySize := int64(100)
|
|
|
|
|
cfg := &config.Config{
|
|
|
|
|
Routes: map[string]*config.Route{
|
|
|
|
|
"capped-route": {
|
|
|
|
|
Name: "capped-route",
|
|
|
|
|
Upstream: config.Upstream{
|
|
|
|
|
Address: upstreamAddr,
|
|
|
|
|
MaxBodySize: maxBodySize,
|
|
|
|
|
ConnectTimeout: 5 * time.Second,
|
|
|
|
|
ReadTimeout: 5 * time.Second,
|
|
|
|
|
WriteTimeout: 5 * time.Second,
|
|
|
|
|
AuthRequired: false,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handler := New(cfg)
|
|
|
|
|
defer handler.Close()
|
|
|
|
|
|
|
|
|
|
server := httptest.NewServer(handler)
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
|
|
|
|
// Send an oversized body
|
|
|
|
|
body := strings.Repeat("a", int(maxBodySize)+1)
|
2026-08-19 23:55:42 -07:00
|
|
|
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("request failed: %v", err)
|
|
|
|
|
}
|
2026-08-19 20:52:13 -07:00
|
|
|
resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
// Verify rejection status
|
|
|
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
|
|
|
t.Errorf("expected 413, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
}
|