mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user