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 }