Redirect Hop Destination and Private-Network Exposure Gate avatar

Redirect Hop Destination and Private-Network Exposure Gate

Pricing

from $10.00 / 1,000 run_starts

Go to Apify Store
Redirect Hop Destination and Private-Network Exposure Gate

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

kingii98

Maintained by Community

Actor 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:

FieldMeaning
hop_index0 is the URL itself, 1 is the first redirect target
hop_urlthe absolute URL of this hop
methodHEAD, or GET when the server refused HEAD
status_codethe status this hop answered
locationthe raw Location header
next_urlthe Location header resolved against hop_url
resolved_ipsevery address the resolver answered, not only the first
ip_classpublic, private, loopback, link_local, carrier_nat, cloud_metadata, unique_local, multicast, unspecified, broadcast or reserved
metadata_endpointthe provider whose metadata service answers on that address
dns_rebindtrue when the DNS answer shows a rebind
connectedfalse when the hop was classified unsafe and was not sent
safefalse when the hop resolves outside the public class
gate_failingtrue when this hop makes the URL FAIL under your failOn rules
reason_codeOK, 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

ClassBlocks
loopback127.0.0.0/8, ::1
private10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
unique_localfc00::/7
link_local169.254.0.0/16, fe80::/10
carrier_nat100.64.0.0/10
cloud_metadata169.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)
reserveddocumentation, 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:

  1. 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.
  2. Second answer. The hostname answers public for the check and non-public for the next lookup. When dns_rebind is 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.

FieldDefaultMeaning
urlsfour public URLs1 to 200 absolute http or https URLs
domains[]bare hostnames, each walked as https://<hostname>/
maxHops10redirects 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
hopTimeoutSeconds10time one hop may take
concurrency5chains walked at one time
maxResponseBytes65536byte 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

verdictgateMeaning
passPASSevery hop resolved public, or the only findings are classes you did not select
failFAILa hop resolves into a class you selected in failOn
errorERRORthe chain could not be walked: DNS failure, timeout, refused connection, loop, or more hops than the limit
rejectedREJECTEDthe input entry is not a usable http or https URL
skippedSKIPPEDthe 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)

EventUnitCounted
run_startone runonce for each run, charged before any work, because the container start and the dataset init are already spent
url_scannedone URL with its full hop chainonce 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_findingone hop that resolves outside the public classonce 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 httpx opens the connection and resolves it again. A record that changes between those two moments is caught by the second lookup that dns_rebind performs, 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 sync
uv run pytest
uv run ruff check .