Search docs

Jump between documentation pages.

Browse docs

IP reputation / dynamic denylist feed

As of 0.37.0 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 anunref'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.

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());

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.

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 — 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 to read X-Forwarded-For / X-Real-IP (only behind a proxy you trust to overwrite them), 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() uses the platform fetch; pass an SSRF-guarded fetchImpl if feed URLs are operator-configurable.