A route may say the largest body it carries #68
@@ -167,6 +167,16 @@ type rule struct {
|
|||||||
// webmail front is the first of these — never for anything reached over plain http, where
|
// webmail front is the first of these — never for anything reached over plain http, where
|
||||||
// there is nothing to verify in the first place.
|
// there is nothing to verify in the first place.
|
||||||
insecure bool
|
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.
|
// 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
|
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)
|
matched.to.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -773,6 +795,18 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.insecure, _ = c.Values["insecure"].(bool)
|
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)
|
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -373,3 +375,116 @@ func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
|
|||||||
"once rather than what is served now")
|
"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