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:
Jakub Zych
2026-10-03 19:42:37 +02:00
parent 93b7142059
commit e6a67134d1
10 changed files with 543 additions and 38 deletions

View File

@@ -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).

View File

@@ -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