feat(14-01): fetchguard client covers PUT, multipart, bearer and a trusted mode
- TrustedMode (declared after PublicOnlyMode) lifts the scheme, host and dial checks for Client only - PutJSON, PostMultipart with FormField/FormFile, Bearer - tests for modes, redirects, multipart order, body cap and the scheme guard - README, root modules row and outbound HTTP docs describe the client and its test seam
This commit is contained in:
@@ -5,8 +5,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,9 +20,11 @@ import (
|
||||
// Client is a guarded outbound HTTP client for calling service APIs: any
|
||||
// method, JSON or multipart bodies, caller-supplied headers. It keeps the
|
||||
// guarantees of Fetch for the policy it was built with: URL validation
|
||||
// before any I/O, the dial-time private and reserved address check on every
|
||||
// new connection (except in TrustedMode), a response body cap and no
|
||||
// redirects. Status codes are returned, never judged.
|
||||
// before any I/O (https only and the host list, except in TrustedMode), the
|
||||
// dial-time private and reserved address check on every new connection
|
||||
// (except in TrustedMode), a response body cap and no redirects. Status codes
|
||||
// are returned, never judged. Request bodies are not capped: callers bound
|
||||
// their own inputs.
|
||||
//
|
||||
// A Client is safe for concurrent use; build one per vendor and reuse it so
|
||||
// connections are kept alive.
|
||||
@@ -41,7 +46,7 @@ func NewClient(policy Policy, cfg *compass.Config) (*Client, error) {
|
||||
policy: policy,
|
||||
maxBytes: maxBytes,
|
||||
timeout: timeout,
|
||||
http: newHTTPClient(newTransport(policy, timeout, true), timeout),
|
||||
http: newHTTPClient(newTransport(policy, timeout, true, policy.Mode != TrustedMode), timeout),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -126,6 +131,70 @@ func (c *Client) PostJSON(ctx context.Context, rawURL string, header http.Header
|
||||
return c.sendJSON(ctx, http.MethodPost, rawURL, header, body)
|
||||
}
|
||||
|
||||
// PutJSON is PostJSON with the PUT method.
|
||||
func (c *Client) PutJSON(ctx context.Context, rawURL string, header http.Header, body any) (*Result, error) {
|
||||
return c.sendJSON(ctx, http.MethodPut, rawURL, header, body)
|
||||
}
|
||||
|
||||
// FormField is one plain multipart/form-data field.
|
||||
type FormField struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
// FormFile is one multipart/form-data file part. ContentType defaults to
|
||||
// application/octet-stream.
|
||||
type FormFile struct {
|
||||
Field string
|
||||
Filename string
|
||||
ContentType string
|
||||
Body io.Reader
|
||||
}
|
||||
|
||||
// PostMultipart POSTs a multipart/form-data body to rawURL: every field in
|
||||
// slice order, then every file in slice order. header is copied first; the
|
||||
// multipart Content-Type with its boundary always wins.
|
||||
func (c *Client) PostMultipart(ctx context.Context, rawURL string, header http.Header, fields []FormField, files []FormFile) (*Result, error) {
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for _, f := range fields {
|
||||
if err := mw.WriteField(f.Name, f.Value); err != nil {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
}
|
||||
for _, f := range files {
|
||||
ct := f.ContentType
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
h := textproto.MIMEHeader{}
|
||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`,
|
||||
quoteEscaper.Replace(f.Field), quoteEscaper.Replace(f.Filename)))
|
||||
h.Set("Content-Type", ct)
|
||||
part, err := mw.CreatePart(h)
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
if f.Body != nil {
|
||||
if _, err := io.Copy(part, f.Body); err != nil {
|
||||
return nil, &Error{Reason: ReasonNetworkError, Err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
last := http.Header{"Content-Type": {mw.FormDataContentType()}}
|
||||
return c.sendWith(ctx, http.MethodPost, rawURL, header, last, &buf)
|
||||
}
|
||||
|
||||
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
|
||||
|
||||
// Bearer returns the Authorization header value "Bearer <token>".
|
||||
func Bearer(token string) string {
|
||||
return "Bearer " + token
|
||||
}
|
||||
|
||||
// Get sends a GET to rawURL with header.
|
||||
func (c *Client) Get(ctx context.Context, rawURL string, header http.Header) (*Result, error) {
|
||||
return c.send(ctx, http.MethodGet, rawURL, nil, header, nil)
|
||||
@@ -142,6 +211,12 @@ func (c *Client) sendJSON(ctx context.Context, method, rawURL string, header htt
|
||||
}
|
||||
|
||||
func (c *Client) send(ctx context.Context, method, rawURL string, base, header http.Header, body io.Reader) (*Result, error) {
|
||||
return c.sendWith(ctx, method, rawURL, base, header, body)
|
||||
}
|
||||
|
||||
// sendWith builds a request whose headers are first, then second (second
|
||||
// wins per header name) and sends it.
|
||||
func (c *Client) sendWith(ctx context.Context, method, rawURL string, first, second http.Header, body io.Reader) (*Result, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -149,11 +224,10 @@ func (c *Client) send(ctx context.Context, method, rawURL string, base, header h
|
||||
if err != nil {
|
||||
return nil, &Error{Reason: ReasonInvalidURL, Err: err}
|
||||
}
|
||||
for k, vs := range base {
|
||||
req.Header[k] = append([]string(nil), vs...)
|
||||
}
|
||||
for k, vs := range header {
|
||||
req.Header[http.CanonicalHeaderKey(k)] = append([]string(nil), vs...)
|
||||
for _, h := range []http.Header{first, second} {
|
||||
for k, vs := range h {
|
||||
req.Header[http.CanonicalHeaderKey(k)] = append([]string(nil), vs...)
|
||||
}
|
||||
}
|
||||
return c.Send(req)
|
||||
}
|
||||
@@ -163,7 +237,14 @@ func (c *Client) check(u *url.URL) error {
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return &Error{Reason: ReasonInvalidURL}
|
||||
}
|
||||
if strings.ToLower(u.Scheme) != "https" {
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if c.policy.Mode == TrustedMode {
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return &Error{Reason: ReasonScheme}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if scheme != "https" {
|
||||
return &Error{Reason: ReasonScheme}
|
||||
}
|
||||
if c.policy.Mode == AllowHostsMode && !hostAllowed(u.Hostname(), c.policy.AllowHosts) {
|
||||
|
||||
Reference in New Issue
Block a user