From a287812e143c3c7d0ea0306526b034a4fbd46d36 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 16:01:21 +0200 Subject: [PATCH] A route may say the largest body it carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy configuration beside insecure, not a fifth policy — ADR 0108 closed that set at four, and both of these tune how a request is carried rather than deciding what a name admits. 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. Absent is no limit, which is what every route already got. A limit that is not a whole positive number of bytes takes the route with it, named in the log like a port that is not one — serving it without the limit would carry exactly what the module said not to carry. Enforced on the declared length where there is one, and while reading for a chunked body, which declares none: without the second, a limit is advice. --- examples/route-proxy/main.go | 34 ++++++++ examples/route-proxy/routes_test.go | 115 ++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/examples/route-proxy/main.go b/examples/route-proxy/main.go index 38d9308..5b9a39a 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -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) } diff --git a/examples/route-proxy/routes_test.go b/examples/route-proxy/routes_test.go index 36d49a5..3bb71a3 100644 --- a/examples/route-proxy/routes_test.go +++ b/examples/route-proxy/routes_test.go @@ -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) +} -- 2.54.0