Redirect Hop Destination and Private-Network Exposure Gate
Pricing
from $10.00 / 1,000 run_starts
Redirect Hop Destination and Private-Network Exposure Gate
Prove that no hop of a redirect chain reaches your internal network. For each URL the Actor walks every hop with automatic redirects off, resolves the hostname of each hop, classifies every address the resolver answers (public, RFC1918 private, loopback,
Pricing
from $10.00 / 1,000 run_starts
Rating
0.0
(0)
Developer
kingii98
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
8 days ago
Last modified
Categories
Share
Prove, before you deploy, that no hop of a redirect chain reaches your internal network.
Your service fetches a URL that a user, a partner or an agent tool supplies. You validate that URL. Then you follow the redirect. CVE-2026-14540 in Google MCP Toolbox is exactly that mistake: the first URL passed the check, the redirect target did not, and no filter looked at the destination address.
This Actor walks every hop of every URL with automatic redirects off. For each hop it resolves the hostname before it sends the request, classifies every address the resolver answered, and refuses to open the connection when an address is outside the public class. The unsafe hop is therefore reported in full, and never reached.
What the other redirect Actors do not do
The Apify Store already holds redirect-chain tools. They report status codes, final URLs, phishing risk, insecure downgrades and tracking parameters. None of them classifies the IP address that each hop resolves to. The buyer who asks "does this link send a person somewhere bad" is served. The buyer who asks "does this link let an attacker reach my internal network" is not.
What it answers
For each URL, one url record with a PASS or FAIL flag and a reason
code, and one hop record for each step of the chain:
| Field | Meaning |
|---|---|
hop_index | 0 is the URL itself, 1 is the first redirect target |
hop_url | the absolute URL of this hop |
method | HEAD, or GET when the server refused HEAD |
status_code | the status this hop answered |
location | the raw Location header |
next_url | the Location header resolved against hop_url |
resolved_ips | every address the resolver answered, not only the first |
ip_class | public, private, loopback, link_local, carrier_nat, cloud_metadata, unique_local, multicast, unspecified, broadcast or reserved |
metadata_endpoint | the provider whose metadata service answers on that address |
dns_rebind | true when the DNS answer shows a rebind |
connected | false when the hop was classified unsafe and was not sent |
safe | false when the hop resolves outside the public class |
gate_failing | true when this hop makes the URL FAIL under your failOn rules |
reason_code | OK, PRIVATE_ADDRESS, LOOPBACK_ADDRESS, LINK_LOCAL_ADDRESS, CLOUD_METADATA_ADDRESS, CARRIER_NAT_ADDRESS, UNIQUE_LOCAL_ADDRESS, RESERVED_ADDRESS, DNS_REBIND_SPLIT_ANSWER, DNS_FAIL, TIMEOUT, CONNECT_FAIL, TOO_MANY_HOPS, REDIRECT_LOOP, BAD_LOCATION |
One run_summary record holds the run verdict, the URL counts by verdict and
the settings the gate ran with. A CI step can read that one record and stop the
build on gate == "FAIL".
Address classes
| Class | Blocks |
|---|---|
loopback | 127.0.0.0/8, ::1 |
private | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
unique_local | fc00::/7 |
link_local | 169.254.0.0/16, fe80::/10 |
carrier_nat | 100.64.0.0/10 |
cloud_metadata | 169.254.169.254 (AWS, Azure, Google Cloud, OpenStack), 169.254.170.2 (ECS), 169.254.170.23 (EKS Pod Identity), 169.254.169.253 (Google Cloud metadata DNS), fd00:ec2::254, 100.100.100.200 (Alibaba Cloud), 192.0.0.192 (Oracle Cloud) |
reserved | documentation, benchmark, multicast, unspecified, broadcast and future-use blocks |
A metadata address wins over the block that contains it: 169.254.169.254 is
reported as cloud_metadata, not as link_local, because that is the address
that leaks a role credential.
An IPv4 address written in IPv6 form is classified by the IPv4 address it
carries. ::ffff:127.0.0.1 and 2002:7f00:1::1 both read as loopback. An
attacker writes those forms because a naive filter reads them as IPv6 and finds
no IPv4 loopback block.
DNS rebind
dns_rebind reports two shapes of the same attack:
- Split answer. One DNS reply carries a public address and a non-public address together. A client that reads only the first entry sees nothing wrong; the next connection may take the other one.
- Second answer. The hostname answers public for the check and non-public
for the next lookup. When
dns_rebindis selected, the Actor resolves each public hop a second time after its request, and reports the hop when the second answer is not public.
Keep dns_rebind in failOn for the weekly allowlist re-check. That is the
run whose job is to catch a record that changed under a stable hostname.
Input
Every field carries a default, so a run with an empty input {} gates the
default URL list and succeeds.
| Field | Default | Meaning |
|---|---|---|
urls | four public URLs | 1 to 200 absolute http or https URLs |
domains | [] | bare hostnames, each walked as https://<hostname>/ |
maxHops | 10 | redirects followed for one URL |
followMethods | ["HEAD", "GET"] | methods used to read a hop, in order |
failOn | ["private", "loopback", "link_local", "metadata", "dns_rebind"] | the classes that make a URL FAIL |
hopTimeoutSeconds | 10 | time one hop may take |
concurrency | 5 | chains walked at one time |
maxResponseBytes | 65536 | byte cap on a body that is read and thrown away |
The urls list and the domains list are walked together, and the total is
limited to 200 targets for each run.
A class outside your failOn selection is still reported, and the hop is still
not connected to. Only the PASS or FAIL flag changes.
Verdicts
verdict | gate | Meaning |
|---|---|---|
pass | PASS | every hop resolved public, or the only findings are classes you did not select |
fail | FAIL | a hop resolves into a class you selected in failOn |
error | ERROR | the chain could not be walked: DNS failure, timeout, refused connection, loop, or more hops than the limit |
rejected | REJECTED | the input entry is not a usable http or https URL |
skipped | SKIPPED | the maximum charge of the run did not cover this URL |
A failed gate, an unreachable target and an input that breaks a bound are all results. The run still ends SUCCEEDED, and the verdict is in the dataset and in the status message. A FAILED run means this Actor malfunctioned.
Use it as a CI gate
Run the Actor on each pull request with the URL list your service accepts, then
read the one run_summary record:
curl -s "https://api.apify.com/v2/datasets/$DATASET_ID/items?filter=record_type%3Drun_summary" \| jq -e '.[0].gate == "PASS"'
jq -e exits non-zero when the gate failed, which stops the build. The
failedUrls and unsafeHops output links give the evidence for the comment
you leave on the pull request.
How to see a FAIL
The default input names only stable public endpoints, so it passes. To watch the gate fail, add a URL that names a non-public address:
{ "urls": ["http://169.254.169.254/latest/meta-data/", "http://10.0.0.1/","http://[::ffff:127.0.0.1]/"] }
Each one is reported with its class and its reason code, and none of them is connected to. A literal address costs no DNS query.
A hostname that resolves into a non-public block shows the same finding with
the DNS step included. Public wildcard resolvers such as nip.io and
sslip.io answer <address>.nip.io with that address. Be ready for a
DNS_FAIL instead: many resolvers hold DNS rebind protection, which drops a
non-public answer for a public name before this Actor ever reads it. That is
itself a useful signal, and it is reported as an error verdict, not as a
gate failure.
Billing (pay per event)
| Event | Unit | Counted |
|---|---|---|
run_start | one run | once for each run, charged before any work, because the container start and the dataset init are already spent |
url_scanned | one URL with its full hop chain | once for each URL whose chain was walked. An input entry that could not be read and a URL the charge limit did not cover are not charged |
unsafe_hop_finding | one hop that resolves outside the public class | once for each such hop, whether or not your failOn selection makes that class stop the build |
The run reads the charge limit before it starts and walks only the URLs the
limit covers. A URL that the limit leaves out gets a skipped record with the
reason code NOT_SCANNED_CHARGE_LIMIT, so no scan is ever done without pay and
no scan is ever paid for without a record.
Limits and honest edges
- HTTP only. No browser, no proxy, no paid API. A redirect written in
JavaScript or in a
<meta http-equiv="refresh">tag is not a hop, and this Actor does not see it. - Time of check, time of use. The Actor resolves a hop, then
httpxopens the connection and resolves it again. A record that changes between those two moments is caught by the second lookup thatdns_rebindperforms, and is reported; it is not prevented. Your own service needs address pinning at the socket, which no external scanner can do for it. - The run is a pure function of the network at run time. It keeps no state between runs. A hostname that is safe today can answer differently tomorrow, which is why the weekly re-check of an allowlist is a real job.
- The resolver is the container's resolver. A split-horizon DNS record answers differently inside your VPC. Run the gate where your service runs to read what your service would read.
Develop
uv syncuv run pytestuv run ruff check .