fix(adapt): a net/http handler's request owns its strings, so a header it keeps is not overwritten by the next request

Claude-Session: https://claude.ai/code/session_01AGuBEqCjuman4E9FdKEgeV
This commit is contained in:
2026-10-03 02:22:20 -07:00
parent 247435d564
commit 710cfc6694
3 changed files with 78 additions and 0 deletions
+1
View File
@@ -57,6 +57,7 @@ Patch tags, one above the highest `v*` on GitHub. Push the commit to main, read
- Doc prose is filed under `DocKey(pkg, method, path)`. zipdoc writes the package's import path. `pkgOf` reads the handler's package from its runtime name and maps `main` to the build info path, because a built binary names package main's functions `main.` while `go test` names them by import path.
- MCP over HTTP: POST only (GET and DELETE answer 405), one message per request, no sessions, no batches, notifications 202. `negotiate` answers `initialize` in the requested revision when it is 2025-06-18, 2025-11-25 or 2026-07-28, else in 2026-07-28. The official TypeScript (1.30.0) and Python (2.2.0) clients refuse an answer in a revision they did not request and do not know.
- `AdaptNetHTTP` carries an upgrade: a request with `Connection: Upgrade` runs the handler inside fasthttp's hijack, on a writer that is an `http.Hijacker`, so a net/http WebSocket server (coder/websocket, gorilla) writes its own 101 and owns the socket; a handler that declines answers an ordinary HTTP/1.1 reply. `wsx` stays the native way for a zip handler.
- `AdaptNetHTTP` hands the handler a request that owns its strings (`own`): method, host, header names and values and the URL are copied out of fasthttp's pooled buffer, which the next request on the connection overwrites. A handler may keep a header past its return; the body stays a view, valid until the handler returns (an upgrade's body is copied, since its handler runs after the buffer is released).
- zip validates no tokens. Identity is read from gateway-set headers (`HeaderOrg` is `X-Org-Id`); `Authorize` runs at the invoke seam, so it covers REST, MCP, the call plane and the in-process CLI.
- `app.Test` runs `prepare`, so `/mcp`, the OpenAPI document and the call plane answer under test as they do when serving.
- The `/docs` page loads Swagger UI from cdn.jsdelivr.net. Embedding swagger-ui-dist 5 would add about 1.74 MB (bundle 1,552,209 bytes, CSS 185,784 bytes, Apache-2.0) to every binary that links zip.
+42
View File
@@ -8,7 +8,9 @@ import (
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"github.com/zap-proto/fiber/v3/middleware/adaptor"
@@ -103,12 +105,24 @@ func adaptStreaming(h http.Handler) func(*Ctx) error {
// matches on URL.Path, which is why a mux in front of it looks fine.
req.RequestURI = string(c.fc.Request().Header.RequestURI())
// THE REQUEST OWNS ITS STRINGS. ConvertRequest hands net/http views of
// fasthttp's pooled request buffer, and the next request on the connection
// writes over that buffer. A Go string is immutable, so a handler may keep
// one — a session desk holding the bearer a ticket was issued for — and
// then read someone else's bytes under it: the talk socket's chat call
// went out with a corrupted token on every other turn.
if err := own(req); err != nil {
return err
}
// AN UPGRADE IS ANSWERED ON THE CONNECTION ITSELF. A net/http WebSocket
// server (coder/websocket, gorilla) takes the socket with http.Hijacker and
// writes the 101 itself; a writer without Hijack makes it answer 501 to
// every upgrade. So the handler runs inside fasthttp's hijack, against the
// raw connection, after this returns.
if upgrading(c.fc.Request()) {
// The handler runs after this returns, when the buffer is the pool's.
req.Body = io.NopCloser(bytes.NewReader(bytes.Clone(c.fc.Request().Body())))
serveUpgrade(c, h, req)
return nil
}
@@ -152,6 +166,34 @@ func adaptStreaming(h http.Handler) func(*Ctx) error {
}
}
// own copies every string the request carries out of fasthttp's buffer: method,
// protocol, host, header names and values, and the URL, parsed again from the
// target already copied into RequestURI. The body stays a view: net/http forbids
// reading it after the handler returns, and the buffer lives until then.
func own(req *http.Request) error {
req.Method = strings.Clone(req.Method)
req.Proto = strings.Clone(req.Proto)
req.Host = strings.Clone(req.Host)
header := make(http.Header, len(req.Header))
for k, vs := range req.Header {
kept := make([]string, len(vs))
for i, v := range vs {
kept[i] = strings.Clone(v)
}
header[strings.Clone(k)] = kept
}
req.Header = header
for i, v := range req.TransferEncoding {
req.TransferEncoding[i] = strings.Clone(v)
}
u, err := url.ParseRequestURI(req.RequestURI)
if err != nil {
return err
}
req.URL = u
return nil
}
// serveUpgrade runs h on the hijacked connection. The request no longer has the
// fasthttp context to carry — the handler must not hold ctx members past the
// hijack — so it carries one of its own, cancelled when the handler returns.
+35
View File
@@ -248,3 +248,38 @@ func TestAdaptNetHTTP_ASwitchWrittenBeforeTheHijackIsSent(t *testing.T) {
t.Fatalf("after the switch the connection answered %q", got)
}
}
// A HEADER KEPT IS A HEADER KEPT. A handler stores the bearer one request
// presented; the next request on the same connection presents another of the
// same length. The stored one must still read as it was — it was handed a Go
// string, and a string does not change under its holder.
func TestAdaptNetHTTP_AKeptHeaderSurvivesTheNextRequest(t *testing.T) {
kept := make(chan [2]string, 2)
base := streamApp(t, func(w http.ResponseWriter, r *http.Request) {
kept <- [2]string{r.Header.Get("Authorization"), r.URL.Query().Get("ticket")}
_, _ = io.WriteString(w, "ok")
})
conn, err := net.Dial("tcp", strings.TrimPrefix(base, "http://"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
rd := bufio.NewReader(conn)
ask := func(bearer, ticket string) {
fmt.Fprintf(conn, "GET /legacy/session?ticket=%s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer %s\r\n\r\n", ticket, bearer)
resp, err := http.ReadResponse(rd, nil)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
ask("aaaaaaaaaaaaaaaa", "first-ticket")
first := <-kept
ask("bbbbbbbbbbbbbbbb", "other-ticket")
<-kept
if first[0] != "Bearer aaaaaaaaaaaaaaaa" || first[1] != "first-ticket" {
t.Fatalf("the first request's header and ticket now read %q and %q", first[0], first[1])
}
}