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>
88 lines
2.6 KiB
Go
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
|
|
}
|