# IP reputation / dynamic denylist feed

DaloyJS ships `ipReputation()`. Where `ipRestriction()` enforces a *static* allow/deny list compiled once at startup, `ipReputation()` wires **pluggable, periodically-refreshed abuse feeds** (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intelligence) into the request path without a redeploy.

- Pluggable feeds: any source that yields IP / CIDR strings. `urlFeed()` ships for the common case (fetch a newline / Spamhaus-DROP-style list over HTTP).
- Periodic refresh: the denylist reloads on an `unref`'d timer so stale ranges expire and new ones are picked up automatically.
- Fail-open: a denylist is additive defense, never the only gate. If a feed can't be loaded (initial or refresh), traffic is *not* blocked: the last-known-good list is retained. A feed outage never takes your app down.

It reuses the same SSRF-grade CIDR matcher as `ipRestriction()`, is dependency-free, and runs on every supported runtime.

**Diagram: Request against the denylist**

- **Resolve client IP** (request) - socket value, or X-Forwarded-For when trustProxyHeaders
- **Match the compiled CIDR set** - same SSRF-grade matcher as ipRestriction()
- **Listed -> block** - log mode never blocks. Only fires onMatch
- **Not listed / unresolved IP -> allow** - feed outage retains last-known-good. Fail-open

The denylist is periodically refreshed from pluggable feeds. Reputation is layered defense, so a failed load or an unresolvable IP never blocks traffic: it retains the last-known-good list and lets the request through.

## Quick start

```ts
import { createApp } from "@daloyjs/core";
import { ipReputation, urlFeed } from "@daloyjs/core";

const app = createApp();

const reputation = ipReputation({
  // Trust the proxy that fronts your app to set X-Forwarded-For.
  trustProxyHeaders: true,
  feeds: [
    urlFeed("https://www.spamhaus.org/drop/drop.txt", { name: "spamhaus-drop" }),
    urlFeed("https://check.torproject.org/torbulkexitlist", { name: "tor-exit" }),
  ],
  refreshIntervalMs: 60 * 60_000, // hourly
});

app.use(reputation.hooks);

// Release the refresh timer on graceful shutdown.
process.on("SIGTERM", () => reputation.stop());
```

The denylist is matched against the **rightmost** `X-Forwarded-For` entry (the one your immediate proxy appended), so a listed client cannot dodge it by prepending a clean spoofed entry on the left. Multi-hop chains declare their length with `trustedHops` (`trustProxyHeaders: true` is exactly `trustedHops: 1`). When the origin itself can be reached, set `trustedProxies` so the peer socket is verified against a CIDR allowlist before any forwarded header is read (see [the autoBan note](/docs/auto-ban#verify-the-peer-trustedproxies)).

## Wiring abuse feeds

A feed is anything implementing `IpReputationFeed`:

```ts
interface IpReputationFeed {
  name: string;
  fetch(signal?: AbortSignal): Promise<readonly string[]>;
}
```

`urlFeed()` covers the common case. It fetches the URL, understands the Spamhaus-DROP-style `<cidr> ; <annotation>` format, and skips `#`, `;`, and `//` comment lines. Lines that aren't valid IPs/CIDRs are skipped, so a partially-malformed feed still loads its good rows. Its outbound fetch is [SSRF-hardened by default](/docs/security/fetch-guard) (see the security notes below).

```ts
// Custom feed backed by your own threat-intel store.
const internalFeed: IpReputationFeed = {
  name: "internal-blocklist",
  async fetch() {
    const rows = await db.query("SELECT cidr FROM blocked_ranges");
    return rows.map((r) => r.cidr);
  },
};

const reputation = ipReputation({
  feeds: [internalFeed],
  trustProxyHeaders: true,
});
```

## Fail-open semantics

Reputation is layered defense, so an unavailable feed must never block legitimate traffic:

- A failed **initial** load leaves an empty (permissive) denylist, so requests flow.
- A failed **refresh** keeps the previous, last-known-good entries for that feed. The other feeds are unaffected.
- An **unresolvable client IP** is treated as not-listed.

Observe feed health with `onError`:

```ts
const reputation = ipReputation({
  feeds: [urlFeed("https://example.com/blocklist.txt")],
  trustProxyHeaders: true,
  onError: (err, feedName) => {
    metrics.increment("ip_reputation.feed_error", { feed: feedName });
    logger.warn({ err, feedName }, "reputation feed refresh failed");
  },
});
```

## Monitor mode

Roll a new feed out in `"log"` mode first to measure what it would block before you enforce it:

```ts
const reputation = ipReputation({
  feeds: [urlFeed("https://example.com/new-feed.txt")],
  trustProxyHeaders: true,
  mode: "log", // never blocks; only fires onMatch
  onMatch: ({ ip, feeds }) => {
    logger.info({ ip, feeds }, "would-block (monitor mode)");
  },
});
```

## Manual refresh & introspection

`ipReputation()` returns a controller you can drive directly:

```ts
const reputation = ipReputation({
  feeds: [urlFeed("https://example.com/blocklist.txt")],
  refreshIntervalMs: 0,   // disable the timer; refresh on your own schedule
  loadOnStart: false,     // defer the first load
});

await reputation.refresh();          // force a reload now
await reputation.ready;              // resolves after the first load attempt
reputation.size;                     // number of compiled entries
reputation.has("203.0.113.7");       // probe without side effects
```

## Custom IP resolution

By default the client IP is resolved from the socket-supplied value. Set `trustProxyHeaders: true` or `trustedProxies` to read `X-Forwarded-For` / `X-Real-IP` (only behind a proxy you trust to overwrite them. Prefer `trustedProxies` when the origin can be reached directly), or pass your own `resolveIp`:

```ts
const reputation = ipReputation({
  feeds: [urlFeed("https://example.com/blocklist.txt")],
  resolveIp: (ctx) => ctx.request.headers.get("cf-connecting-ip") ?? undefined,
});
```

## Security notes

- Defense in depth. A denylist complements (never replaces) authentication, rate limiting, and `ipRestriction()` allowlists.
- Trust your feeds. A compromised feed can deny legitimate clients. Prefer reputable sources and watch `onError` / match volume.
- SSRF. `urlFeed()`'s outbound fetch is SSRF-hardened by default: it routes through a [`fetchGuard()`](/docs/security/fetch-guard) instance (per-hop redirect re-validation, cloud-metadata and internal IPs blocked), so a compromised feed host can't redirect the request into internal space. Override `fetchImpl` only for a non-standard runtime, or with `fetchGuard({ allowPrivate: true })` for an intentionally internal feed mirror.

---

Source: https://daloyjs.dev/docs/ip-reputation