# Redirect Hop Destination and Private-Network Exposure Gate (`kingii98/redirect-hop-destination-and-private-network-exposure-gate`) Actor

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,

- **URL**: https://apify.com/kingii98/redirect-hop-destination-and-private-network-exposure-gate.md
- **Developed by:** [kingii98](https://apify.com/kingii98) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 run\_starts

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Redirect Hop Destination and Private-Network Exposure Gate

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:

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.

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

```bash
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:

```json
{ "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 `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

```bash
uv sync
uv run pytest
uv run ruff check .
```

# Actor input Schema

## `urls` (type: `array`):

1 to 200 absolute http or https URLs. Each URL is walked hop by hop with automatic redirects off. The hostname of every hop is resolved before the hop is sent, and a hop that resolves outside the public address class is reported and is NOT connected to. A URL with credentials in it is refused and gets its own row. Give the URLs your service accepts from a user, a partner or an agent tool.

## `domains` (type: `array`):

Hostnames without a scheme, for example 'example.com'. Each one is walked as https://<hostname>/. Use this field for an allowlist that you re-check every week, because the DNS records under a stable hostname change while the hostname does not. The urls list and this list are walked together, and the total of both is limited to 200 targets for each run.

## `maxHops` (type: `integer`):

How many redirects the Actor follows for one URL. Hop 0 is the URL itself, so a limit of 10 walks the URL and up to 10 redirect targets. A chain that is still redirecting at the limit ends with the reason code TOO\_MANY\_HOPS and a verdict of error.

## `followMethods` (type: `array`):

The HTTP methods used to read a hop, in order. HEAD is tried first because it costs no body. A server that answers 400, 403, 404, 405 or 501 to a HEAD request is asked again with GET, which is what a redirect behind a HEAD-hostile server needs.

## `failOn` (type: `array`):

A hop that resolves into one of the selected classes makes the URL FAIL. 'private' covers RFC1918 and IPv6 unique-local. 'loopback' covers 127.0.0.0/8 and ::1. 'link\_local' covers 169.254.0.0/16 and fe80::/10. 'metadata' covers the instance metadata endpoints of AWS, Azure, Google Cloud, Alibaba Cloud and Oracle Cloud. 'dns\_rebind' fails a hop whose DNS answer mixes a public and a non-public address, or whose second lookup answers with a non-public address after the first answered public. 'carrier\_nat' covers 100.64.0.0/10 and 'reserved' covers the documentation, benchmark, multicast and future-use blocks. A hop outside the public class is always reported, even when its class is not selected here.

## `hopTimeoutSeconds` (type: `integer`):

The time one hop may take, for the DNS lookup and for the HTTP request. A hop that runs out of time ends the chain with the reason code TIMEOUT and a verdict of error.

## `concurrency` (type: `integer`):

How many chains are walked in parallel. Each chain is still walked hop by hop, one hop after the other.

## `maxResponseBytes` (type: `integer`):

The gate reads the status line and the Location header of a hop, never the page. The body of a final answer is read against this cap and thrown away, so a large download cannot fill the run.

## Actor input object example

```json
{
  "urls": [
    "http://github.com/",
    "http://apify.com/",
    "https://wikipedia.org/",
    "http://www.iana.org/"
  ],
  "domains": [],
  "maxHops": 10,
  "followMethods": [
    "HEAD",
    "GET"
  ],
  "failOn": [
    "private",
    "loopback",
    "link_local",
    "metadata",
    "dns_rebind"
  ],
  "hopTimeoutSeconds": 10,
  "concurrency": 5,
  "maxResponseBytes": 65536
}
```

# Actor output Schema

## `dataset` (type: `string`):

One run-summary record, one record for each URL with its PASS or FAIL flag and reason code, and one record for each hop with the hop index, the status code, the Location header, the resolved addresses and the address class.

## `failedUrls` (type: `string`):

The URL records whose chain reaches a private, loopback, link-local or cloud-metadata address, or whose DNS answer shows a rebind. These are the records a CI gate stops the build on.

## `unsafeHops` (type: `string`):

The hop records that resolve outside the public address class, with the address, the class and the reason code that names the block.

## `runSummary` (type: `string`):

The single run-summary record: the gate flag, the URL counts by verdict, the unsafe hop count and the settings the gate ran with.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "urls": [
        "http://github.com/",
        "http://apify.com/",
        "https://wikipedia.org/",
        "http://www.iana.org/"
    ],
    "domains": [],
    "maxHops": 10,
    "followMethods": [
        "HEAD",
        "GET"
    ],
    "failOn": [
        "private",
        "loopback",
        "link_local",
        "metadata",
        "dns_rebind"
    ],
    "hopTimeoutSeconds": 10,
    "concurrency": 5,
    "maxResponseBytes": 65536
};

// Run the Actor and wait for it to finish
const run = await client.actor("kingii98/redirect-hop-destination-and-private-network-exposure-gate").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "urls": [
        "http://github.com/",
        "http://apify.com/",
        "https://wikipedia.org/",
        "http://www.iana.org/",
    ],
    "domains": [],
    "maxHops": 10,
    "followMethods": [
        "HEAD",
        "GET",
    ],
    "failOn": [
        "private",
        "loopback",
        "link_local",
        "metadata",
        "dns_rebind",
    ],
    "hopTimeoutSeconds": 10,
    "concurrency": 5,
    "maxResponseBytes": 65536,
}

# Run the Actor and wait for it to finish
run = client.actor("kingii98/redirect-hop-destination-and-private-network-exposure-gate").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "urls": [
    "http://github.com/",
    "http://apify.com/",
    "https://wikipedia.org/",
    "http://www.iana.org/"
  ],
  "domains": [],
  "maxHops": 10,
  "followMethods": [
    "HEAD",
    "GET"
  ],
  "failOn": [
    "private",
    "loopback",
    "link_local",
    "metadata",
    "dns_rebind"
  ],
  "hopTimeoutSeconds": 10,
  "concurrency": 5,
  "maxResponseBytes": 65536
}' |
apify call kingii98/redirect-hop-destination-and-private-network-exposure-gate --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,kingii98/redirect-hop-destination-and-private-network-exposure-gate"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/iA3jdyJBI0ACKR2Z0/builds/QJ3WcxeO1KbKCZ9Sf/openapi.json
