Repository navigation
Harden proxy request handling: 32 MiB body cap, loopback backend gate, fail-closed peer TLS #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -213,28 +213,36 @@ type workloadParams struct { | |
| WorkloadInfo Workload `json:"workloadInfo"` | ||
| } | ||
|
|
||
| // errBodyTooLarge is returned by bufferBodyAndModel when the request body | ||
| // exceeds maxInferenceBodyBytes. | ||
| var errBodyTooLarge = stderrors.New("request body exceeds 32 MiB limit") | ||
|
|
||
| // bufferBodyAndModel reads the request body once and returns the raw bytes | ||
| // (so each failover attempt can replay it — see the loop in handleHTTP) along | ||
| // with the JSON "model" field for workload tracking. Inference bodies are | ||
| // small (prompt + model), so full buffering is cheap. Returns (nil, "") when | ||
| // the body is absent and an empty model when none is parseable. The caller | ||
| // restores r.Body from the returned bytes before each forward attempt. | ||
| func bufferBodyAndModel(r *http.Request) ([]byte, string) { | ||
| // small (prompt + model), so full buffering is cheap. Returns (nil, "", nil) | ||
| // when the body is absent and an empty model when none is parseable. The | ||
| // caller restores r.Body from the returned bytes before each forward attempt. | ||
| // Bodies over maxInferenceBodyBytes are rejected with errBodyTooLarge. | ||
| func bufferBodyAndModel(r *http.Request) ([]byte, string, error) { | ||
| if r.Body == nil { | ||
| return nil, "" | ||
| return nil, "", nil | ||
| } | ||
| body, err := io.ReadAll(r.Body) | ||
| body, err := io.ReadAll(io.LimitReader(r.Body, maxInferenceBodyBytes+1)) | ||
| _ = r.Body.Close() | ||
| if err != nil { | ||
| return body, "" | ||
| return body, "", err | ||
| } | ||
| if len(body) > maxInferenceBodyBytes { | ||
| return nil, "", errBodyTooLarge | ||
| } | ||
| var probe struct { | ||
| Model string `json:"model"` | ||
| } | ||
| if err := json.Unmarshal(body, &probe); err != nil { | ||
| return body, "" | ||
| return body, "", nil | ||
| } | ||
| return body, probe.Model | ||
| return body, probe.Model, nil | ||
| } | ||
|
|
||
| type statusCapture struct { | ||
|
|
@@ -736,6 +744,12 @@ const ( | |
| proxyReadHeaderTimeout = 10 * time.Second | ||
| proxyServerIdleTimeout = 90 * time.Second | ||
| maxModelListBytes = 16 << 20 | ||
| // maxInferenceBodyBytes caps a single proxied request body. handleHTTP | ||
| // buffers the whole body to replay it across failover attempts, so an | ||
| // uncapped read lets any loopback client grow the proxy until it OOMs. | ||
| // Bodies over the cap are rejected with 413 before any routing work | ||
| // happens. | ||
| maxInferenceBodyBytes = 32 << 20 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason you picked 32 mb?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No measurement behind it, it is a judgment call. The proxy buffers the full body so it can replay it across failover attempts, which means the read has to be capped or any client can OOM the proxy. 32 MiB covers real inference bodies with a lot of headroom (they are JSON, usually KBs) while bounding per-request memory. Happy to make it a config knob if you would rather have operators tune it. |
||
| ) | ||
|
|
||
| // idleClientWriteTimeout bounds how long a single write of streamed response | ||
|
|
@@ -860,11 +874,11 @@ func (p *Proxy) peerHTTPTransport(peerUUID string) *http.Transport { | |
| delete(p.peerTransports, peerUUID) | ||
| } | ||
| if p.mesh == nil { | ||
| return newProxyTransport(nil) | ||
| return unpinnedPeerTransport() | ||
| } | ||
| cfg, ok := p.mesh.ClientTLSConfig(peerUUID) | ||
| if !ok { | ||
| return newProxyTransport(nil) | ||
| return unpinnedPeerTransport() | ||
| } | ||
| tr := newProxyTransport(cfg) | ||
| if p.peerTransports == nil { | ||
|
|
@@ -874,6 +888,22 @@ func (p *Proxy) peerHTTPTransport(peerUUID string) *http.Transport { | |
| return tr | ||
| } | ||
|
|
||
| // errPeerUnpinned is the dial error of a fail-closed peer transport: the | ||
| // peer's certificate pin was absent when the transport was built. | ||
| var errPeerUnpinned = stderrors.New("peer is not a pinned cluster member") | ||
|
|
||
| // unpinnedPeerTransport returns a transport that fails every dial. It is the | ||
| // fail-closed answer when a peer has no live certificate pin — used instead | ||
| // of an unpinned (plaintext-auth) transport so a vanished pin can never | ||
| // silently downgrade a peer connection. | ||
| func unpinnedPeerTransport() *http.Transport { | ||
| return &http.Transport{ | ||
| DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { | ||
| return nil, errPeerUnpinned | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // dropUnpinnedPeerTransports closes idle conns for peer Transports whose pins | ||
| // are gone. Safe to call from the mesh Watch callback. | ||
| func (p *Proxy) dropUnpinnedPeerTransports() { | ||
|
|
@@ -1161,7 +1191,24 @@ func (f *facade) handleHTTP(w http.ResponseWriter, r *http.Request) { | |
| // Parse the request's model before choosing a node. Model eligibility only | ||
| // applies to inference routes; control endpoints retain their existing | ||
| // routing behavior even when their JSON happens to contain a model field. | ||
| bodyBytes, model := bufferBodyAndModel(r) | ||
| bodyBytes, model, bodyErr := bufferBodyAndModel(r) | ||
| if bodyErr != nil { | ||
| // errBodyTooLarge (413) is answered here so the oversized body never | ||
| // reaches candidate selection or an engine. Any other read error | ||
| // leaves bodyBytes as whatever was read before the failure. | ||
| if stderrors.Is(bodyErr, errBodyTooLarge) { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusRequestEntityTooLarge) | ||
| _, _ = w.Write([]byte(`{"error":"request body exceeds 32 MiB limit"}`)) | ||
| _ = f.notify("proxy/request", RequestEvent{ | ||
| ID: reqID, Method: r.Method, Path: r.URL.Path, Target: "rejected", | ||
| Status: http.StatusRequestEntityTooLarge, Duration: time.Since(start).Milliseconds(), | ||
| Error: bodyErr.Error(), | ||
| }) | ||
| return | ||
| } | ||
| slog.Warn("request body read error", "id", reqID, "err", bodyErr) | ||
| } | ||
| isInf := isInferenceRequest(f.profile, r.Method, r.URL.Path) | ||
| routingModel := "" | ||
| if isInf { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package main | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestBufferBodyAndModelRejectsOversizedBody: the inbound body cap is the | ||
| // fix for the unauthenticated OOM — handleHTTP buffers the whole body for | ||
| // failover replay, so an uncapped read lets any loopback client grow the | ||
| // proxy until it dies. | ||
| func TestBufferBodyAndModelRejectsOversizedBody(t *testing.T) { | ||
| big := strings.NewReader(strings.Repeat("x", maxInferenceBodyBytes+1)) | ||
| req := httptest.NewRequest(http.MethodPost, "/api/chat", big) | ||
| if _, _, err := bufferBodyAndModel(req); err != errBodyTooLarge { | ||
| t.Fatalf("oversized body err = %v, want errBodyTooLarge", err) | ||
| } | ||
|
|
||
| // Exactly at the cap still passes. | ||
| exact := strings.NewReader(strings.Repeat("x", maxInferenceBodyBytes)) | ||
| req = httptest.NewRequest(http.MethodPost, "/api/chat", exact) | ||
| body, _, err := bufferBodyAndModel(req) | ||
| if err != nil { | ||
| t.Fatalf("at-cap body err = %v, want nil", err) | ||
| } | ||
| if len(body) != maxInferenceBodyBytes { | ||
| t.Fatalf("at-cap body len = %d, want %d", len(body), maxInferenceBodyBytes) | ||
| } | ||
|
|
||
| // Small bodies still parse the model field. | ||
| req = httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{"model":"llama3"}`)) | ||
| _, model, err := bufferBodyAndModel(req) | ||
| if err != nil || model != "llama3" { | ||
| t.Fatalf("small body model = %q, err = %v; want %q, nil", model, err, "llama3") | ||
| } | ||
| } | ||
|
|
||
| // TestHandleHTTPRejectsOversizedBody: an over-cap POST is answered 413 with | ||
| // a JSON error before any routing or engine work, and the over-cap read | ||
| // consumes at most cap+1 bytes from the client. | ||
| func TestHandleHTTPRejectsOversizedBody(t *testing.T) { | ||
| profile, ok := profileFor("ollama") | ||
| if !ok { | ||
| t.Fatal("no ollama engine profile") | ||
| } | ||
| f := testProxy(profile, NewDiscovery(), profile.StandalonePort).soleFacade() | ||
|
|
||
| pr, pw := io.Pipe() | ||
| go func() { | ||
| _, _ = pw.Write([]byte(strings.Repeat("x", maxInferenceBodyBytes+1024))) | ||
| _ = pw.Close() | ||
| }() | ||
| req := httptest.NewRequest(http.MethodPost, "/api/chat", pr) | ||
| rec := httptest.NewRecorder() | ||
|
|
||
| f.handleHTTP(rec, req) | ||
|
|
||
| if rec.Code != http.StatusRequestEntityTooLarge { | ||
| t.Fatalf("oversized POST status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge) | ||
| } | ||
| if ct := rec.Header().Get("Content-Type"); ct != "application/json" { | ||
| t.Errorf("Content-Type = %q, want application/json", ct) | ||
| } | ||
| var payload map[string]string | ||
| if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { | ||
| t.Fatalf("413 body is not valid JSON: %v (body = %q)", err, rec.Body.String()) | ||
| } | ||
| if payload["error"] != "request body exceeds 32 MiB limit" { | ||
| t.Errorf("413 error = %q, want %q", payload["error"], "request body exceeds 32 MiB limit") | ||
| } | ||
| } | ||
|
|
||
| // TestHandleHTTPAcceptsNormalBody: a small body is not 413'd (it takes the | ||
| // normal no-candidate rejection path in this fixture). | ||
| func TestHandleHTTPAcceptsNormalBody(t *testing.T) { | ||
| profile, ok := profileFor("ollama") | ||
| if !ok { | ||
| t.Fatal("no ollama engine profile") | ||
| } | ||
| f := testProxy(profile, NewDiscovery(), profile.StandalonePort).soleFacade() | ||
|
|
||
| req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{"model":"llama3"}`)) | ||
| rec := httptest.NewRecorder() | ||
|
|
||
| f.handleHTTP(rec, req) | ||
|
|
||
| if rec.Code == http.StatusRequestEntityTooLarge { | ||
| t.Fatalf("normal POST status = 413, want the ordinary rejection path") | ||
| } | ||
| } | ||
|
|
||
| // TestSetLocalBackendRejectsNonLoopback: the loopback-only local engine | ||
| // invariant is enforced where the backend is stored — a non-loopback host is | ||
| // refused outright, so the mTLS ingress can never be turned into a forwarder | ||
| // to an arbitrary LAN address. | ||
| func TestSetLocalBackendRejectsNonLoopback(t *testing.T) { | ||
| profile, ok := profileFor("ollama") | ||
| if !ok { | ||
| t.Fatal("no ollama engine profile") | ||
| } | ||
| f := testProxy(profile, NewDiscovery(), profile.StandalonePort).soleFacade() | ||
|
|
||
| for _, host := range []string{"192.168.1.5", "10.0.0.2", "example.com", "::ffff:192.168.1.5"} { | ||
| if err := f.setLocalBackend(localBackend{Engine: "ollama", Host: host, Port: 11436, Healthy: true}); err == nil { | ||
| t.Errorf("setLocalBackend accepted non-loopback host %q", host) | ||
| } | ||
| } | ||
| for _, host := range []string{"", "127.0.0.1", "127.0.0.2", "::1"} { | ||
| if err := f.setLocalBackend(localBackend{Engine: "ollama", Host: host, Port: 11436, Healthy: true}); err != nil { | ||
| t.Errorf("setLocalBackend rejected loopback host %q: %v", host, err) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TestPeerHTTPTransportFailsClosedWithoutPin: with no live certificate pin | ||
| // the transport refuses every dial instead of falling back to an unpinned | ||
| // transport — a pin that vanishes between candidate selection and dial time | ||
| // can never silently downgrade to an unauthenticated connection. | ||
| func TestPeerHTTPTransportFailsClosedWithoutPin(t *testing.T) { | ||
| profile, ok := profileFor("ollama") | ||
| if !ok { | ||
| t.Fatal("no ollama engine profile") | ||
| } | ||
| p := testProxy(profile, NewDiscovery(), profile.StandalonePort) // mesh nil => unclustered | ||
|
|
||
| tr := p.peerHTTPTransport("no-such-peer") | ||
| if tr == nil { | ||
| t.Fatal("peerHTTPTransport returned nil; want a fail-closed transport") | ||
| } | ||
| if tr.TLSClientConfig != nil { | ||
| t.Error("fail-closed transport must not carry a TLS config") | ||
| } | ||
| conn, err := tr.DialContext(t.Context(), "tcp", "192.0.2.10:443") | ||
| if err != errPeerUnpinned { | ||
| t.Errorf("dial err = %v, want errPeerUnpinned", err) | ||
| } | ||
| if conn != nil { | ||
| _ = conn.Close() | ||
| t.Error("fail-closed transport dialed successfully") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like we never got to the buffering and then reading from disk if the body is too large. Are you still planning to implement that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Still planned, but I would rather do it as a follow-up than bolt it onto this one. The temp file lifecycle and the cleanup paths deserve their own review, and I do not want to rush that in a hardening PR. The 413 stays as the safe behavior until then.