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:
@@ -91,7 +91,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
|
||||
| [compass](modules/compass/README.md) | Layered YAML configuration with per-environment directories, `SUMMER_` environment overrides, embedded plugin defaults and dot-path access. |
|
||||
| [conga](modules/conga/README.md) | Background jobs on River over the shared Postgres pool: transactional dispatch, a `summer_jobs` progress record, in-process or dedicated workers, and a wall-clock scheduler. |
|
||||
| [festival](modules/festival/README.md) | Typed, synchronous event bus with listener priorities, payload collection and stop-when-handled dispatch. |
|
||||
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTPS fetcher that blocks private and reserved addresses and enforces host, size and timeout limits. |
|
||||
| [fetchguard](modules/fetchguard/README.md) | Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints. |
|
||||
| [flare](modules/flare/README.md) | Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface. |
|
||||
| [lagoon](modules/lagoon/README.md) | Postgres data layer: the shared GORM connection, per-plugin migrations, model helpers and file attachments. |
|
||||
| [lighthouse](modules/lighthouse/README.md) | Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Outbound HTTP
|
||||
description: Fetch URLs that users or third parties supply through fetchguard, which allows HTTPS only, blocks private addresses at dial time and limits size and time.
|
||||
description: Fetch user-supplied URLs and call vendor APIs through fetchguard, which blocks private addresses at dial time, limits size and time and never redirects.
|
||||
section: services
|
||||
order: 100
|
||||
---
|
||||
@@ -8,7 +8,7 @@ order: 100
|
||||
|
||||
A WinterCMS plugin fetches a remote URL with the Laravel HTTP client or Guzzle, and checks the URL by hand when it came from a user. When a URL comes from outside the application, such as a remote image address, fetch it with [fetchguard](../../modules/fetchguard/README.md). It is the framework's guard against server-side request forgery: a request that a user can aim at the application's own network, a cloud metadata service or an internal admin panel.
|
||||
|
||||
For calls to services the application itself chose, such as a payment provider's API, the standard `net/http` client is fine.
|
||||
For calls to service APIs, such as a payment provider or a model vendor, use a `fetchguard.Client` (see [Calling a service API](#calling-a-service-api)). It applies the same guard to every request, caps responses and never follows redirects.
|
||||
|
||||
## Policies
|
||||
|
||||
@@ -70,3 +70,52 @@ A failure is always a `fetchguard.Error` with one `fetchguard.Reason` from a clo
|
||||
Redirects are never followed: a 3xx response is returned as a result. To follow it, call `fetchguard.Fetch` again with the `Location` URL, which runs every check again.
|
||||
|
||||
The body is capped at the policy's `MaxBytes` (a larger body is `fetchguard.ReasonTooLarge`) and the call at its `Timeout`. A limit left at zero falls back to `http.fetch.max_bytes` and `http.fetch.timeout_seconds` from the configuration you pass, then to the framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`). A configured value of zero or less is an error, not a way to turn a limit off.
|
||||
|
||||
## Calling a service API
|
||||
|
||||
Build one `fetchguard.Client` per vendor with `fetchguard.NewClient` and keep it for the life of the plugin: it keeps connections alive and runs the dial guard on every new one. The client sends any method; the helpers cover the common shapes:
|
||||
|
||||
- `fetchguard.Client.PostJSON` and `fetchguard.Client.PutJSON` marshal the body, set `Content-Type: application/json` and then copy your headers over it.
|
||||
- `fetchguard.Client.PostMultipart` writes every `fetchguard.FormField` and then every `fetchguard.FormFile` in slice order.
|
||||
- `fetchguard.Client.Get` sends a GET, and `fetchguard.Client.Do` or `fetchguard.Client.Send` take any `http.Request` you build.
|
||||
- `fetchguard.Bearer` builds the `Authorization` value.
|
||||
|
||||
```go src=modules/fetchguard/example_test.go#ExampleClient_PostJSON
|
||||
client, err := fetchguard.NewClient(fetchguard.Policy{
|
||||
Mode: fetchguard.AllowHostsMode,
|
||||
AllowHosts: []string{"api.example.com"},
|
||||
Timeout: 10 * time.Second,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", fetchguard.Bearer("example-token"))
|
||||
|
||||
// Production code passes its own context; the example routes the call to
|
||||
// a stub instead of the network.
|
||||
ctx := fetchguard.WithTransport(context.Background(), stubVendor{})
|
||||
res, err := client.PostJSON(ctx, "https://api.example.com/v1/items", header, map[string]string{"name": "widget"})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(res.StatusCode, string(res.Body))
|
||||
// Output:
|
||||
// 201 {"id":42}
|
||||
```
|
||||
|
||||
The `fetchguard.Result` carries the status code and every response header, unjudged: a 401, a 429 with `Retry-After` or a 3xx is a result, not an error, and the caller decides what it means. Errors are transport failures and guard refusals, always a `fetchguard.Error`.
|
||||
|
||||
Choose the timeout per consumer in the policy: the client has no vendor defaults. A short timeout suits a metadata API; a model call that generates a long answer needs minutes. Request bodies are not capped, so bound uploads before you send them.
|
||||
|
||||
### Trusted mode
|
||||
|
||||
`fetchguard.TrustedMode` is for endpoints an operator configured, in Go code or in admin settings, for example a model server on the local network. A client in this mode accepts `http` and `https`, checks no host list and skips the dial guard; the body cap and the no-redirect rule still apply. Only Go code that builds the policy can choose it: no configuration key, environment variable or request input selects it.
|
||||
|
||||
Never use it for a URL that a user or a third party supplied, including a per-user override of a vendor's base URL. Those go through `fetchguard.AllowHostsMode` or `fetchguard.PublicOnlyMode`.
|
||||
|
||||
## Testing outbound calls
|
||||
|
||||
Tests never call a vendor. `fetchguard.WithTransport` returns a context whose client requests go to an `http.RoundTripper` you supply instead of the network. The URL guard still runs first, so the test also proves the policy accepts the vendor's real host. The override lives only in the context: no policy field, client field, configuration key, environment variable or header can set it.
|
||||
|
||||
For API parity work, hand it the [tide](../../modules/tide/README.md) upstream fake, which replays the vendor exchanges recorded from the reference backend and asserts every request the client sends. See [Parity testing](parity-testing.md).
|
||||
|
||||
@@ -39,6 +39,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
|
||||
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
|
||||
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
|
||||
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |
|
||||
| A plugin's own curl request sender for a vendor API (JSON or multipart POST, bearer token) | One `fetchguard.Client` per vendor from `fetchguard.NewClient`, with `fetchguard.Client.PostJSON`, `fetchguard.Client.PostMultipart` and `fetchguard.Bearer` | [Outbound HTTP](../services/outbound-http.md#calling-a-service-api), [fetchguard](../../modules/fetchguard/README.md) |
|
||||
|
||||
## What is not provided
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# fetchguard
|
||||
|
||||
Guarded outbound HTTPS fetcher that blocks private and reserved addresses and enforces host, size and timeout limits.
|
||||
Guarded outbound HTTP client and fetcher that blocks private and reserved addresses, enforces host, size and timeout limits, and offers an explicit trusted mode for operator-configured endpoints.
|
||||
|
||||
`import "git.golem15.com/golem15/summercms/modules/fetchguard"`
|
||||
|
||||
@@ -8,13 +8,18 @@ Guarded outbound HTTPS fetcher that blocks private and reserved addresses and en
|
||||
|
||||
`fetchguard` is the framework's server-side request forgery guard for fetching URLs that come from users or third parties, such as a remote image address. Every call takes a `fetchguard.Policy` that either restricts the target to an allow list of hosts or permits any public host; in both modes the dial-time check refuses private, loopback, link-local, carrier-grade NAT, documentation, multicast and other reserved IPv4 and IPv6 ranges, including IPv4 embedded in NAT64 and 6to4 addresses. Failures come back as a `fetchguard.Error` carrying one `fetchguard.Reason` from a closed set, so callers can map them onto stable API error codes. WinterCMS has no dedicated counterpart; plugins there typically used Guzzle with hand-written checks.
|
||||
|
||||
`fetchguard.Fetch` is a one-shot guarded GET. For calling a service API (any method, JSON or multipart bodies, bearer credentials) build one `fetchguard.Client` per vendor with `fetchguard.NewClient`; it applies the same policy to every request it sends and replaces a plugin's own curl request sender.
|
||||
|
||||
## Features
|
||||
|
||||
- HTTPS only: any other scheme fails with `fetchguard.ReasonScheme`.
|
||||
- Two modes: `fetchguard.AllowHostsMode` (exact or dotted-suffix host match against `fetchguard.Policy.AllowHosts`) and `fetchguard.PublicOnlyMode` (any public host).
|
||||
- HTTPS only in the guarded modes: any other scheme fails with `fetchguard.ReasonScheme`.
|
||||
- Two guarded modes: `fetchguard.AllowHostsMode` (exact or dotted-suffix host match against `fetchguard.Policy.AllowHosts`) and `fetchguard.PublicOnlyMode` (any public host).
|
||||
- `fetchguard.TrustedMode` for endpoints an operator configured in Go code or admin settings, such as a model server on the local network: a `fetchguard.Client` in this mode accepts http and https, checks no host list and runs no dial guard, but still caps the body and never follows redirects. Only Go code that builds the policy can choose it; `fetchguard.Fetch` guards as `fetchguard.PublicOnlyMode` when given it.
|
||||
- `fetchguard.Client` sends any method: `fetchguard.Client.Do` and `fetchguard.Client.Send` take an `http.Request`, and `fetchguard.Client.Get`, `fetchguard.Client.PostJSON`, `fetchguard.Client.PutJSON` and `fetchguard.Client.PostMultipart` cover the common shapes. `fetchguard.Bearer` builds an Authorization value. Response status and headers come back unjudged in `fetchguard.Result`.
|
||||
- Code-only transport seam: `fetchguard.WithTransport` routes a context's requests to a test `http.RoundTripper`. No policy field, config key, environment variable or header can set it.
|
||||
- The private and reserved address check runs on the resolved IP at dial time, so DNS answers that point inside the network are refused (`fetchguard.ReasonPrivateIP`); environment proxies are ignored so the check sees the real target.
|
||||
- Redirects are never followed: a 3xx response is returned as a successful `fetchguard.Result`, and a caller that wants to follow the Location header calls `fetchguard.Fetch` again, which re-runs the guard.
|
||||
- The response body is capped at the policy's byte limit (`fetchguard.ReasonTooLarge` when exceeded), with a per-call timeout.
|
||||
- Redirects are never followed, in any mode: a 3xx response is returned as a successful `fetchguard.Result`, and a caller that wants to follow the Location header sends a new request, which re-runs the guard.
|
||||
- The response body is capped at the policy's byte limit (`fetchguard.ReasonTooLarge` when exceeded), with a per-call timeout. Request bodies are not capped; callers bound their own inputs.
|
||||
- Limits left at zero in the policy fall back to config keys, then to framework defaults of 10 MiB and 10 seconds (`fetchguard.Defaults`, `fetchguard.DefaultsFromConfig`).
|
||||
- Typed failure reasons: `fetchguard.ReasonInvalidURL`, `fetchguard.ReasonScheme`, `fetchguard.ReasonUnresolvable`, `fetchguard.ReasonPrivateIP`, `fetchguard.ReasonNetworkError`, `fetchguard.ReasonTooLarge`.
|
||||
|
||||
@@ -42,14 +47,57 @@ if res.StatusCode != http.StatusOK {
|
||||
image := res.Body
|
||||
```
|
||||
|
||||
Calling a service API with a reusable client:
|
||||
|
||||
```go
|
||||
vendor, err := fetchguard.NewClient(fetchguard.Policy{
|
||||
Mode: fetchguard.AllowHostsMode,
|
||||
AllowHosts: []string{"api.example.com"},
|
||||
Timeout: 10 * time.Second,
|
||||
}, app.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", fetchguard.Bearer(token))
|
||||
res, err := vendor.PostJSON(ctx, "https://api.example.com/v1/items", header, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode == http.StatusTooManyRequests {
|
||||
wait := res.Header.Get("Retry-After")
|
||||
// ...
|
||||
}
|
||||
|
||||
// Multipart upload: fields first, then files, each in slice order.
|
||||
res, err = vendor.PostMultipart(ctx, "https://api.example.com/v1/files", header,
|
||||
[]fetchguard.FormField{{Name: "title", Value: "Report"}},
|
||||
[]fetchguard.FormFile{{Field: "file", Filename: "report.pdf", ContentType: "application/pdf", Body: f}},
|
||||
)
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `fetchguard.Fetch` | Validates the URL against the policy and performs the guarded HTTPS GET; a non-nil error is always a `fetchguard.Error`. |
|
||||
| `fetchguard.Policy` | Per-call settings: mode, allowed hosts, byte limit and timeout (zero means use the configured default). |
|
||||
| `fetchguard.Mode` | Selects `fetchguard.AllowHostsMode` or `fetchguard.PublicOnlyMode`. |
|
||||
| `fetchguard.Result` | Response body, Content-Type header value and status code of any completed response, including 3xx and non-2xx. |
|
||||
| `fetchguard.Mode` | Selects `fetchguard.AllowHostsMode`, `fetchguard.PublicOnlyMode` or `fetchguard.TrustedMode`. |
|
||||
| `fetchguard.TrustedMode` | Client mode for operator-configured endpoints: http and https, no host list, no dial guard; body cap and no redirects still apply. |
|
||||
| `fetchguard.Result` | Response body, Content-Type header value, status code and all headers (`Header`) of any completed response, including 3xx and non-2xx. |
|
||||
| `fetchguard.NewClient` | Resolves the policy's limits once and builds a reusable `fetchguard.Client`. |
|
||||
| `fetchguard.Client` | Guarded outbound client, safe for concurrent use; one per vendor keeps connections alive. |
|
||||
| `fetchguard.Client.Do` | Validates the request URL and sends it; the response body fails with `too_large` past the byte limit. The caller closes the body. |
|
||||
| `fetchguard.Client.Send` | `fetchguard.Client.Do` plus reading the capped body into a `fetchguard.Result`. |
|
||||
| `fetchguard.Client.Get` | Sends a GET with the given headers. |
|
||||
| `fetchguard.Client.PostJSON` | Marshals the body as JSON and POSTs it; `Content-Type: application/json` is set first, then the caller's headers are copied over it. |
|
||||
| `fetchguard.Client.PutJSON` | `fetchguard.Client.PostJSON` with the PUT method. |
|
||||
| `fetchguard.Client.PostMultipart` | POSTs a multipart/form-data body: fields, then files, in slice order. |
|
||||
| `fetchguard.FormField` | One plain multipart field (`Name`, `Value`). |
|
||||
| `fetchguard.FormFile` | One multipart file part (`Field`, `Filename`, `ContentType`, `Body`); the content type defaults to `application/octet-stream`. |
|
||||
| `fetchguard.Bearer` | Returns the Authorization value `Bearer <token>`. |
|
||||
| `fetchguard.WithTransport` | Returns a context whose client requests go to the given `http.RoundTripper`; the test and parity-replay seam, settable only from code. |
|
||||
| `fetchguard.Error` | Failure carrying a `fetchguard.Reason` and the underlying error for logging. |
|
||||
| `fetchguard.Reason` | Closed set of failure reasons (`invalid_url`, `scheme`, `unresolvable`, `private_ip`, `network_error`, `too_large`). |
|
||||
| `fetchguard.Defaults` | Framework fallback limits: 10 MiB and 10 seconds. |
|
||||
@@ -57,7 +105,7 @@ image := res.Body
|
||||
|
||||
## Configuration
|
||||
|
||||
`fetchguard.Fetch` and `fetchguard.DefaultsFromConfig` read these keys from the `compass.Config` passed to them. They apply only when the policy leaves the matching limit at zero, and an explicitly configured zero or negative value is an error.
|
||||
`fetchguard.Fetch`, `fetchguard.NewClient` and `fetchguard.DefaultsFromConfig` read these keys from the `compass.Config` passed to them. They apply only when the policy leaves the matching limit at zero, and an explicitly configured zero or negative value is an error.
|
||||
|
||||
| Key | Default | Controls |
|
||||
|-----|---------|----------|
|
||||
@@ -75,7 +123,7 @@ http:
|
||||
|
||||
- SummerCMS modules: [compass](../compass/README.md) (config lookup).
|
||||
- Third-party: none.
|
||||
- Standard library: `context`, `crypto/tls`, `errors`, `fmt`, `io`, `math`, `net`, `net/http`, `net/netip`, `net/url`, `strings`, `syscall`, `time`.
|
||||
- Standard library: `bytes`, `context`, `crypto/tls`, `encoding/json`, `errors`, `fmt`, `io`, `math`, `mime/multipart`, `net`, `net/http`, `net/netip`, `net/textproto`, `net/url`, `strings`, `syscall`, `time`.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -83,4 +131,4 @@ http:
|
||||
go test ./modules/fetchguard/...
|
||||
```
|
||||
|
||||
The tests run against local `net/http/httptest` TLS servers and cover the address classifier directly; they need no external services.
|
||||
The tests run against local `net/http/httptest` servers and cover the address classifier directly; they need no external services. To test code that calls a vendor through a `fetchguard.Client`, pass a context from `fetchguard.WithTransport` with a fake `http.RoundTripper`, such as the [tide](../tide/README.md) upstream fake that replays recorded vendor exchanges and asserts every request.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
54
modules/fetchguard/client_internal_test.go
Normal file
54
modules/fetchguard/client_internal_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package fetchguard
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClientNeverFollowsRedirects(t *testing.T) {
|
||||
handler := func(followed *atomic.Bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/target" {
|
||||
followed.Store(true)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/target", http.StatusFound)
|
||||
})
|
||||
}
|
||||
for _, mode := range []Mode{AllowHostsMode, PublicOnlyMode, TrustedMode} {
|
||||
var followed atomic.Bool
|
||||
var srv *httptest.Server
|
||||
policy := Policy{Mode: mode, AllowHosts: []string{"127.0.0.1"}, MaxBytes: 1024, Timeout: 5 * time.Second}
|
||||
if mode == TrustedMode {
|
||||
srv = httptest.NewServer(handler(&followed))
|
||||
} else {
|
||||
srv = httptest.NewTLSServer(handler(&followed))
|
||||
policy = withTestLoopback(srv, policy)
|
||||
}
|
||||
c, err := NewClient(policy, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := c.PostJSON(t.Context(), srv.URL+"/start", nil, map[string]int{"n": 1})
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("mode %d: %v", mode, err)
|
||||
}
|
||||
if res.StatusCode != http.StatusFound || res.Header.Get("Location") != "/target" {
|
||||
t.Fatalf("mode %d: status %d location %q, want the 302 itself", mode, res.StatusCode, res.Header.Get("Location"))
|
||||
}
|
||||
if followed.Load() {
|
||||
t.Fatalf("mode %d: redirect target was requested", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientNegativeLimitsUseDefaults(t *testing.T) {
|
||||
if _, err := NewClient(Policy{Mode: PublicOnlyMode, MaxBytes: -1, Timeout: -1}, nil); err != nil {
|
||||
// Negative limits fall back to defaults like zero does.
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package fetchguard_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -53,7 +56,7 @@ func TestClientPostJSONThroughUpstreamFake(t *testing.T) {
|
||||
}
|
||||
ctx := fetchguard.WithTransport(t.Context(), fake)
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", fetchguardBearer("example-token-value"))
|
||||
header.Set("Authorization", fetchguard.Bearer("example-token-value"))
|
||||
header.Set("Accept", "application/json")
|
||||
header.Set("User-Agent", "example-client/1.0")
|
||||
res, err := client.PostJSON(ctx, "https://api.example.test/v1/things?lang=en&mode=fast", header, map[string]any{
|
||||
@@ -81,8 +84,6 @@ func TestClientPostJSONThroughUpstreamFake(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchguardBearer(token string) string { return "Bearer " + token }
|
||||
|
||||
func TestTransportSeamIsCodeOnly(t *testing.T) {
|
||||
rtType := reflect.TypeFor[http.RoundTripper]()
|
||||
for _, typ := range []reflect.Type{reflect.TypeFor[fetchguard.Policy](), reflect.TypeFor[fetchguard.Client]()} {
|
||||
@@ -141,3 +142,217 @@ func TestTransportSeamIsCodeOnly(t *testing.T) {
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestClientModes(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Run("AllowHostsMode refuses a host outside the list", func(t *testing.T) {
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.AllowHostsMode, AllowHosts: []string{"api.example.test"}})
|
||||
_, err := c.Get(t.Context(), "https://other.example.test/", nil)
|
||||
if got := reasonOf(t, err); got != fetchguard.ReasonInvalidURL {
|
||||
t.Fatalf("reason = %s, want invalid_url", got)
|
||||
}
|
||||
})
|
||||
t.Run("PublicOnlyMode refuses loopback at dial", func(t *testing.T) {
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.PublicOnlyMode})
|
||||
_, err := c.Get(t.Context(), strings.Replace(srv.URL, "http://", "https://", 1), nil)
|
||||
if got := reasonOf(t, err); got != fetchguard.ReasonPrivateIP {
|
||||
t.Fatalf("reason = %s, want private_ip", got)
|
||||
}
|
||||
})
|
||||
t.Run("TrustedMode reaches an http loopback endpoint", func(t *testing.T) {
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.TrustedMode})
|
||||
res, err := c.Get(t.Context(), srv.URL+"/v1/models", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK || string(res.Body) != "ok" {
|
||||
t.Fatalf("result = %d %q", res.StatusCode, res.Body)
|
||||
}
|
||||
})
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("server hits = %d, want 1 (only the trusted call)", hits.Load())
|
||||
}
|
||||
t.Run("TrustedMode still refuses other schemes", func(t *testing.T) {
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.TrustedMode})
|
||||
_, err := c.Get(t.Context(), "ftp://127.0.0.1/x", nil)
|
||||
if got := reasonOf(t, err); got != fetchguard.ReasonScheme {
|
||||
t.Fatalf("reason = %s, want scheme", got)
|
||||
}
|
||||
})
|
||||
t.Run("Fetch ignores TrustedMode", func(t *testing.T) {
|
||||
_, err := fetchguard.Fetch(t.Context(), strings.Replace(srv.URL, "http://", "https://", 1), fetchguard.Policy{Mode: fetchguard.TrustedMode, Timeout: 2 * time.Second, MaxBytes: 1024}, nil)
|
||||
if got := reasonOf(t, err); got != fetchguard.ReasonPrivateIP {
|
||||
t.Fatalf("reason = %s, want private_ip", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientSchemeGuard(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits.Add(1) }))
|
||||
t.Cleanup(srv.Close)
|
||||
for _, p := range []fetchguard.Policy{
|
||||
{Mode: fetchguard.AllowHostsMode, AllowHosts: []string{"127.0.0.1"}},
|
||||
{Mode: fetchguard.PublicOnlyMode},
|
||||
} {
|
||||
c := mustClient(t, p)
|
||||
_, err := c.PostJSON(t.Context(), srv.URL, nil, map[string]string{"a": "b"})
|
||||
if got := reasonOf(t, err); got != fetchguard.ReasonScheme {
|
||||
t.Fatalf("mode %d: reason = %s, want scheme", p.Mode, got)
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(t.Context(), http.MethodDelete, srv.URL, nil)
|
||||
if _, err := c.Do(req); reasonOf(t, err) != fetchguard.ReasonScheme {
|
||||
t.Fatalf("mode %d: Do reason = %v, want scheme", p.Mode, err)
|
||||
}
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Fatal("http URL must not cause network I/O in a guarded mode")
|
||||
}
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.PublicOnlyMode})
|
||||
if _, err := c.Get(t.Context(), "not a url", nil); reasonOf(t, err) != fetchguard.ReasonInvalidURL {
|
||||
t.Fatalf("malformed URL: %v", err)
|
||||
}
|
||||
if _, err := c.Do(nil); reasonOf(t, err) != fetchguard.ReasonInvalidURL {
|
||||
t.Fatalf("nil request: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientMultipart(t *testing.T) {
|
||||
type part struct{ name, filename, ctype, body string }
|
||||
var got []part
|
||||
var gotAuth, gotCT string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(p)
|
||||
got = append(got, part{p.FormName(), p.FileName(), p.Header.Get("Content-Type"), string(b)})
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.TrustedMode})
|
||||
h := http.Header{}
|
||||
h.Set("Authorization", fetchguard.Bearer("tok"))
|
||||
h.Set("Content-Type", "application/json") // must not win over the boundary
|
||||
res, err := c.PostMultipart(t.Context(), srv.URL+"/upload", h,
|
||||
[]fetchguard.FormField{{Name: "title", Value: "Hello"}, {Name: "kind", Value: "bug"}},
|
||||
[]fetchguard.FormFile{
|
||||
{Field: "file", Filename: "shot.png", ContentType: "image/png", Body: strings.NewReader("\x89PNG")},
|
||||
{Field: "extra", Filename: `a"b.txt`, Body: strings.NewReader("text")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PostMultipart: %v", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("status = %d: %s", res.StatusCode, res.Body)
|
||||
}
|
||||
if gotAuth != "Bearer tok" || !strings.HasPrefix(gotCT, "multipart/form-data; boundary=") {
|
||||
t.Fatalf("auth %q, content type %q", gotAuth, gotCT)
|
||||
}
|
||||
want := []part{
|
||||
{"title", "", "", "Hello"},
|
||||
{"kind", "", "", "bug"},
|
||||
{"file", "shot.png", "image/png", "\x89PNG"},
|
||||
{"extra", `a"b.txt`, "application/octet-stream", "text"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parts = %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPutJSONHeaderOrder(t *testing.T) {
|
||||
var method, ct, body string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method, ct = r.Method, r.Header.Get("Content-Type")
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
body = string(b)
|
||||
w.Header().Set("Retry-After", "3")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.TrustedMode})
|
||||
res, err := c.PutJSON(t.Context(), srv.URL, http.Header{"content-type": {"application/vnd.example+json"}}, []int{1, 2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodPut || ct != "application/vnd.example+json" || body != "[1,2]" {
|
||||
t.Fatalf("got %s %q %q", method, ct, body)
|
||||
}
|
||||
if res.StatusCode != http.StatusTooManyRequests || res.Header.Get("Retry-After") != "3" {
|
||||
t.Fatalf("status %d, Retry-After %q: the status is returned, not judged", res.StatusCode, res.Header.Get("Retry-After"))
|
||||
}
|
||||
if _, err := c.PostJSON(t.Context(), srv.URL, nil, func() {}); reasonOf(t, err) != fetchguard.ReasonInvalidURL {
|
||||
t.Fatalf("unmarshalable body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBodyCap(t *testing.T) {
|
||||
const maxBytes = 64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := maxBytes
|
||||
if r.URL.Path == "/over" {
|
||||
n++
|
||||
}
|
||||
_, _ = w.Write([]byte(strings.Repeat("x", n)))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := mustClient(t, fetchguard.Policy{Mode: fetchguard.TrustedMode, MaxBytes: maxBytes})
|
||||
|
||||
res, err := c.Get(t.Context(), srv.URL+"/exact", nil)
|
||||
if err != nil || len(res.Body) != maxBytes {
|
||||
t.Fatalf("exact: %v, %d bytes", err, len(res.Body))
|
||||
}
|
||||
if _, err := c.Get(t.Context(), srv.URL+"/over", nil); reasonOf(t, err) != fetchguard.ReasonTooLarge {
|
||||
t.Fatalf("Send over cap: %v, want too_large", err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL+"/over", nil)
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if reasonOf(t, err) != fetchguard.ReasonTooLarge {
|
||||
t.Fatalf("Do body over cap: %v, want too_large", err)
|
||||
}
|
||||
if len(data) != maxBytes {
|
||||
t.Fatalf("read %d bytes before the cap error, want %d", len(data), maxBytes)
|
||||
}
|
||||
if n, err := resp.Body.Read(make([]byte, 8)); n != 0 || reasonOf(t, err) != fetchguard.ReasonTooLarge {
|
||||
t.Fatalf("read after cap = %d, %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustClient(t *testing.T, p fetchguard.Policy) *fetchguard.Client {
|
||||
t.Helper()
|
||||
if p.Timeout == 0 {
|
||||
p.Timeout = 5 * time.Second
|
||||
}
|
||||
c, err := fetchguard.NewClient(p, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/fetchguard"
|
||||
@@ -50,3 +53,44 @@ func ExampleFetch() {
|
||||
// invalid_url https://%zz
|
||||
// 10485760 10s
|
||||
}
|
||||
|
||||
// stubVendor stands in for a vendor API in the example; tests use the tide
|
||||
// upstream fake the same way.
|
||||
type stubVendor struct{}
|
||||
|
||||
func (stubVendor) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
body := `{"id":42}`
|
||||
if r.Header.Get("Authorization") != "Bearer example-token" {
|
||||
body = `{"error":"unauthorized"}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusCreated,
|
||||
Header: http.Header{"Content-Type": {"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ExampleClient_PostJSON() {
|
||||
client, err := fetchguard.NewClient(fetchguard.Policy{
|
||||
Mode: fetchguard.AllowHostsMode,
|
||||
AllowHosts: []string{"api.example.com"},
|
||||
Timeout: 10 * time.Second,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
header := http.Header{}
|
||||
header.Set("Authorization", fetchguard.Bearer("example-token"))
|
||||
|
||||
// Production code passes its own context; the example routes the call to
|
||||
// a stub instead of the network.
|
||||
ctx := fetchguard.WithTransport(context.Background(), stubVendor{})
|
||||
res, err := client.PostJSON(ctx, "https://api.example.com/v1/items", header, map[string]string{"name": "widget"})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println(res.StatusCode, string(res.Body))
|
||||
// Output:
|
||||
// 201 {"id":42}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func Fetch(ctx context.Context, rawURL string, policy Policy, cfg *compass.Confi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := newHTTPClient(newTransport(policy, timeout, false), timeout)
|
||||
client := newHTTPClient(newTransport(policy, timeout, false, true), timeout)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
@@ -86,18 +86,19 @@ func Fetch(ctx context.Context, rawURL string, policy Policy, cfg *compass.Confi
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newTransport builds the guarded transport for policy. keepAlives is off for
|
||||
// one-shot Fetch calls and on for a reusable Client; the dial Control runs on
|
||||
// every new connection either way.
|
||||
func newTransport(policy Policy, timeout time.Duration, keepAlives bool) *http.Transport {
|
||||
// newTransport builds the transport for policy. keepAlives is off for
|
||||
// one-shot Fetch calls and on for a reusable Client. When guard is set the
|
||||
// dial Control runs on every new connection.
|
||||
func newTransport(policy Policy, timeout time.Duration, keepAlives, guard bool) *http.Transport {
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
if guard {
|
||||
dialer.Control = dialControl(policy)
|
||||
}
|
||||
return &http.Transport{
|
||||
// User-supplied URLs must not be forwarded through HTTP_PROXY:
|
||||
// the dial-time IP check would then see the proxy, not the target.
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
Control: dialControl(policy),
|
||||
}).DialContext,
|
||||
Proxy: nil,
|
||||
DialContext: dialer.DialContext,
|
||||
TLSClientConfig: policy.tlsConfig,
|
||||
DisableKeepAlives: !keepAlives,
|
||||
ForceAttemptHTTP2: true,
|
||||
|
||||
@@ -9,13 +9,25 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// Mode selects host-allow-list vs any-public-host. The private/loopback/
|
||||
// reserved IP block is always on regardless of Mode (D-11).
|
||||
// Mode selects host-allow-list vs any-public-host. In AllowHostsMode and
|
||||
// PublicOnlyMode the URL must be https and the private/loopback/reserved IP
|
||||
// block runs at dial time (D-11). TrustedMode lifts both, for Client only.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// AllowHostsMode accepts only hosts listed in Policy.AllowHosts (exact or
|
||||
// dotted-suffix match).
|
||||
AllowHostsMode Mode = iota
|
||||
// PublicOnlyMode accepts any host that resolves to a public address.
|
||||
PublicOnlyMode
|
||||
// TrustedMode is for endpoints an operator configured in Go code or admin
|
||||
// settings, for example a model server on the local network. A Client in
|
||||
// this mode accepts http and https, checks no host list and runs no dial
|
||||
// guard; the response body is still capped and redirects are still never
|
||||
// followed. Never use it for a URL a user or a third party supplied. Only
|
||||
// Go code that builds the Policy can choose it; Fetch ignores it and
|
||||
// guards as PublicOnlyMode.
|
||||
TrustedMode
|
||||
)
|
||||
|
||||
// Reason is the closed set of Fetch failure reasons, matching PHP's
|
||||
|
||||
Reference in New Issue
Block a user