NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Safe Fetch for Laravel

Destination policy

DestinationPolicy is what the guard lets through on top of the blocked-network table. SafeFetch::policy() is the configured default; every wither returns a new policy, so a call can tighten or loosen it locally:

Http::safe(SafeFetch::policy()->allowHosts(['hooks.example.com'])->followRedirects())->post($url, $payload);

Withers

MethodDefaultDoes
allowHttp() · httpsOnly()['https']http as well, or https only.
ports(?array $ports)nullThe only ports a URL may use; null for any.
allowHosts(array $hosts)[]Replaces the strict allowlist (so it can widen a configured one) and refuses an empty list (InvalidArgumentException).
withoutHostAllowlist()—Lifts the allowlist — said explicitly.
blockHosts(array $hosts)[]Adds blocked host patterns.
trustPrivateHosts(array $hosts) · withoutPrivateHosts()[]Adds names whose addresses may be private · clears them.
allowRanges(array $ranges) · withoutAllowedRanges()[]Adds addresses or CIDRs exempt from the table · clears them.
blockRanges(array $ranges)[]Adds ranges that are always refused and beat every allow.
blockAppHosts(bool $block = true) · withoutAppHostBlock()trueThe app-host block on or off, over whatever the config says.
appHosts(array $hosts)app.url’s host + app_hosts.extraAdds names that mean the app.
redirects(RedirectPolicy $redirects) · followRedirects(int $max = 3)offSets the redirect policy · follows 307/308 up to $max hops.
maxResponseBytes(?int $bytes)nullHttp::safe() only: cut a response body at this many bytes (at least 1).

Withers that tighten compose freely; anything that loosens is a call that says so. allowHosts() refuses an empty list for that reason: a list that happens to be empty ($tenant->hosts ?? []) would otherwise allow every host. Branch on it, or call withoutHostAllowlist() when lifting the allowlist is what you mean.

Validation of the values

The constructor validates everything and throws InvalidArgumentException: schemes http and https only, at least one; ports 1–65535, at least one when a list; host entries are canonical names or *. plus a name — no bare *, no scheme, port, path or address (an address belongs in the ranges); ranges are an address or address/prefix; 0.0.0.0/0, ::/0 and every IPv6 prefix that carries all of IPv4 (::ffff:0:0/96, ::/96, 64:ff9b::/96, ::ffff:0:0:0/96, 2002::/16, or anything holding one, such as 2000::/3) are refused in the allowed ranges; a response cap of at least 1.

Host patterns

Exact names or *.example.com, matched in any case, one trailing dot dropped on both sides, an international name compared as punycode. *.example.com matches any subdomain — never example.com itself or badexample.com.

The order of the rules

First failure wins:

  • 1. Canonicalise the URL (see URLs and blocked networks).
  • 2. A scheme the policy does not allow → scheme.
  • 3. A port the policy does not allow → port.
  • 4. Name rules: a blocked host → blocked (blocked_host, before any lookup); one of the app’s hosts — or an address literal that is one of its addresses — while the block is on → blocked (app_host); a strict allowlist that does not list the host → not_allowed (an address literal is never on it); localhost or *.localhost not listed as a private host → blocked (hostname).
  • 5. Resolve the name once (an address literal is its own address); nothing → unresolved.
  • 6. Every address: in a blocked range → blocked (blocked_range); in the table and neither in an allowed range nor the name a private host → blocked (private_address). An answer that is no address at all is refused whatever the policy trusts.

SafeFetch::precheck() runs steps 1–4, and step 6 for an address literal, without step 5.

Block beats allow

  • An allowlisted host is still resolved, judged and pinned — “may be asked for”, never “may be private”.
  • A private host (minio.internal) is still resolved and pinned; only its addresses are exempt from the table (a localhost listed there is looked up too).
  • A private host never exempts a cloud-metadata address — all link-local (169.254.0.0/16 and fe80::/10: instance metadata, ECS task credentials at 169.254.170.2, EKS Pod Identity at 169.254.170.23, Tencent at 169.254.0.23), fd00:ec2::/32, 100.100.100.200, 192.0.0.192, 168.63.129.16, or the IPv4 one an IPv6 address carries. Only an allowed range names one back in.
  • Ranges match an address and the IPv4 address it carries (127.0.0.1/32 covers ::ffff:127.0.0.1), and an IPv6 range inside a prefix that carries IPv4 also covers the plain IPv4 addresses it carries (::ffff:8.8.8.0/120 covers 8.8.8.8). A blocked range that holds such a prefix whole (2002::/16, ::/0) blocks only its IPv6 forms, never every IPv4 address.

A policy from scratch

SafeFetch::policy() plus withers is the usual start — it carries your configuration. Both policies can also be built directly; every argument is named and validated:

new DestinationPolicy(
    array $schemes = ['https'], ?array $ports = null,
    array $allowedHosts = [], array $blockedHosts = [], array $privateHosts = [],
    array $allowedRanges = [], array $blockedRanges = [],
    bool $blockAppHosts = true, array $appHosts = [],
    RedirectPolicy $redirects = new RedirectPolicy, ?int $maxResponseBytes = null,
    array $appAddresses = [],
)

new RedirectPolicy(
    bool $enabled = false, int $max = 3, ?array $statuses = null,
    bool $allowDowngrade = false, array $forwardHeaders = [],
)
use RoundlyConsulting\SafeFetch\DataTransferObjects\DestinationPolicy;
use RoundlyConsulting\SafeFetch\DataTransferObjects\RedirectPolicy;

$policy = new DestinationPolicy(
    ports: [443],
    allowedHosts: ['*.partner.example'],
    blockedRanges: ['203.0.113.0/24'],
    redirects: RedirectPolicy::methodPreserving(2),
    maxResponseBytes: 65_536,
);

$redirects = new RedirectPolicy(enabled: true, max: 5, statuses: [301, 302, 307, 308], forwardHeaders: ['X-Signature']);

A policy built this way knows nothing of config/safe-fetch.php — not even the app’s own host names: blockAppHosts is on, but appHosts and appAddresses are empty until you pass them. The blocked-network table applies whatever the policy says.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.