Your Gateway Broke on v3.5.0: The Proxy Middleware's New SSRF Policy
You bump Fiber from v3.4.0 to v3.5.0, the tests pass, the deploy goes out, and
every request to /api/users comes back as a 500. The body of that response
tells anyone who asks exactly what went wrong:
proxy: upstream host resolves to a blocked address: localhost -> 127.0.0.1
In a Docker Compose or Kubernetes setup the hostname is users or
users.default.svc and the IP starts with 10. or 172., but the message is
the same. If the upstream in your proxy.Balancer config is an IP literal, you
do not even get that far: the process panics at startup with proxy: upstream host resolves to a blocked address: 127.0.0.1.
This is not a regression. Fiber v3.5.0 hardened the proxy middleware (#4405), and its new default policy rejects upstreams on loopback, private, link-local and similar addresses. That default is right for code that forwards requests to URLs it did not choose, and inconvenient for the most common use of the middleware, an API gateway in front of internal services. This post shows how to give each kind of proxying the policy it needs, with one gateway that does both, built and tested against Fiber v3.5.0.
What the Policy Protects Againstโ
Server-side request forgery, SSRF for short, means an attacker gets your server
to send a request on their behalf. The interesting targets are the ones only
your server can reach. On AWS, the instance metadata service answers on the
link-local address 169.254.169.254 and can hand out the credentials of the
instance role. Other targets are an admin panel bound to 127.0.0.1, a
database's HTTP interface on the private network, or an internal service that
trusts every request from inside the VPC.
A proxy is a natural tool for this, since forwarding requests is all it does. As
long as every upstream address comes from your own configuration, an attacker
has nothing to work with. The risk appears when some part of the address comes
from the request: an image proxy that loads ?url=..., a webhook tester, a link
preview, a per-tenant upstream that a customer can edit in a settings page.
proxy.DefaultSecurityPolicy() returns four settings, all on the strict side:
AllowedSchemesis["http", "https"]. Upstream URLs with any other scheme, such asfileorgopher, are rejected.AllowPrivateIPsisfalse. Upstreams that resolve to loopback, RFC 1918 private, link-local, multicast, unspecified or CGNAT addresses are rejected, and so are IPv6 unique local addresses and the IPv6 transition ranges that can carry an embedded IPv4 address.AllowHTTPSDowngradeisfalse.DoRedirectsrefuses to follow a redirect fromhttpsto plainhttp.KeepHopByHopHeadersisfalse.Connection,Transfer-Encoding,Upgradeand the other hop-by-hop headers are stripped in both directions.
The private-address check looks at what a hostname resolves to, not at how it is
spelled. localhost, a Docker service name and an IPv4-mapped IPv6 literal like
[::ffff:127.0.0.1] all end up as the same blocked loopback address. The check
also fails closed: when the DNS lookup fails, the upstream counts as blocked,
and the error is ErrUpstreamHostBlocked with the lookup error wrapped inside.
Where the Check Runs Depends on the Helperโ
The middleware has two families of entry points, and they check at different
moments. That is why the same misconfiguration shows up as a startup panic in
one service and as a runtime 500 in another.
proxy.Balancer checks IP literals when it is constructed and panics on a
blocked one. It does not resolve hostnames at startup. Instead, it installs a
dialer on each upstream client that resolves the name and checks every returned
address whenever it opens a new connection. Checking at dial time is what
defeats DNS rebinding, where a hostname resolves to a public address when it is
validated and to a private one when it is used. It is also why Servers: []string{"http://localhost:8081"} starts without complaint and fails on the
first request.
The runtime helpers Do, Forward, DoRedirects, DoTimeout and DoDeadline
check the target on every call, which includes a DNS lookup for hostnames, and
DoRedirects checks every redirect target before following it.
Forward("http://127.0.0.1:8081") therefore constructs fine and returns a 500
per request. DomainForward and BalancerForward do both: they check their
configured upstream at construction, resolving hostnames, and panic if it is
blocked, and they check again on every request. In a Compose file where the
gateway starts before the users container,
BalancerForward([]string{"http://users:8081"}) panics with users lookup failed because the name does not resolve yet.
The difference that decides how you fix things is where the policy comes from. A
Balancer takes Config.SecurityPolicy if you set it, and otherwise copies the
package-level policy when it is constructed. Every other helper has no
config struct and reads the package-level policy, which you change with
proxy.WithSecurityPolicy, on each request. So that policy is a setting for the
whole process.
In the v3.5.0 source, the clients the runtime helpers send through also get the
dial-time check, installed through fasthttp's ConfigureClient hook. The
caution box on the docs page still describes these helpers as unguarded at dial
time. If the difference matters for your threat model, read
middleware/proxy/security.go of the version you actually run.
The Tempting Fixโ
The quickest way to make the 500s go away is a single line in main:
// Don't do this in a process that also proxies user-supplied URLs.
proxy.WithSecurityPolicy(proxy.SecurityPolicy{AllowPrivateIPs: true})
Every Balancer constructed after that call, and without a policy of its own,
accepts private upstreams again. So does every runtime helper in the process,
including any handler that forwards to a URL from the request. I tried it
against the gateway below: after that call, a request to
/img?url=http://localhost:8081/api/users/7 went straight through to the
internal users service, and only the content-type check further down kept the
JSON away from the caller. A plain proxy.Forward to the same address returned
the user record outright. The proxy docs use
WithSecurityPolicy this way for local development, which is fine for a
throwaway dev server and wrong for anything that also fetches what a user names.
A Balancer only ever talks to the addresses in Config.Servers, and nothing
in the request can change them. If those addresses come from your own
configuration, allowing private IPs for that one balancer costs nothing. The
runtime helpers are where request data can reach the upstream address, and that
is where the strict default belongs.
One Gateway, Two Policiesโ
The gateway below forwards /api/users/* to an internal users service. It also
offers /img, an image proxy that loads a picture from a URL in the query
string, which is how chat apps and forums show remote images without exposing
their users' IP addresses to every image host. Both run in one process, so the
package-level policy would affect both. The example needs Go 1.25 or newer, the
minimum for Fiber v3.5.0.
package main
import (
"errors"
"iter"
"log"
"mime"
"net"
"net/url"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/proxy"
"github.com/valyala/fasthttp"
)
// Response types the image proxy passes through. SVG is missing on purpose:
// it can carry scripts.
var imageTypes = map[string]bool{
"image/avif": true,
"image/gif": true,
"image/jpeg": true,
"image/png": true,
"image/webp": true,
}
// internalPolicy relaxes the SSRF check for one balancer whose upstreams come
// from our own configuration. Everything else keeps the package default.
func internalPolicy() *proxy.SecurityPolicy {
p := proxy.DefaultSecurityPolicy()
p.AllowPrivateIPs = true
return &p
}
// newImageClient returns the dedicated client for fetching user-supplied URLs.
// Use it only here, so that no connection pool predates the SSRF guard.
func newImageClient() *fasthttp.Client {
return &fasthttp.Client{
NoDefaultUserAgentHeader: true,
DisablePathNormalizing: true,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
MaxResponseBodySize: 5 << 20, // 5 MiB
MaxConnsPerHost: 64,
}
}
func newGateway(usersUpstream string, imageClient *fasthttp.Client) *fiber.App {
app := fiber.New(fiber.Config{ErrorHandler: errorHandler})
users := proxy.Balancer(proxy.Config{
Servers: []string{usersUpstream},
SecurityPolicy: internalPolicy(),
Timeout: 2 * time.Second,
// Balancer does not set X-Real-IP like Forward does. Overwrite it so
// clients cannot spoof the address the users service sees.
ModifyRequest: func(c fiber.Ctx) error {
ip := c.IP()
c.Request().Header.Del("X-Real-IP")
c.Request().Header.Add("X-Real-IP", ip)
return nil
},
})
app.Use("/api/users", func(c fiber.Ctx) error {
err := users(c)
switch {
case err == nil:
return nil
case errors.Is(err, fasthttp.ErrTimeout),
errors.Is(err, fasthttp.ErrDialTimeout):
log.Printf("users upstream: %v", err)
return fiber.ErrGatewayTimeout
default:
log.Printf("users upstream: %v", err)
return fiber.ErrBadGateway
}
})
app.Get("/img", imageProxy(imageClient))
return app
}
func imageProxy(client *fasthttp.Client) fiber.Handler {
return func(c fiber.Ctx) error {
target := c.Query("url")
u, err := url.Parse(target)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fiber.NewError(fiber.StatusBadRequest,
"url must be an absolute http(s) URL")
}
// An image URL has no business carrying credentials, and refusing
// them here keeps them out of the logs below.
if u.User != nil {
return fiber.NewError(fiber.StatusBadRequest,
"url must not contain credentials")
}
// Log scheme, host and path only: signed URLs carry their token in
// the query string.
logTarget := u.Scheme + "://" + u.Host + u.Path
// The incoming request is reused as the outgoing one, so drop
// everything the third party has no business seeing: cookies,
// Authorization, forwarding headers.
keepOnly(&c.Request().Header, fiber.HeaderAccept,
fiber.HeaderIfNoneMatch, fiber.HeaderIfModifiedSince)
err = proxy.DoRedirects(c, target, 3, client)
if err != nil {
c.Response().Reset()
var dnsErr *net.DNSError
switch {
case errors.As(err, &dnsErr):
log.Printf("img: resolve %q: %v", logTarget, err)
if dnsErr.IsNotFound {
return fiber.NewError(fiber.StatusBadRequest,
"url host does not resolve")
}
return fiber.ErrBadGateway // resolver timeout or failure
case isPolicyError(err):
log.Printf("img: refused %q: %v", logTarget, err)
return fiber.NewError(fiber.StatusBadRequest, "url not allowed")
}
log.Printf("img: fetch %q: %v", logTarget, err)
return fiber.ErrBadGateway
}
res := c.Response()
keepOnly(&res.Header,
fiber.HeaderContentType, fiber.HeaderContentLength,
fiber.HeaderContentEncoding, fiber.HeaderETag,
fiber.HeaderLastModified, fiber.HeaderCacheControl,
fiber.HeaderVary)
switch res.StatusCode() {
case fiber.StatusOK:
case fiber.StatusNotModified:
return nil
default:
log.Printf("img: %q answered %d", logTarget, res.StatusCode())
res.Reset()
return fiber.ErrBadGateway
}
contentType := string(res.Header.ContentType())
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || !imageTypes[mediaType] {
log.Printf("img: %q is %q, not an image", logTarget, contentType)
res.Reset()
return fiber.NewError(fiber.StatusBadGateway,
"upstream did not return a supported image")
}
c.Set(fiber.HeaderXContentTypeOptions, "nosniff")
c.Set(fiber.HeaderContentSecurityPolicy, "default-src 'none'; sandbox")
return nil
}
}
func isPolicyError(err error) bool {
return errors.Is(err, proxy.ErrUpstreamHostBlocked) ||
errors.Is(err, proxy.ErrUpstreamSchemeNotAllowed) ||
errors.Is(err, proxy.ErrUpstreamHostInvalid) ||
errors.Is(err, proxy.ErrRedirectDowngrade)
}
// keepOnly deletes every header except the allowed ones. It collects the
// names first because deleting while iterating the header store is not safe.
func keepOnly(h interface {
All() iter.Seq2[[]byte, []byte]
Del(key string)
}, allowed ...string,
) {
var drop []string
for k := range h.All() {
name := string(k)
keep := false
for _, a := range allowed {
if strings.EqualFold(name, a) {
keep = true
break
}
}
if !keep {
drop = append(drop, name)
}
}
for _, name := range drop {
h.Del(name)
}
}
func errorHandler(c fiber.Ctx, err error) error {
var fe *fiber.Error
if errors.As(err, &fe) {
return c.Status(fe.Code).JSON(fiber.Map{"error": fe.Message})
}
log.Printf("%s %s: %v", c.Method(), c.Path(), err)
return c.Status(fiber.StatusInternalServerError).
JSON(fiber.Map{"error": "internal error"})
}
func main() {
usersUpstream := os.Getenv("USERS_UPSTREAM")
if usersUpstream == "" {
usersUpstream = "http://127.0.0.1:8081"
}
app := newGateway(usersUpstream, newImageClient())
log.Fatal(app.Listen(":3000"))
}
internalPolicy starts from proxy.DefaultSecurityPolicy() and changes only
AllowPrivateIPs, so the scheme allowlist and the hop-by-hop stripping stay on
for the internal route. Building on the default instead of writing a
SecurityPolicy literal also means that a field added in a later release starts
with its safe value. Nothing touches the package-level policy, so /img keeps
the strict settings.
If your internal routes currently use Forward, BalancerForward or
DomainForward, moving them to Balancer is the only way to give them their
own policy, and the move changes three things. Balancer does not overwrite
X-Real-IP the way those helpers do, which is why the example sets it in
ModifyRequest; without that, a client could tell the users service any address
it likes. Balancer uses only the host and port of each entry in Servers and
ignores a path, so http://users:8081/v1 no longer adds the /v1 prefix. And
its Timeout defaults to one second, which may be shorter than what you had.
The wrapper around the balancer is the other half of the upgrade fix. Fiber's
default error handler writes err.Error() into the response, and that is how
the 500 at the top of this post published the gateway's upstream hostname and
IP address. The wrapper logs the error and answers with 502 Bad Gateway, or
with 504 Gateway Timeout when fasthttp reports a response or connect timeout.
With the users service stalled for longer than the two-second Timeout, a
request to /api/users/slow returns after two seconds with {"error":"Gateway Timeout"} and nothing about the network behind the gateway.
Start a users service on 127.0.0.1:8081, run the gateway, and the internal
route works as it did on v3.4.0:
curl -i http://localhost:3000/api/users/42
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 24
{"id":"42","name":"Ada"}
The image proxy, still on the default policy, refuses everything that points inward:
curl -i "http://localhost:3000/img?url=http://169.254.169.254/latest/meta-data/"
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Content-Length: 27
{"error":"url not allowed"}
http://localhost:8081/api/users/1 and http://[::ffff:127.0.0.1]:8081/ get
the same answer. http://0x7f000001:8081/, a hex spelling of 127.0.0.1 that
browsers accept, gets {"error":"url host does not resolve"}. The server log
has the details:
img: refused "http://169.254.169.254/latest/meta-data/": proxy: upstream host resolves to a blocked address: 169.254.169.254
img: refused "http://localhost:8081/api/users/1": proxy: upstream host resolves to a blocked address: localhost -> 127.0.0.1
img: refused "http://[::ffff:127.0.0.1]:8081/": proxy: upstream host resolves to a blocked address: 127.0.0.1
img: resolve "http://0x7f000001:8081/": proxy: upstream host resolves to a blocked address: 0x7f000001 lookup failed: lookup 0x7f000001 on 8.8.8.8:53: no such host
The hex address is blocked because Go's resolver on Linux finds no host by that
name, not because it is recognized as loopback. The system resolver on other
platforms may accept it and return 127.0.0.1, which the check then rejects as
loopback. Either way, the request does not go out.
Because a failed lookup is reported as ErrUpstreamHostBlocked, the handler
first asks errors.As for a *net.DNSError. A name that does not exist,
IsNotFound in Go's terms, is the user's mistake and gets a 400 with a
message they can act on. A resolver timeout or any other DNS failure is the
gateway's problem and becomes a 502, so clients know a retry may work. The
four policy errors become 400, since they mean "we will not fetch that".
Everything else, from connection refused to an oversized body, is the remote
side's fault and becomes a 502. The handler also refuses URLs with credentials
in them, such as https://alice:[email protected]/a.png, before it does
anything else. No image URL needs them, and refusing them means they never reach
a log line. The query string is the other place where secrets hide, since signed
image URLs carry their token there, so the log lines only print scheme, host and
path. The example accepts plain http because putting old http images on
https pages is one of the main reasons image proxies exist. If you can do
without that, accept only https: then the gateway never sends a signed URL to
the image host in clear text.
What the Policy Does Not Coverโ
The SSRF check decides which hosts the proxy may talk to. It has no say over what the proxy sends there or passes back, and with user-supplied URLs both matter.
Request and Response Headersโ
proxy.Do and its siblings do not build a new request. They take the incoming
request, point it at the upstream URL, strip the hop-by-hop headers and send it.
Everything else the browser sent to your domain goes along: the session cookie,
an Authorization header, whatever your frontend adds. DoRedirects removes
Cookie and Authorization when a redirect crosses to another host, but the
first request carries all of it. For an internal service that is usually what
you want. For http://203.0.113.10/cat.png, it hands your user's session to
whoever runs that host.
The response has the mirror problem. The remote host's headers reach your client
as if your domain had sent them, so a Set-Cookie from the image host lands in
the cookie jar for your domain.
keepOnly handles both directions with an allowlist: Accept and the two
conditional headers go out, and seven headers come back. Vary is one of them,
because the request forwards Accept: an upstream that picks the image format
from Accept says so in Vary, and without it a cache in front of the gateway
could hand an AVIF to a client that asked for PNG. I prefer an allowlist over
deleting Cookie and Authorization by name, because the next custom header
your frontend adds should not leak just because nobody updated a list.
keepOnly deletes each name exactly as the header store holds it, so it also
works with DisableHeaderNormalizing. Dropping User-Agent has a cost: some
image hosts answer requests without one with a 403. If yours do, send a fixed
User-Agent of your own instead of forwarding the browser's.
Untrusted Content on Your Originโ
Whatever /img returns is served from your domain. If a user can make it return
text/html, they get a page on your origin. The handler therefore checks the
media type and passes only the raster formats in imageTypes. The nosniff
header stops browsers from guessing a different type, and the sandbox content
security policy limits the damage if something slips through anyway.
Error paths need care too. Once a fetch got as far as a response, for example on
a redirect hop or a body that was too large, c.Response() already holds the
remote status, headers and body. Returning an error does not clear them: the
error handler sets a new status and body, and the remote headers stay. That is
why every error path in imageProxy calls Reset() on the response first.
Size and Timeโ
A user who can name a URL can name a 10 GB file or a server that sends one byte
per minute. MaxResponseBodySize caps the body at 5 MiB, which still means up
to 5 MiB in memory per request, and ReadTimeout bounds each response including
its body. DoRedirects gets a limit of three hops, and each hop goes through
the same check, which stops the old trick of a public URL that redirects to
169.254.169.254.
There is no overall deadline, though. DoRedirects has no timeout variant, the
policy allows up to five seconds for each DNS lookup, and every hop gets its own
read timeout, so one request can take well over the five seconds the client
config suggests. In production, I would put a concurrency limit and a rate
limiter in front of /img, and cache the results, so that
slow or abusive URLs cannot tie up the gateway.
Clients and Dialersโ
The image proxy gets its own *fasthttp.Client. A client passed to a helper as
the optional argument receives the dial-time check on first use. fasthttp keeps
one connection pool per host inside each client, and a pool created before the
check was installed keeps its unchecked dialer. A client created for this one
handler has no such pools, so every connection it makes is checked. The
alternative is proxy.WithClient, which replaces the client for every helper in
the process.
Two more limits follow from how the dial check works. It validates the address
and then calls your client's Dial, if you set one, with that address. A custom
Dial that connects somewhere else, such as through an egress proxy, makes the
check meaningless. The validation itself covers IPv6: every resolved address,
IPv4 or IPv6, has to pass. The dialer is a separate step, and unless
DialDualStack is set, it only tries the IPv4 addresses that passed and skips
the IPv6 ones, so an IPv6-only host is unreachable. Both behaviors come straight
from security.go in v3.5.0.
Prove It With a Testโ
Protections like this one tend to disappear quietly in a refactor: someone calls
WithSecurityPolicy to fix a local setup, or swaps the image client for a
shared one. A test catches that. The obstacle is that the test needs a "public"
upstream, and everything a test can start listens on loopback, which the policy
blocks.
The custom Dial behavior from the previous section is the way out. The test
points the image proxy at 203.0.113.10, an address reserved for documentation
that the policy treats as public, and gives the client a Dial that connects to
the local test server instead. Save it as main_test.go next to main.go and
run go test -race:
package main
import (
"io"
"net"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
)
// startUpstream runs app on a loopback listener and returns its address.
func startUpstream(t *testing.T, app *fiber.App) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() {
_ = app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true})
}()
t.Cleanup(func() { _ = app.Shutdown() })
return ln.Addr().String()
}
// pretendPublic returns an image client whose connections all land on addr.
// The SSRF guard still validates the IP from the URL (203.0.113.10 is a
// public documentation address) before it calls this Dial.
func pretendPublic(addr string) *fasthttp.Client {
c := newImageClient()
c.Dial = func(string) (net.Conn, error) { return net.Dial("tcp", addr) }
return c
}
func TestGateway(t *testing.T) {
var seenCookie atomic.Value // written by the remote server's goroutine
seenCookie.Store("")
remote := fiber.New()
remote.Get("/cat.png", func(c fiber.Ctx) error {
seenCookie.Store(c.Get(fiber.HeaderCookie))
c.Cookie(&fiber.Cookie{Name: "tracker", Value: "1"})
c.Set(fiber.HeaderContentType, "image/png")
return c.Send([]byte("\x89PNG fake"))
})
remote.Get("/page", func(c fiber.Ctx) error {
return c.Type("html").SendString("<script>alert(1)</script>")
})
remote.Get("/bounce", func(c fiber.Ctx) error {
return c.Redirect().To("http://169.254.169.254/latest/meta-data/")
})
users := fiber.New()
users.Get("/api/users/:id", func(c fiber.Ctx) error {
if ip := c.Get("X-Real-IP"); ip == "198.51.100.7" {
t.Errorf("users service saw spoofed X-Real-IP %q", ip)
}
return c.JSON(fiber.Map{"id": c.Params("id")})
})
usersAddr := startUpstream(t, users)
remoteAddr := startUpstream(t, remote)
app := newGateway("http://"+usersAddr, pretendPublic(remoteAddr))
tests := []struct {
path string
want int
}{
{"/api/users/42", fiber.StatusOK},
{"/img?url=http://203.0.113.10/cat.png", fiber.StatusOK},
{"/img?url=http://203.0.113.10/page", fiber.StatusBadGateway},
{"/img?url=http://203.0.113.10/bounce", fiber.StatusBadRequest},
{"/img?url=http://169.254.169.254/latest/", fiber.StatusBadRequest},
{"/img?url=http://localhost:8081/", fiber.StatusBadRequest},
}
for _, tt := range tests {
req := httptest.NewRequest(fiber.MethodGet, tt.path, nil)
req.Header.Set(fiber.HeaderCookie, "session=secret")
req.Header.Set("X-Real-IP", "198.51.100.7")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("%s: %v", tt.path, err)
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != tt.want {
t.Errorf("%s: status %d, want %d (%s)",
tt.path, resp.StatusCode, tt.want, body)
}
leaked := resp.Header.Get(fiber.HeaderSetCookie)
if tt.want == fiber.StatusOK && leaked != "" {
t.Errorf("%s: leaked Set-Cookie %q", tt.path, leaked)
}
}
if got := seenCookie.Load().(string); got != "" {
t.Errorf("remote host received Cookie %q", got)
}
}
The test checks that the internal route reaches a loopback upstream and
overwrites a spoofed X-Real-IP, that a real image passes and an HTML page does
not, that a public URL redirecting to the metadata address is refused, and that
cookies flow in neither direction. To see whether it catches regressions, I
commented out the two keepOnly calls, and it failed on the cookie checks.
Without the X-Real-IP lines in ModifyRequest, it failed on the spoofed
address.
Trade-offsโ
The strict policy costs DNS lookups. With it, every call to Do, Forward,
DomainForward or BalancerForward resolves the upstream hostname before the
request goes out, and every new connection resolves it again in the dialer. Go
does not cache DNS answers in the process, so this load goes to your resolver.
For an image proxy that is small next to the remote fetch. With
AllowPrivateIPs set, the validation skips the lookup.
If a single Forward to an internal service is all your process does with the
proxy middleware, setting the package-level policy is acceptable, as long as
everyone who later adds a user-facing helper knows it is there. If you pass your
own *fasthttp.LBClient as Config.Client, the balancer installs no check at
all, because it does not build the connections. And if you proxy user-supplied
URLs at any scale, an egress firewall that blocks the metadata address and your
private ranges still matters, as does IMDSv2 on AWS. A check in application code
protects only the code paths that go through it.
Wrapping Upโ
For a gateway in front of internal services, set Config.SecurityPolicy on each
Balancer, built from proxy.DefaultSecurityPolicy() with only
AllowPrivateIPs changed, and leave the package-level policy strict. Map
upstream errors to 502 and 504 yourself so the error body does not describe
your network. When the URL comes from a user, the policy is only the first step:
decide which headers leave and which come back, which content types you serve
from your origin, and how large and slow a response may be.
