Alex Dunmow 39427c40e7 feat(egress): http.request capability + allowed_hosts manifest (ADR 0023)
Host-mediated outbound HTTP for wasm plugins: guest http.RoundTripper
over the http.request capability; the host performs the request under
policy. New egress package defines the host-pattern grammar (exact or
multi-label wildcard, public-suffix rejected), matching, resource
bounds, and the ErrDenied sentinel (ABI_ERROR_CODE_EGRESS_DENIED).
plugin.mod gains first-class AllowedHosts + MaxResponseMB; manifest
gains allowed_hosts=34, max_response_mb=35. CoreServices.OutboundHTTP
carries the transport. Golden roundtrip + denial-mapping tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 02:00:29 +08:00

88 lines
2.6 KiB
Go

package caps
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"time"
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
"git.dev.alexdunmow.com/block/pluginsdk/egress"
)
// httpStub implements http.RoundTripper over the http.request capability: it
// marshals the outbound *http.Request into an HttpRequestRequest, hands it to
// the host (which enforces the plugin's egress grant, the SSRF guard, and the
// platform denylist, then performs the request), and rebuilds the reply into
// an *http.Response. The guest never touches a socket. Plugins use it through
// an ordinary &http.Client{Transport: deps.OutboundHTTP}.
type httpStub struct{ base }
var _ http.RoundTripper = (*httpStub)(nil)
// MaxRequestBytes bounds the request body the guest will marshal across the
// boundary, matching the host's cap; larger fails before any host call.
func (s *httpStub) RoundTrip(req *http.Request) (*http.Response, error) {
var body []byte
if req.Body != nil {
defer func() { _ = req.Body.Close() }()
b, err := io.ReadAll(io.LimitReader(req.Body, egress.MaxRequestBytes+1))
if err != nil {
return nil, fmt.Errorf("http.request: read body: %w", err)
}
if len(b) > egress.MaxRequestBytes {
return nil, fmt.Errorf("http.request: request body exceeds %d bytes", egress.MaxRequestBytes)
}
body = b
}
out := &abiv1.HttpRequestRequest{
Method: req.Method,
Url: req.URL.String(),
Headers: make(map[string]*abiv1.HeaderValues, len(req.Header)),
Body: body,
}
for name, values := range req.Header {
out.Headers[name] = &abiv1.HeaderValues{Values: values}
}
if dl, ok := req.Context().Deadline(); ok {
if ms := time.Until(dl).Milliseconds(); ms > 0 {
out.TimeoutMs = uint32(ms)
}
}
resp := &abiv1.HttpRequestResponse{}
if err := s.invoke(req.Context(), "request", out, resp); err != nil {
if isEgressDenied(err) {
return nil, fmt.Errorf("%w: %s", egress.ErrDenied, err.Error())
}
return nil, err
}
header := make(http.Header, len(resp.GetHeaders()))
for name, hv := range resp.GetHeaders() {
for _, v := range hv.GetValues() {
header.Add(name, v)
}
}
return &http.Response{
Status: http.StatusText(int(resp.GetStatus())),
StatusCode: int(resp.GetStatus()),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: header,
Body: io.NopCloser(bytes.NewReader(resp.GetBody())),
ContentLength: int64(len(resp.GetBody())),
Request: req,
}, nil
}
func isEgressDenied(err error) bool {
var coded abiCoded
return errors.As(err, &coded) &&
coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED
}