Merge pull request 'A route may say the largest body it carries' (#68) from feat/a-route-may-limit-the-body-it-carries into main
This commit was merged in pull request #68.
This commit is contained in:
@@ -167,6 +167,16 @@ type rule struct {
|
||||
// webmail front is the first of these — never for anything reached over plain http, where
|
||||
// there is nothing to verify in the first place.
|
||||
insecure bool
|
||||
// maxRequestBody is the largest body, in bytes, this route carries. Zero is no limit, which is
|
||||
// what every route gets by saying nothing: this proxy has never limited a body, and a default
|
||||
// arriving with the field would change every route that never asked for one.
|
||||
//
|
||||
// **Configuration, not a policy** (novox/hq ADR 0108 closed that set at four). It belongs beside
|
||||
// `insecure` for the same reason `insecure` is not a policy: both tune how this proxy carries a
|
||||
// request to a backend, rather than deciding what the name admits or who may reach it. A
|
||||
// registry is the case that needs it — image layers arrive as single requests of gigabytes, and
|
||||
// a proxy's own default refuses them long before the workload is reached.
|
||||
maxRequestBody int64
|
||||
}
|
||||
|
||||
// table is what the proxy is currently serving, replaced whole whenever the file changes.
|
||||
@@ -636,6 +646,18 @@ func handler(held *table) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if matched.maxRequestBody > 0 {
|
||||
// Refused on the declared length where there is one, so an upload that cannot succeed
|
||||
// is answered before it is carried; and capped while reading for a chunked body, which
|
||||
// declares no length at all. Without the second, a limit is advice.
|
||||
if r.ContentLength > matched.maxRequestBody {
|
||||
http.Error(w, fmt.Sprintf("request body too large for this route: %d bytes is the most it carries",
|
||||
matched.maxRequestBody), http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, matched.maxRequestBody)
|
||||
}
|
||||
|
||||
matched.to.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -773,6 +795,18 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
||||
continue
|
||||
}
|
||||
made.insecure, _ = c.Values["insecure"].(bool)
|
||||
// A limit this proxy cannot read is a route it does not serve, named like a port that
|
||||
// is not a port. Serving it without the limit would carry exactly what the module said
|
||||
// not to carry, and report success doing it.
|
||||
if asked, said := c.Values["max-request-body"]; said {
|
||||
bytes, whole := asWhole(asked)
|
||||
if !whole || bytes <= 0 {
|
||||
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
||||
"not a whole positive number of bytes; skipped", c.From, c.Node, name, asked)
|
||||
continue
|
||||
}
|
||||
made.maxRequestBody = int64(bytes)
|
||||
}
|
||||
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -373,3 +375,116 @@ func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
|
||||
"once rather than what is served now")
|
||||
}
|
||||
}
|
||||
|
||||
// A route may say the largest body it carries, and the proxy holds requests to it.
|
||||
//
|
||||
// The registry is why: image layers arrive as single requests of gigabytes, and a proxy's own
|
||||
// default refuses them long before the workload is reached. It is configuration beside `insecure`,
|
||||
// not a fifth policy — novox/hq ADR 0108 closed that set at four.
|
||||
func TestARouteMayLimitTheBodyItCarries(t *testing.T) {
|
||||
routes, _, err := routesFrom(write(t, `{"given":[
|
||||
{"from":"registry","node":"anchor","at":"anchor.internal",
|
||||
"values":{"name":"images.example","port":5000,"max-request-body":21474836480}}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules := routes["images.example"]
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("the route is not served once: %v", routes)
|
||||
}
|
||||
if rules[0].maxRequestBody != 21474836480 {
|
||||
t.Fatalf("the limit did not survive the contribution: %d", rules[0].maxRequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
// Saying nothing leaves the route unlimited, which is what every route already got.
|
||||
func TestARouteThatSaysNothingCarriesAnySize(t *testing.T) {
|
||||
routes, _, err := routesFrom(write(t, `{"given":[
|
||||
{"from":"app","node":"anchor","at":"anchor.internal","values":{"name":"app.example","port":8080}}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := routes["app.example"][0].maxRequestBody; got != 0 {
|
||||
t.Fatalf("a route that asked for no limit got one: %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A limit that is not a whole positive number of bytes takes the route with it. Serving it without
|
||||
// the limit would carry exactly what the module said not to carry, and report success doing it.
|
||||
func TestARouteWithAnUnusableLimitIsSkipped(t *testing.T) {
|
||||
for _, asked := range []string{`"lots"`, `-1`, `0`, `1.5`} {
|
||||
routes, _, err := routesFrom(write(t, `{"given":[
|
||||
{"from":"registry","node":"anchor","at":"anchor.internal",
|
||||
"values":{"name":"images.example","port":5000,"max-request-body":`+asked+`}}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(routes["images.example"]) != 0 {
|
||||
t.Errorf("a route asking for a max-request-body of %s was served anyway: %v", asked, routes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A request larger than the route carries is refused by the proxy, with the limit named, and the
|
||||
// workload never sees it. A request within it is proxied normally.
|
||||
func TestABodyOverTheLimitIsRefusedAndOneUnderItIsCarried(t *testing.T) {
|
||||
var reached int
|
||||
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
reached++
|
||||
fmt.Fprintf(w, "carried %d bytes", len(body))
|
||||
}))
|
||||
defer workload.Close()
|
||||
|
||||
at := strings.TrimPrefix(workload.URL, "http://")
|
||||
host, port, _ := strings.Cut(at, ":")
|
||||
routes, _, err := routesFrom(write(t, `{"given":[
|
||||
{"from":"registry","node":"anchor","at":"`+host+`",
|
||||
"values":{"name":"images.example","port":`+port+`,"max-request-body":8}}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held := newTable()
|
||||
held.set(routes, map[string]bool{})
|
||||
proxy := httptest.NewServer(handler(held))
|
||||
defer proxy.Close()
|
||||
|
||||
over, err := post(proxy.URL, "images.example", "123456789")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer over.Body.Close()
|
||||
if over.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("a body over the limit answered %d, not 413", over.StatusCode)
|
||||
}
|
||||
if reached != 0 {
|
||||
t.Fatalf("the workload was reached by a request the route said it would not carry")
|
||||
}
|
||||
|
||||
under, err := post(proxy.URL, "images.example", "1234")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer under.Body.Close()
|
||||
if under.StatusCode != http.StatusOK {
|
||||
t.Fatalf("a body within the limit answered %d, not 200", under.StatusCode)
|
||||
}
|
||||
if reached != 1 {
|
||||
t.Fatalf("the workload was not reached by a request within the limit")
|
||||
}
|
||||
}
|
||||
|
||||
// post sends a body to the proxy as the named route, since a route is found by the Host header.
|
||||
func post(url, host, body string) (*http.Response, error) {
|
||||
asked, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
asked.Host = host
|
||||
asked.Header.Set("Content-Type", "application/octet-stream")
|
||||
return http.DefaultClient.Do(asked)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user