diff --git a/.env.example b/.env.example
index eb5b8f3..e26d8c0 100644
--- a/.env.example
+++ b/.env.example
@@ -1,83 +1,212 @@
-# ─────────────────────────────────────────────────────────────────────────────
-# IPGeolocation.io – Next.js Middleware · Environment Variables
-# ─────────────────────────────────────────────────────────────────────────────
-# Get your free API key at https://ipgeolocation.io
-# ─────────────────────────────────────────────────────────────────────────────
-
-# ── Required ──────────────────────────────────────────────────────────────────
-
+# =============================================================================
+# IPGeolocation.io middleware for Next.js
+# Environment variables
+#
+# Copy the values you need into .env.local for local development, and into
+# Project Settings > Environment Variables on Vercel for deployments.
+#
+# Get an API key at https://app.ipgeolocation.io/signup
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Required
+# -----------------------------------------------------------------------------
+
+# Your IPGeolocation.io API key. Without it the middleware does nothing.
IPGEOLOCATION_API_KEY=
+# -----------------------------------------------------------------------------
+# Country controls
+# -----------------------------------------------------------------------------
-# ── Country controls ──────────────────────────────────────────────────────────
-
-# Allow-list: ONLY these countries can access your app (others → block page).
-# Leave empty to allow all countries.
-# Format: comma-separated ISO 3166-1 alpha-2 codes (case-insensitive)
+# Allow list. Only these countries can reach the app, everyone else is blocked.
+# Leave empty to allow every country.
+# Format: comma separated ISO 3166-1 alpha-2 codes, case insensitive.
# Example: US,CA,GB,AU
IPGEO_ALLOWED_COUNTRIES=
-# Block-list: these countries are always redirected to the block page.
-# Format: comma-separated ISO 3166-1 alpha-2 codes (case-insensitive)
+# Block list. These countries are always blocked.
# Example: CN,RU,KP
IPGEO_BLOCKED_COUNTRIES=
-# Country-based path redirects (JSON map).
-# Users from a matching country are redirected to the given path once.
-# Example: {"US":"/us","GB":"/uk","PK":"/pk","DE":"/de"}
+# When an allow list is set and the country cannot be determined, the request is
+# blocked. Set this to true to let unknown countries through instead.
+IPGEO_ALLOW_UNKNOWN_COUNTRY=false
+
+# Country to path redirects, as a JSON object.
+# Example: {"US":"/us","GB":"/uk","DE":"/de"}
IPGEO_COUNTRY_REDIRECTS={}
+# Keep the requested path and query string when redirecting, so that
+# /pricing?ref=ad becomes /uk/pricing?ref=ad.
+IPGEO_REDIRECT_PRESERVE_PATH=true
+
+# Leave visitors alone when they are already on one of the mapped paths, so a
+# German visitor who opens /uk on purpose stays there.
+IPGEO_REDIRECT_RESPECT_EXISTING=true
-# ── Security controls ─────────────────────────────────────────────────────────
+# HTTP status for country redirects. One of 301, 302, 307 or 308.
+IPGEO_REDIRECT_STATUS=307
-# Block VPN users
+# Name of a cookie that switches country redirects off for that visitor. Set the
+# cookie from a "stay on this site" link. Leave empty to disable the feature.
+IPGEO_REDIRECT_SKIP_COOKIE=
+
+# -----------------------------------------------------------------------------
+# Security controls
+#
+# Every variable in this block needs the security module, which is available on
+# paid plans. A lookup with security costs 3 credits instead of 1. When all of
+# them are false and no threat score threshold is set, the middleware does not
+# request the security module at all and each lookup costs 1 credit.
+# -----------------------------------------------------------------------------
+
+# Block VPN exit nodes.
IPGEO_BLOCK_VPN=false
-# Block proxy connections (datacenter / anonymous proxies)
+# Block datacenter and anonymous proxies.
IPGEO_BLOCK_PROXY=false
-# Block Tor exit nodes (recommended: true)
-IPGEO_BLOCK_TOR=true
+# Block residential proxy networks.
+IPGEO_BLOCK_RESIDENTIAL_PROXY=false
+
+# Block Tor exit nodes.
+IPGEO_BLOCK_TOR=false
+
+# Block relay networks such as iCloud Private Relay.
+# Be careful: this affects a large number of ordinary Apple users.
+IPGEO_BLOCK_RELAY=false
-# Block cloud / datacenter IP ranges (AWS, GCP, Azure, etc.)
+# Block cloud and hosting ranges such as AWS, GCP and Azure.
IPGEO_BLOCK_CLOUD_PROVIDER=false
-# Block known bots / scrapers
+# Block IPs with bot activity.
IPGEO_BLOCK_BOT=false
-# Block IPs flagged as spam sources
+# Keep bots the API marks as known good, such as search engine crawlers, even
+# when IPGEO_BLOCK_BOT is on. Turning this off can remove your site from search
+# results, so leave it on unless you know you need the opposite.
+IPGEO_ALLOW_KNOWN_GOOD_BOTS=true
+
+# Block IPs flagged as spam sources.
IPGEO_BLOCK_SPAM=false
-# Block IPs with a known attack history
+# Block IPs with a known attack history.
IPGEO_BLOCK_KNOWN_ATTACKER=false
-# Block if threat score ≥ this value. Leave empty to disable.
-# Range: 0–100. Recommended starting value: 75
-# Example: 75
+# Block any IP the API marks as anonymous, which covers VPN, proxy, Tor and
+# relay in one flag.
+IPGEO_BLOCK_ANONYMOUS=false
+
+# Block at or above this threat score, from 0 to 100. Leave empty to disable.
+# A useful starting point is 75.
IPGEO_THREAT_SCORE_BLOCK_THRESHOLD=
+# Block the request when a security rule is active but the API returned no
+# security data, which happens on a free plan. The default is to let the request
+# through and log the problem.
+IPGEO_REQUIRE_SECURITY=false
-# ── Behavior ──────────────────────────────────────────────────────────────────
+# -----------------------------------------------------------------------------
+# Blocking behaviour
+# -----------------------------------------------------------------------------
-# Path to redirect blocked requests to
+# How to handle a blocked request.
+# redirect send the visitor to IPGEO_BLOCK_PATH (default)
+# rewrite render IPGEO_BLOCK_PATH at the original URL
+# deny answer immediately with IPGEO_BLOCK_STATUS and IPGEO_BLOCK_MESSAGE
+IPGEO_BLOCK_MODE=redirect
+
+# The page used by redirect and rewrite mode.
IPGEO_BLOCK_PATH=/blocked
-# Prefix for geo headers forwarded to your app
-# Your app can read e.g. request.headers.get('x-ipgeo-country')
+# Status and body used by deny mode.
+IPGEO_BLOCK_STATUS=403
+IPGEO_BLOCK_MESSAGE=Access to this site is restricted.
+
+# Add ?reason=... to the block URL. The reason is always sent as the
+# x-ipgeo-block-reason response header.
+IPGEO_BLOCK_INCLUDE_REASON=true
+
+# Block traffic when a lookup fails, instead of letting it through.
+# false keeps the site available during an API incident, which is the default.
+IPGEO_FAIL_CLOSED=false
+
+# -----------------------------------------------------------------------------
+# Bypasses
+# -----------------------------------------------------------------------------
+
+# Turn the middleware off without removing it from the project.
+IPGEO_ENABLED=true
+
+# Paths that skip every check. Useful for health checks and inbound webhooks.
+# Example: /health,/api/webhooks
+IPGEO_BYPASS_PATHS=
+
+# IP addresses that skip every check, comma separated.
+# Example: 203.0.113.10,198.51.100.4
+IPGEO_BYPASS_IPS=
+
+# A shared secret. A request carrying x-ipgeo-bypass-token with this value skips
+# every check, which lets an uptime monitor through. Leave empty to disable.
+IPGEO_BYPASS_TOKEN=
+
+# -----------------------------------------------------------------------------
+# Headers
+# -----------------------------------------------------------------------------
+
+# Prefix for the geo headers the middleware adds to the request.
+# Inbound headers that use this prefix are always removed first, so a visitor
+# cannot forge them.
IPGEO_HEADER_PREFIX=x-ipgeo
-# API request timeout in milliseconds (default: 3000)
+# Also copy the geo headers onto the response. Off by default, because the
+# values describe the visitor and should not be cached by a shared cache.
+IPGEO_SET_RESPONSE_HEADERS=false
+
+# -----------------------------------------------------------------------------
+# Network behaviour
+# -----------------------------------------------------------------------------
+
+# API timeout per attempt, in milliseconds.
IPGEO_TIMEOUT_MS=3000
-# In-memory cache TTL in milliseconds (default: 60000 = 1 minute)
-# Set to 0 to disable caching entirely.
+# Retries for a timeout, a network error or a 5xx response. 0 to 3.
+IPGEO_RETRIES=1
+
+# In memory cache lifetime in milliseconds. 0 disables caching.
IPGEO_CACHE_TTL_MS=60000
-# fail-closed: if the geo lookup fails (timeout / API error), block the request
-# instead of passing it through. Default: false (fail-open = safer for uptime)
-IPGEO_FAIL_CLOSED=false
+# Largest number of cached IPs per edge isolate. 0 disables caching.
+IPGEO_CACHE_MAX_ENTRIES=1000
+
+# Pause lookups after this many consecutive failures. 0 disables the breaker.
+IPGEO_CIRCUIT_FAILURE_THRESHOLD=5
+
+# How long the pause lasts, in milliseconds.
+IPGEO_CIRCUIT_COOLDOWN_MS=30000
+
+# Restrict the API response to these fields to shrink the payload.
+# Leave empty for the full response.
+# Example: location,asn,time_zone,security
+IPGEO_FIELDS=
+
+# -----------------------------------------------------------------------------
+# Client IP resolution
+# -----------------------------------------------------------------------------
+
+# Read the leftmost entry of x-forwarded-for instead of the rightmost.
+# Leave this off on Vercel.
+IPGEO_TRUST_FIRST_XFF=false
+
+# Number of proxies you operate in front of the app. The client IP is read that
+# many positions to the left of the rightmost entry.
+IPGEO_TRUSTED_PROXY_COUNT=0
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
-# Trust the FIRST IP in x-forwarded-for instead of the last.
-# On Vercel, the LAST IP is injected by Vercel's edge and is most trustworthy.
-# Only set this to true if you're behind a custom reverse proxy that you control.
-IPGEO_TRUST_FIRST_XFF=false
\ No newline at end of file
+# One of silent, error, warn, info or debug. Use debug while setting things up,
+# then go back to warn.
+IPGEO_LOG_LEVEL=warn
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..21e81de
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [18, 20, 22]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: npm
+
+ - run: npm ci
+ - run: npm run typecheck
+ - run: npm test
+ - run: npm run build
diff --git a/.gitignore b/.gitignore
index 7d0e8dd..eacd80d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,10 @@
node_modules/
dist/
coverage/
+.env
+.env.local
*.env
*.env.local
.DS_Store
-.npmrc
\ No newline at end of file
+.npmrc
+.vscode
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c41c3b..edae346 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,91 @@
# Changelog
-All notable changes to `ipgeolocation-vercel-middleware` will be documented here.
+All notable changes to `ipgeolocation-vercel-middleware` are recorded here.
+This project follows [Semantic Versioning](https://semver.org/).
-This project adheres to [Semantic Versioning](https://semver.org/).
+## 2.0.0
----
+A correctness and hardening release. Read the upgrade notes before deploying,
+because several defaults changed.
-## [1.0.0] — Initial release
+### Breaking changes
+
+- The security module is now requested only when a security rule is switched on.
+ Version 1.0.0 always sent `include=security`, which made every lookup cost 3
+ credits and returned HTTP 401 on free plans. With no security rules active, a
+ lookup now costs 1 credit and works on a free key.
+- `lookupIpGeolocation` no longer defaults `includeSecurity` to `true`. Pass
+ `includeSecurity: true` or `include: ['security']` when you want it.
+- `lookupIpGeolocation` rejects private, loopback and bogon addresses before
+ calling the API, because the API answers HTTP 423 for them. Pass
+ `allowPrivateIp: true` to restore the old behaviour.
+- `getClientIp` validates the address and ignores private ranges by default.
+ It returns `null` where 1.0.0 returned `127.0.0.1` during local development.
+- Country redirects keep the requested path and query string by default. Set
+ `IPGEO_REDIRECT_PRESERVE_PATH=false` for the 1.0.0 behaviour.
+- When `IPGEO_ALLOWED_COUNTRIES` is set and the country cannot be determined,
+ the request is blocked. Set `IPGEO_ALLOW_UNKNOWN_COUNTRY=true` to allow it.
+- Country blocks now include `?reason=country` in the block URL.
+
+### Added
+
+- `evaluateIpGeolocation`, which returns the decision without sending a
+ response, so the geo headers survive when you compose your own middleware.
+- `withIpGeolocation`, a wrapper for the same pattern.
+- `createIpGeoMiddleware` and `getMiddlewareConfig` for configuration in code.
+- `proxy`, an alias of `middleware` for the Next.js 16 `proxy.ts` convention.
+- `lookupIpGeolocationResult`, which reports the failure reason, the HTTP
+ status, whether the answer came from cache and how many credits were charged.
+- Block modes: `redirect`, `rewrite` and `deny` through `IPGEO_BLOCK_MODE`.
+- Bypasses: `IPGEO_ENABLED`, `IPGEO_BYPASS_PATHS`, `IPGEO_BYPASS_IPS` and
+ `IPGEO_BYPASS_TOKEN`.
+- Retries for timeouts, network errors and 5xx responses, with `IPGEO_RETRIES`.
+- A circuit breaker, `IPGEO_CIRCUIT_FAILURE_THRESHOLD` and
+ `IPGEO_CIRCUIT_COOLDOWN_MS`, so an API incident does not add the full timeout
+ to every request.
+- Request coalescing, so concurrent lookups for one IP share a single call.
+- A bounded cache, `IPGEO_CACHE_MAX_ENTRIES`, with eviction of the coldest keys.
+- Automatic retry without the security module when a free plan key rejects it,
+ plus a five minute latch so the mistake is not repeated on every request.
+- New rules: `IPGEO_BLOCK_RESIDENTIAL_PROXY`, `IPGEO_BLOCK_RELAY`,
+ `IPGEO_BLOCK_ANONYMOUS`, `IPGEO_ALLOW_KNOWN_GOOD_BOTS` and
+ `IPGEO_REQUIRE_SECURITY`.
+- Redirect controls: `IPGEO_REDIRECT_STATUS`, `IPGEO_REDIRECT_PRESERVE_PATH`,
+ `IPGEO_REDIRECT_RESPECT_EXISTING` and `IPGEO_REDIRECT_SKIP_COOKIE`.
+- `IPGEO_TRUSTED_PROXY_COUNT` for deployments behind your own reverse proxy.
+- `IPGEO_LOG_LEVEL` with a `warn` default, replacing a log line per lookup.
+- `IPGEO_SET_RESPONSE_HEADERS` for copying the geo headers onto the response.
+- New headers: `x-ipgeo-continent`, `x-ipgeo-state-code`, `x-ipgeo-zipcode`,
+ `x-ipgeo-is-eu`, `x-ipgeo-currency`, `x-ipgeo-company`,
+ `x-ipgeo-is-residential-proxy`, `x-ipgeo-is-relay`, `x-ipgeo-is-anonymous`
+ and `x-ipgeo-is-known-good-bot`.
+- `resetIpGeoRuntimeState` and `getIpGeoRuntimeState` for tests and health
+ checks.
+- 177 unit tests covering the library and the middleware.
+
+### Fixed
+
+- Inbound `x-ipgeo-*` headers are removed on every path. In 1.0.0 a visitor
+ could send `x-ipgeo-country` and the application had no way to tell.
+- A redirect target with a trailing slash, such as `{"GB":"/uk/"}`, caused an
+ endless redirect loop. Targets are normalised and loops are detected.
+- Redirect targets pointing at another origin are rejected, so a mistyped
+ variable cannot turn the middleware into an open redirect.
+- The default matcher skipped every path starting with `blocked`, including
+ `/blockedlist`, and ran on static files. Both are fixed.
+- Country blocks sent no reason, so the block page could not explain itself.
+- Country redirects fired on POST requests, which turned a form submission into
+ a GET.
+- An unexpected error inside the middleware could return HTTP 500 for the whole
+ site. Errors are caught and follow the fail open or fail closed setting.
+- Blocks and redirects now send `cache-control: no-store`, so a shared cache
+ cannot serve one visitor's geo decision to another.
+- `x-forwarded-for` values with a port, IPv6 in brackets, IPv4 mapped IPv6 and
+ zone indexes are parsed correctly instead of being sent to the API as is.
+- `IPGEO_COUNTRY_REDIRECTS` is parsed once per value rather than on every
+ request.
+
+## 1.0.0
### Added
- `lookupIpGeolocation` — fetch geo + security data from IPGeolocation.io v3 API
diff --git a/LICENSE b/LICENSE
index e28d6b3..fa0067c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 IPGeolocation.io
+Copyright (c) 2026 IPGeolocation.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index cc30960..7110fb8 100644
--- a/README.md
+++ b/README.md
@@ -1,299 +1,777 @@
-# ipgeolocation-vercel-middleware
+# IPGeolocation.io Next.js Middleware for Vercel
-**Official [IPGeolocation.io](https://ipgeolocation.io) middleware for Next.js.**
-Block bots, VPNs, proxies, and bad actors at the Vercel edge — before your app sees a single request.
+Official Next.js middleware from [IPGeolocation.io](https://ipgeolocation.io). It resolves the visitor's IP address at the Vercel edge, applies your country and security rules before the request reaches your application, and passes the geolocation data on as request headers.
[](https://www.npmjs.com/package/ipgeolocation-vercel-middleware)
[](LICENSE)
-[](https://nextjs.org)
+[](https://nextjs.org)
----
+## Overview
-## How it works
+Next.js middleware runs on the Vercel edge network before a request is routed to a page, a Route Handler or an API route. That is the right place to decide whether a visitor should be served at all, and the right place to work out where they are so the rest of your application does not have to.
-```
-Incoming request
- │
- ▼
- Vercel Edge Network
- │
- ▼
- middleware.ts ──► IPGeolocation.io API (with 60s in-memory cache)
- │
- ├── Blocked country / VPN / bot / threat score? ──► redirect to /blocked
- │
- ├── Country redirect configured? ──► redirect to /us, /uk, etc.
- │
- └── Pass through + attach x-ipgeo-* headers to request
-```
-
-Zero latency added for repeat visitors (cached). No changes required to your app — geo data is forwarded as HTTP headers.
-
----
+This package handles both jobs. Set an API key, and every request that matches your matcher carries headers such as `x-ipgeo-country`, `x-ipgeo-city` and `x-ipgeo-timezone`. Switch on a rule, and traffic from a blocked country, a VPN, a Tor exit node or an IP with a high threat score is stopped at the edge.
-## Features
+### What you can do with it
-| Feature | Description |
+| Capability | Description |
|---|---|
-| Country allow-list | Only permit traffic from specific countries |
-| Country block-list | Block specific countries |
-| Country redirects | Send users to locale-specific paths (`/us`, `/uk`) |
-| VPN detection | Block VPN connections |
-| Proxy detection | Block datacenter / anonymous proxies |
+| Country allow list | Serve only the countries you list |
+| Country block list | Block the countries you list |
+| Country redirects | Send visitors to locale paths such as `/us` or `/uk`, keeping the path and query string |
+| VPN detection | Block connections coming through a VPN |
+| Proxy detection | Block datacenter, anonymous and residential proxies |
| Tor detection | Block Tor exit nodes |
-| Bot detection | Block known bots and scrapers |
-| Spam detection | Block IPs flagged as spam sources |
-| Attacker detection | Block IPs with a known attack history |
-| Cloud provider detection | Block cloud / datacenter IP ranges |
-| Threat score | Block IPs above a configurable threat score (0–100) |
-| Geo headers | Forward rich geo data to your app as `x-ipgeo-*` headers |
-| Edge caching | In-memory TTL cache — one API call per IP per minute |
-| Fail-closed mode | Block traffic when the API is unavailable |
-
----
+| Relay detection | Block relay networks such as iCloud Private Relay |
+| Bot detection | Block automated traffic while keeping known good crawlers |
+| Spam and attacker detection | Block addresses with spam or attack history |
+| Cloud provider detection | Block AWS, GCP, Azure and other hosting ranges |
+| Threat score | Block at or above a score you choose, from 0 to 100 |
+| Geolocation headers | Country, city, region, coordinates, time zone, currency, ASN and company as request headers |
+| Edge caching | One API call per IP per cache window, per edge isolate |
+| Circuit breaker | Lookups pause during an API incident instead of adding latency to every request |
+| Fail open or fail closed | Choose availability or enforcement when a lookup cannot complete |
+
+### How a request flows
+
+1. A request reaches the Vercel edge network and matches the middleware matcher.
+2. Inbound headers that use your geo prefix are removed, so a visitor cannot forge them.
+3. The client IP is read from `x-forwarded-for` or another platform header, then validated.
+4. The IP is looked up through the IPGeolocation.io v3 API, or read from the in memory cache.
+5. Country rules run, then security rules, then country redirects.
+6. If nothing matched, the request continues with the `x-ipgeo-*` headers attached.
+
+Repeat visitors within the cache window add no network latency and cost no credits.
## Requirements
-- Next.js ≥ 13 (Edge Middleware)
-- An [IPGeolocation.io API key](https://app.ipgeolocation.io/signup) (free tier available)
+- Next.js 13 or later, including Next.js 16 with `proxy.ts`
+- Node.js 18 or later for local development
+- An [IPGeolocation.io API key](https://app.ipgeolocation.io/signup)
+
+### Which plan you need
+
+The base lookup, which returns country, region, city, coordinates, time zone, currency and ASN, works on the free plan. The free plan includes 1,000 requests per day.
----
+The security module, which is what VPN, proxy, Tor, relay, bot, spam, attacker and threat score rules read, is available on paid plans only. A free plan key that asks for it receives HTTP 401. This middleware requests the module only when at least one security rule is switched on, so a free key that uses country rules and geolocation headers works without any extra configuration.
+
+| What you use | Credits per uncached lookup | Plan |
+|---|---|---|
+| Geolocation headers, country allow and block lists, country redirects | 1 | Free or paid |
+| Any of the security rules, or a threat score threshold | 3 | Paid |
+
+Credits are charged only on a successful response, and the exact figure for a request is returned in the `X-Credits-Charged` header. See the [credits usage guide](https://ipgeolocation.io/documentation/credits-usage.html) for the full rules.
## Installation
```bash
npm install ipgeolocation-vercel-middleware
-# or
+```
+
+```bash
yarn add ipgeolocation-vercel-middleware
-# or
+```
+
+```bash
pnpm add ipgeolocation-vercel-middleware
```
----
+## Quick Start
+
+### Step 1. Add your API key
+
+Create `.env.local` in your project root:
+
+```bash
+IPGEOLOCATION_API_KEY=your_api_key_here
+```
-## Quick start
+Add the same variable in your Vercel project under **Settings > Environment Variables** for Production, Preview and Development.
-### Option A — Use the pre-built middleware (simplest)
+### Step 2. Create the middleware file
+
+On Next.js 13, 14 and 15, create `middleware.ts` in your project root, next to `app/` or `pages/`:
+
+```typescript
+import { middleware } from 'ipgeolocation-vercel-middleware/middleware';
+
+export { middleware };
+
+export const config = {
+ matcher: [
+ '/((?!_next/static|_next/image|_next/data|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|js|mjs|map|json|txt|xml|ico|png|jpg|jpeg|gif|webp|avif|svg|woff|woff2|ttf|otf|eot|mp4|webm|mp3|pdf|zip)$).*)'
+ ]
+};
+```
-Create `middleware.ts` at your project root:
+On Next.js 16, the file is named `proxy.ts` and the exported function is named `proxy`:
```typescript
-export { middleware, config } from 'ipgeolocation-vercel-middleware/middleware';
+import { proxy } from 'ipgeolocation-vercel-middleware/middleware';
+
+export { proxy };
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
+};
```
-Then add your API key to `.env.local`:
+Declare `config` in your own file rather than re-exporting it from the package. Next.js reads the matcher statically at build time and cannot follow a value that comes from a dependency. Next.js 16 rejects a re-exported `config` outright.
+
+The matcher above skips Next.js internals and files with a static extension. Every path you exclude is a lookup you do not pay for.
+
+### Step 3. Add a block page
+
+Only needed if you use country or security rules with the default block mode. Create `app/blocked/page.tsx`:
+
+```tsx
+export const metadata = {
+ title: 'Access restricted',
+ robots: { index: false, follow: false }
+};
+
+export default async function BlockedPage({
+ searchParams
+}: {
+ searchParams: Promise<{ reason?: string }>;
+}) {
+ const { reason } = await searchParams;
+
+ return (
+
+ Access restricted
+ We could not accept this connection ({reason ?? 'policy'}).
+
+ If you think this is a mistake, contact{' '}
+ support@example.com.
+
+
+ );
+}
+```
+
+On Next.js 13 and 14, `searchParams` is a plain object rather than a promise, so drop the `await` and the `Promise` type. A fuller example with a message for every reason is in [`examples/app/blocked/page.tsx`](https://github.com/ipgeolocation/ipgeolocation-vercel-middleware/blob/main/examples/app/blocked/page.tsx).
+
+If you would rather not create a page at all, set `IPGEO_BLOCK_MODE=deny` and the middleware answers with a plain HTTP 403.
+
+### Step 4. Switch on the rules you want
+
+Everything is configured through environment variables. A few common starting points:
```bash
+# Geolocation headers only, no blocking. Costs 1 credit per uncached lookup.
IPGEOLOCATION_API_KEY=your_api_key_here
```
-Done.
+```bash
+# Serve three countries and nobody else.
+IPGEOLOCATION_API_KEY=your_api_key_here
+IPGEO_ALLOWED_COUNTRIES=US,CA,GB
+```
----
+```bash
+# Send visitors to locale paths.
+IPGEOLOCATION_API_KEY=your_api_key_here
+IPGEO_COUNTRY_REDIRECTS={"US":"/us","GB":"/uk","DE":"/de"}
+```
-### Option B — Compose with your own middleware
+```bash
+# Block anonymised and high risk traffic. Needs a paid plan.
+IPGEOLOCATION_API_KEY=your_api_key_here
+IPGEO_BLOCK_VPN=true
+IPGEO_BLOCK_TOR=true
+IPGEO_THREAT_SCORE_BLOCK_THRESHOLD=75
+```
-```typescript
-// middleware.ts
-import { NextRequest, NextResponse } from 'next/server';
-import { middleware as ipGeoMiddleware } from 'ipgeolocation-vercel-middleware/middleware';
+### Step 5. Read the data in your application
-export async function middleware(request: NextRequest) {
- // Run IPGeolocation checks first
- const geoResponse = await ipGeoMiddleware(request);
+```tsx
+// app/page.tsx
+import { headers } from 'next/headers';
- // If the middleware wants to block/redirect, respect it
- if (geoResponse.status !== 200) return geoResponse;
+export default async function Page() {
+ const h = await headers();
+ const country = h.get('x-ipgeo-country');
+ const city = h.get('x-ipgeo-city');
- // Your own logic here...
- return NextResponse.next();
+ return
Hello from {city ?? 'somewhere'}, {country ?? 'unknown'}
;
}
+```
+
+On Next.js 13 and 14, `headers()` is synchronous, so drop the `await`.
-export { config } from 'ipgeolocation-vercel-middleware/middleware';
+### Step 6. Check that it works
+
+Run `next dev` and open the site. Locally there is no edge network in front of you, so the request arrives from `127.0.0.1`, which the API cannot resolve. The middleware recognises a private address and passes the request through without spending a credit, which means no headers are set. To see real data locally, send a public address yourself:
+
+```bash
+curl -H 'x-forwarded-for: 91.128.103.196' http://localhost:3000/
```
----
+Set `IPGEO_LOG_LEVEL=debug` while you are setting things up and the middleware reports each lookup, each cache hit and the credits charged. Move back to the default `warn` afterwards.
+
+## Reading geolocation data in your application
-### Option C — Use the utility functions directly
+The middleware attaches the data to the request, so any part of your application can read it without a second API call.
+
+### Server Component
+
+```tsx
+import { headers } from 'next/headers';
+
+export default async function PricingPage() {
+ const h = await headers();
+ const currency = h.get('x-ipgeo-currency') ?? 'USD';
+ const isEu = h.get('x-ipgeo-is-eu') === 'true';
+
+ return ;
+}
+```
+
+### Route Handler
```typescript
-import {
- lookupIpGeolocation,
- getClientIp,
- shouldBlockBySecurity
-} from 'ipgeolocation-vercel-middleware';
+// app/api/where/route.ts
+export function GET(request: Request) {
+ return Response.json({
+ ip: request.headers.get('x-ipgeo-ip'),
+ country: request.headers.get('x-ipgeo-country'),
+ city: request.headers.get('x-ipgeo-city'),
+ timezone: request.headers.get('x-ipgeo-timezone')
+ });
+}
```
----
+### Pages Router
+
+```typescript
+// pages/index.tsx
+export async function getServerSideProps({ req }) {
+ return {
+ props: {
+ country: req.headers['x-ipgeo-country'] ?? null,
+ city: req.headers['x-ipgeo-city'] ?? null
+ }
+ };
+}
+```
+
+### Forwarded headers
+
+A header is set only when the API returned a value for it, so a missing header means the data was not available rather than an empty string. The security headers appear only when a security rule is active, because that is the only time the module is requested.
+
+| Header | Example | Needs security module |
+|---|---|---|
+| `x-ipgeo-ip` | `91.128.103.196` | No |
+| `x-ipgeo-country` | `SE` | No |
+| `x-ipgeo-country-name` | `Sweden` | No |
+| `x-ipgeo-continent` | `EU` | No |
+| `x-ipgeo-state` | `Stockholms län` | No |
+| `x-ipgeo-state-code` | `SE-AB` | No |
+| `x-ipgeo-city` | `Stockholm` | No |
+| `x-ipgeo-zipcode` | `164 40` | No |
+| `x-ipgeo-latitude` | `59.40510` | No |
+| `x-ipgeo-longitude` | `17.95510` | No |
+| `x-ipgeo-is-eu` | `true` | No |
+| `x-ipgeo-timezone` | `Europe/Stockholm` | No |
+| `x-ipgeo-currency` | `SEK` | No |
+| `x-ipgeo-asn` | `AS1257` | No |
+| `x-ipgeo-asn-organization` | `Tele2 Sverige AB` | No |
+| `x-ipgeo-company` | `Tele2 Sverige AB` | No |
+| `x-ipgeo-threat-score` | `80` | Yes |
+| `x-ipgeo-is-vpn` | `true` | Yes |
+| `x-ipgeo-is-proxy` | `false` | Yes |
+| `x-ipgeo-is-residential-proxy` | `false` | Yes |
+| `x-ipgeo-is-tor` | `false` | Yes |
+| `x-ipgeo-is-relay` | `false` | Yes |
+| `x-ipgeo-is-anonymous` | `true` | Yes |
+| `x-ipgeo-is-bot` | `false` | Yes |
+| `x-ipgeo-is-known-good-bot` | `false` | Yes |
+| `x-ipgeo-is-spam` | `false` | Yes |
+| `x-ipgeo-is-known-attacker` | `false` | Yes |
+| `x-ipgeo-is-cloud-provider` | `true` | Yes |
+| `x-ipgeo-cloud-provider-name` | `Packethub S.A.` | Yes |
+
+Rename the prefix with `IPGEO_HEADER_PREFIX` if `x-ipgeo` clashes with something in your stack.
+
+Headers that use the prefix are removed from the inbound request on every path, including paths where no lookup runs. Without that step, a visitor could send `x-ipgeo-country: US` and your application would have no way to tell the difference. If you read these headers anywhere, keep the prefix consistent between the middleware and the reader.
## Configuration
-All configuration is done via environment variables. No code changes needed.
+Every setting is an environment variable, so you can change behaviour per environment in Vercel without a code change. A complete annotated file is in [`.env.example`](.env.example).
### Required
| Variable | Description |
|---|---|
-| `IPGEOLOCATION_API_KEY` | Your IPGeolocation.io API key |
+| `IPGEOLOCATION_API_KEY` | Your API key. When it is missing, the middleware passes every request through untouched. |
### Country controls
| Variable | Type | Default | Description |
|---|---|---|---|
-| `IPGEO_ALLOWED_COUNTRIES` | `string` | _(all)_ | CSV of ISO 3166-1 alpha-2 codes. Only these countries are allowed. Example: `US,CA,GB` |
-| `IPGEO_BLOCKED_COUNTRIES` | `string` | _(none)_ | CSV of ISO 3166-1 alpha-2 codes. These countries are always blocked. Example: `CN,RU` |
-| `IPGEO_COUNTRY_REDIRECTS` | `JSON` | `{}` | JSON map of country → path redirects. Example: `{"US":"/us","GB":"/uk","PK":"/pk"}` |
+| `IPGEO_ALLOWED_COUNTRIES` | CSV | empty | Only these ISO 3166-1 alpha-2 codes are served. Example: `US,CA,GB` |
+| `IPGEO_BLOCKED_COUNTRIES` | CSV | empty | These codes are always blocked. Example: `CN,RU,KP` |
+| `IPGEO_ALLOW_UNKNOWN_COUNTRY` | boolean | `false` | When an allow list is set and the country cannot be determined, the request is blocked. Set to `true` to let it through. |
+| `IPGEO_COUNTRY_REDIRECTS` | JSON | `{}` | Country to path map. Example: `{"US":"/us","GB":"/uk"}` |
+| `IPGEO_REDIRECT_PRESERVE_PATH` | boolean | `true` | Keep the path and query string, so `/pricing?ref=ad` becomes `/uk/pricing?ref=ad` |
+| `IPGEO_REDIRECT_RESPECT_EXISTING` | boolean | `true` | Do not move a visitor who is already on one of the mapped paths |
+| `IPGEO_REDIRECT_STATUS` | number | `307` | One of `301`, `302`, `307`, `308` |
+| `IPGEO_REDIRECT_SKIP_COOKIE` | string | empty | Name of a cookie that switches redirects off for that visitor |
### Security controls
+Each of these needs the security module, which requires a paid plan and raises an uncached lookup from 1 credit to 3.
+
+| Variable | Type | Default | Description |
+|---|---|---|---|
+| `IPGEO_BLOCK_VPN` | boolean | `false` | Block VPN exit nodes |
+| `IPGEO_BLOCK_PROXY` | boolean | `false` | Block datacenter and anonymous proxies |
+| `IPGEO_BLOCK_RESIDENTIAL_PROXY` | boolean | `false` | Block residential proxy networks |
+| `IPGEO_BLOCK_TOR` | boolean | `false` | Block Tor exit nodes |
+| `IPGEO_BLOCK_RELAY` | boolean | `false` | Block relay networks such as iCloud Private Relay |
+| `IPGEO_BLOCK_CLOUD_PROVIDER` | boolean | `false` | Block cloud and hosting ranges |
+| `IPGEO_BLOCK_BOT` | boolean | `false` | Block addresses with bot activity |
+| `IPGEO_ALLOW_KNOWN_GOOD_BOTS` | boolean | `true` | Keep crawlers the API marks as known good when bots are blocked |
+| `IPGEO_BLOCK_SPAM` | boolean | `false` | Block addresses flagged as spam sources |
+| `IPGEO_BLOCK_KNOWN_ATTACKER` | boolean | `false` | Block addresses with attack history |
+| `IPGEO_BLOCK_ANONYMOUS` | boolean | `false` | Block anything the API marks as anonymous, covering VPN, proxy, Tor and relay together |
+| `IPGEO_THREAT_SCORE_BLOCK_THRESHOLD` | number | disabled | Block at or above this score, 0 to 100 |
+| `IPGEO_REQUIRE_SECURITY` | boolean | `false` | Block when a security rule is active but the API returned no security data |
+
+### Blocking behaviour
+
| Variable | Type | Default | Description |
|---|---|---|---|
-| `IPGEO_BLOCK_VPN` | `boolean` | `false` | Block VPN connections |
-| `IPGEO_BLOCK_PROXY` | `boolean` | `false` | Block proxy connections |
-| `IPGEO_BLOCK_TOR` | `boolean` | `true` | Block Tor exit nodes |
-| `IPGEO_BLOCK_CLOUD_PROVIDER` | `boolean` | `false` | Block cloud provider IPs (AWS, GCP, Azure, etc.) |
-| `IPGEO_BLOCK_BOT` | `boolean` | `false` | Block known bots and scrapers |
-| `IPGEO_BLOCK_SPAM` | `boolean` | `false` | Block IPs flagged as spam sources |
-| `IPGEO_BLOCK_KNOWN_ATTACKER` | `boolean` | `false` | Block IPs with known attack history |
-| `IPGEO_THREAT_SCORE_BLOCK_THRESHOLD` | `number` | _(disabled)_ | Block IPs with threat score ≥ this value (0–100). Example: `75` |
+| `IPGEO_BLOCK_MODE` | enum | `redirect` | `redirect`, `rewrite` or `deny` |
+| `IPGEO_BLOCK_PATH` | path | `/blocked` | Page used by `redirect` and `rewrite` |
+| `IPGEO_BLOCK_STATUS` | number | `403` | Status used by `deny` |
+| `IPGEO_BLOCK_MESSAGE` | string | `Access to this site is restricted.` | Body used by `deny` |
+| `IPGEO_BLOCK_INCLUDE_REASON` | boolean | `true` | Add `?reason=...` to the block URL |
+| `IPGEO_FAIL_CLOSED` | boolean | `false` | Block when a lookup fails, rather than letting the request through |
-### Behaviour
+### Bypasses
| Variable | Type | Default | Description |
|---|---|---|---|
-| `IPGEO_BLOCK_PATH` | `string` | `/blocked` | Path to redirect blocked requests to |
-| `IPGEO_HEADER_PREFIX` | `string` | `x-ipgeo` | Prefix for forwarded geo headers |
-| `IPGEO_TIMEOUT_MS` | `number` | `3000` | API request timeout in milliseconds |
-| `IPGEO_CACHE_TTL_MS` | `number` | `60000` | In-memory cache TTL in milliseconds. Set to `0` to disable |
-| `IPGEO_FAIL_CLOSED` | `boolean` | `false` | Block requests when API lookup fails. `false` = fail-open (safer for uptime) |
-| `IPGEO_TRUST_FIRST_XFF` | `boolean` | `false` | Trust the first IP in `x-forwarded-for`. Leave `false` on Vercel (Vercel injects the last, trusted IP) |
+| `IPGEO_ENABLED` | boolean | `true` | Set to `false` to switch the middleware off without removing it |
+| `IPGEO_BYPASS_PATHS` | CSV | empty | Paths that skip every check. Example: `/health,/api/webhooks` |
+| `IPGEO_BYPASS_IPS` | CSV | empty | Addresses that skip every check |
+| `IPGEO_BYPASS_TOKEN` | string | empty | A request carrying `x-ipgeo-bypass-token` with this value skips every check |
----
+### Headers, network and logging
-## Forwarded headers
+| Variable | Type | Default | Description |
+|---|---|---|---|
+| `IPGEO_HEADER_PREFIX` | string | `x-ipgeo` | Prefix for the forwarded headers |
+| `IPGEO_SET_RESPONSE_HEADERS` | boolean | `false` | Also copy the geo headers onto the response |
+| `IPGEO_TIMEOUT_MS` | number | `3000` | API timeout per attempt |
+| `IPGEO_RETRIES` | number | `1` | Retries for a timeout, a network error or a 5xx response, 0 to 3 |
+| `IPGEO_CACHE_TTL_MS` | number | `60000` | Cache lifetime. `0` disables caching |
+| `IPGEO_CACHE_MAX_ENTRIES` | number | `1000` | Largest number of cached addresses per edge isolate |
+| `IPGEO_CIRCUIT_FAILURE_THRESHOLD` | number | `5` | Consecutive failures before lookups pause. `0` disables the breaker |
+| `IPGEO_CIRCUIT_COOLDOWN_MS` | number | `30000` | How long the pause lasts |
+| `IPGEO_FIELDS` | CSV | empty | Restrict the API response to these fields to shrink the payload |
+| `IPGEO_TRUST_FIRST_XFF` | boolean | `false` | Read the leftmost `x-forwarded-for` entry. Leave off on Vercel |
+| `IPGEO_TRUSTED_PROXY_COUNT` | number | `0` | Number of proxies you operate in front of the application |
+| `IPGEO_LOG_LEVEL` | enum | `warn` | `silent`, `error`, `warn`, `info` or `debug` |
+
+## Blocking behaviour in detail
+
+### Block modes
+
+`redirect` sends a 307 to `IPGEO_BLOCK_PATH` with the reason as a query parameter. The visitor sees the block URL in the address bar, and your block page can explain what happened. This is the default.
+
+`rewrite` renders the block page at the URL the visitor asked for. The status stays 200 and the URL does not change, which suits cases where you would rather not advertise that a rule fired.
+
+`deny` answers immediately with `IPGEO_BLOCK_STATUS` and `IPGEO_BLOCK_MESSAGE` as plain text. No page is needed, nothing else runs, and there is no way to create a loop. This is the right mode for an API only deployment.
-The middleware attaches the following headers to every passing request so your app can read geo data without another API call:
+Every block response carries `x-ipgeo-block-reason` and `cache-control: no-store`, so a shared cache cannot serve one visitor's decision to another.
-| Header | Example value |
+### Block reasons
+
+The reason appears in the `reason` query parameter and in the `x-ipgeo-block-reason` header.
+
+| Reason | Meaning |
|---|---|
-| `x-ipgeo-ip` | `1.2.3.4` |
-| `x-ipgeo-country` | `US` |
-| `x-ipgeo-country-name` | `United States` |
-| `x-ipgeo-state` | `New York` |
-| `x-ipgeo-city` | `New York City` |
-| `x-ipgeo-latitude` | `40.7128` |
-| `x-ipgeo-longitude` | `-74.0060` |
-| `x-ipgeo-timezone` | `America/New_York` |
-| `x-ipgeo-asn` | `15169` |
-| `x-ipgeo-asn-organization` | `Google LLC` |
-| `x-ipgeo-threat-score` | `0` |
-| `x-ipgeo-is-vpn` | `false` |
-| `x-ipgeo-is-proxy` | `false` |
-| `x-ipgeo-is-tor` | `false` |
-| `x-ipgeo-is-bot` | `false` |
-| `x-ipgeo-is-spam` | `false` |
-| `x-ipgeo-is-known-attacker` | `false` |
-| `x-ipgeo-is-cloud-provider` | `false` |
-| `x-ipgeo-cloud-provider-name` | `Amazon AWS` |
-
-Read them in a Server Component, Route Handler, or API route:
+| `country` | The country failed the allow list or matched the block list |
+| `vpn`, `proxy`, `residential_proxy`, `tor`, `relay`, `cloud_provider` | The matching connection type rule fired |
+| `bot`, `spam`, `known_attacker`, `anonymous` | The matching reputation rule fired |
+| `threat_score` | The score reached `IPGEO_THREAT_SCORE_BLOCK_THRESHOLD` |
+| `lookup_failed` | The lookup did not complete and `IPGEO_FAIL_CLOSED` is on |
+| `security_unavailable` | A security rule is active, the API returned no security data, and `IPGEO_REQUIRE_SECURITY` is on |
+| `middleware_error` | An unexpected error occurred and `IPGEO_FAIL_CLOSED` is on |
+
+### Fail open and fail closed
+
+By default a failed lookup lets the request through. An API incident, a timeout or an exhausted quota then costs you enforcement rather than availability. Set `IPGEO_FAIL_CLOSED=true` when the rules matter more than uptime, for example on a licensing or compliance boundary.
+
+A private or malformed address is not treated as a failure. Those requests pass through even in fail closed mode, because blocking them would break local development and internal health checks.
+
+## Country redirects
+
+```bash
+IPGEO_COUNTRY_REDIRECTS={"US":"/us","GB":"/uk","DE":"/de"}
+```
+
+A visitor from Great Britain who opens `/pricing?ref=ad` is sent to `/uk/pricing?ref=ad`. Set `IPGEO_REDIRECT_PRESERVE_PATH=false` if you want every visitor to land on the locale home page instead.
+
+Rules that keep the feature predictable:
+
+- Only same origin paths are accepted. A value such as `https://example.com/uk` is ignored and logged, so a mistyped variable cannot turn the middleware into an open redirect.
+- A trailing slash is removed, so `{"GB":"/uk/"}` behaves the same as `{"GB":"/uk"}` rather than redirecting forever.
+- A visitor already at or below the target path is left alone.
+- With `IPGEO_REDIRECT_RESPECT_EXISTING` on, a visitor sitting on any other mapped path is also left alone, so a German visitor who deliberately opened `/uk` stays there.
+- Only `GET` and `HEAD` requests are redirected. A `POST` is never turned into a `GET`.
+
+For a visible way out, set `IPGEO_REDIRECT_SKIP_COOKIE=ipgeo_no_redirect`, then have a "stay on this site" link set that cookie.
+
+## Composing with your own middleware
+
+Next.js allows one middleware file per project, so this package exposes the decision rather than only the finished response.
```typescript
-// app/page.tsx
-import { headers } from 'next/headers';
+// middleware.ts
+import { NextResponse, type NextRequest } from 'next/server';
+import { evaluateIpGeolocation } from 'ipgeolocation-vercel-middleware/middleware';
+
+export async function middleware(request: NextRequest) {
+ const geo = await evaluateIpGeolocation(request);
-export default function Page() {
- const h = headers();
- const country = h.get('x-ipgeo-country'); // "US"
- const city = h.get('x-ipgeo-city'); // "New York City"
- const isVpn = h.get('x-ipgeo-is-vpn'); // "false"
+ // A block or a country redirect. Return it unchanged.
+ if (geo.response) return geo.response;
- return Hello from {city}, {country}!
;
+ // Your own rules, with the geo data already resolved.
+ if (geo.country === 'DE' && request.nextUrl.pathname === '/') {
+ return NextResponse.redirect(new URL('/de', request.url));
+ }
+
+ // Forward the geo headers. Returning a bare NextResponse.next() here is what
+ // drops them.
+ return NextResponse.next({ request: { headers: geo.requestHeaders } });
}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
+};
```
----
+`evaluateIpGeolocation` returns:
-## Block page
+| Field | Type | Description |
+|---|---|---|
+| `action` | `'pass'`, `'block'`, `'redirect'`, `'bypass'`, `'disabled'` | What the middleware decided |
+| `response` | `NextResponse` or `null` | The response to return, or `null` to continue |
+| `requestHeaders` | `Headers` | Request headers with geo values added and forged values removed |
+| `geo` | `IpGeoResponse` or `null` | The full API response |
+| `ip` | `string` or `null` | The resolved client IP |
+| `country` | `string` or `null` | ISO 3166-1 alpha-2 code |
+| `reason` | `string` or `null` | Why the request was blocked or redirected |
-Create `app/blocked/page.tsx` (or `pages/blocked.tsx`) to show a custom message:
+`withIpGeolocation` wraps the same pattern when you only need the happy path:
```typescript
-export default function BlockedPage({
- searchParams
-}: {
- searchParams: { reason?: string };
-}) {
- const reason = searchParams.reason ?? 'policy';
+import { NextResponse } from 'next/server';
+import { withIpGeolocation } from 'ipgeolocation-vercel-middleware/middleware';
- return (
-
- Access restricted
- Your connection was blocked ({reason}).
-
- If you believe this is a mistake, please{' '}
- contact support.
-
-
- );
+export const middleware = withIpGeolocation(async (request, geo) => {
+ if (geo.country === 'US' && !request.cookies.get('age_verified')) {
+ return NextResponse.redirect(new URL('/verify', request.url));
+ }
+
+ return NextResponse.next({ request: { headers: geo.requestHeaders } });
+});
+```
+
+Configuration can also be passed in code, which is useful when a value comes from somewhere other than an environment variable:
+
+```typescript
+import { createIpGeoMiddleware } from 'ipgeolocation-vercel-middleware/middleware';
+
+export const middleware = createIpGeoMiddleware({
+ blockedCountries: new Set(['RU', 'KP']),
+ blockMode: 'deny'
+});
+```
+
+## Client IP resolution
+
+The middleware reads the first of these headers that carries a usable address: `x-vercel-forwarded-for`, `x-forwarded-for`, `x-real-ip`, `cf-connecting-ip`, `true-client-ip`. Values are validated before use, and ports, bracketed IPv6, IPv4 mapped IPv6 such as `::ffff:203.0.113.10` and zone indexes are all normalised.
+
+Private, loopback, link local, carrier grade NAT and documentation ranges are rejected, because the API answers HTTP 423 for them. Rejecting them locally saves a round trip and a log line.
+
+On Vercel, the platform sets `x-forwarded-for` from the real connection and does not forward an externally supplied value, which is why the default of reading the rightmost entry is safe there. Vercel's own [request headers documentation](https://vercel.com/docs/headers/request-headers) states that the header is overwritten to prevent spoofing, and that forwarding a custom value requires an Enterprise trusted proxy.
+
+If you run your own reverse proxy in front of the application, set `IPGEO_TRUSTED_PROXY_COUNT` to the number of hops you control and the client IP is read that many positions to the left of the rightmost entry. `IPGEO_TRUST_FIRST_XFF=true` reads the leftmost entry instead, which is only correct when something you trust rewrites the header for you.
+
+## Performance and cost
+
+**Caching.** Responses are cached in memory per IP and per module set for `IPGEO_CACHE_TTL_MS`, with a default of 60 seconds. The cache lives inside one edge isolate, so it is shared by the requests that isolate serves and is not shared across regions. A larger window cuts credit use, at the cost of reacting more slowly when an address changes reputation.
+
+**Coalescing.** Concurrent lookups for the same address share a single request, so a burst of traffic from one IP does not turn into a burst of API calls.
+
+**Cache size.** The cache is bounded by `IPGEO_CACHE_MAX_ENTRIES`. Expired entries are dropped first, then the coldest keys, so a long lived isolate cannot grow without limit.
+
+**Retries.** A timeout, a network error or a 5xx response is retried once by default with a short delay. Client errors such as 401, 403, 423 and 429 are never retried, because retrying them cannot help.
+
+**Circuit breaker.** After five consecutive failures, lookups pause for 30 seconds. During the pause the middleware applies your fail open or fail closed setting immediately instead of waiting for a timeout on every request. A single success closes the breaker again.
+
+**Credits.** Keep the matcher tight. Every static file that reaches the middleware is a lookup you pay for. Switch security rules off where you do not need them and each lookup costs 1 credit instead of 3. `IPGEO_FIELDS` reduces the response payload, though it does not change the credit cost.
+
+## Using the library directly
+
+The package root exports the building blocks without pulling `next/server` into your bundle, so you can use them in a Route Handler, a cron job or a server action.
+
+```typescript
+import {
+ lookupIpGeolocationResult,
+ getClientIp,
+ shouldBlockBySecurity
+} from 'ipgeolocation-vercel-middleware';
+
+export async function POST(request: Request) {
+ const ip = getClientIp(request.headers);
+ if (!ip) return Response.json({ error: 'No client IP' }, { status: 400 });
+
+ const result = await lookupIpGeolocationResult({
+ apiKey: process.env.IPGEOLOCATION_API_KEY!,
+ ip,
+ include: ['security']
+ });
+
+ if (!result.ok) {
+ console.error(result.reason, result.message);
+ return Response.json({ error: 'Lookup failed' }, { status: 502 });
+ }
+
+ const reason = shouldBlockBySecurity(result.data.security);
+ if (reason) return Response.json({ error: reason }, { status: 403 });
+
+ return Response.json({ country: result.data.location?.country_code2 });
}
```
-The `reason` query parameter will be one of: `vpn`, `proxy`, `tor`, `bot`, `spam`, `known_attacker`, `cloud_provider`, `threat_score`, `lookup_failed`.
+| Export | Description |
+|---|---|
+| `lookupIpGeolocation(options)` | Returns the response or `null` |
+| `lookupIpGeolocationResult(options)` | Returns the response plus the failure reason, HTTP status, cache status and credits charged |
+| `getClientIp(headers, options?)` | Resolves and validates the client IP |
+| `normalizeIp(value)` | Cleans a single address taken from a header |
+| `isPublicIp(value)` | True for a globally routable address |
+| `shouldBlockBySecurity(security, rules?)` | Evaluates the security object and returns the first matching reason |
+| `getSecurityRulesFromEnv()` | Reads the security rules from the environment |
+| `securityRulesEnabled(rules)` | True when at least one rule needs the security module |
+| `buildGeoHeaders(geo, ip, prefix?)` | Builds the `x-ipgeo-*` map |
+| `stripSpoofedGeoHeaders(headers, prefix?)` | Removes inbound headers using the prefix |
+| `parseCsvEnv`, `parseList`, `parseRedirectMap`, `normalizeInternalPath`, `envFlag`, `envNumber` | Configuration parsing helpers |
+| `getIpGeoRuntimeState()` | Cache size, in flight count, failure count and breaker state |
+| `resetIpGeoRuntimeState()` | Clears cache and breaker state, useful in tests |
+
+`lookupIpGeolocationResult` accepts `apiKey`, `ip`, `include`, `includeSecurity`, `fields`, `excludes`, `timeoutMs`, `retries`, `cacheTtlMs`, `allowPrivateIp` and `baseUrl`.
+
+## Upgrading from 1.x
+
+Version 2.0.0 fixes behaviour that was wrong rather than merely different, so a few defaults changed. The full list is in [CHANGELOG.md](CHANGELOG.md). The points most likely to affect you:
+
+- The security module is requested only when a security rule is on. If you relied on `x-ipgeo-is-vpn` being present while every rule was off, switch on the rule you care about or pass `include: ['security']` when calling the library directly.
+- `lookupIpGeolocation` no longer sets `includeSecurity: true` by default.
+- Country redirects keep the path and query string. Set `IPGEO_REDIRECT_PRESERVE_PATH=false` for the old behaviour.
+- An allow list now blocks an unknown country. Set `IPGEO_ALLOW_UNKNOWN_COUNTRY=true` for the old behaviour.
+- Country blocks now add `?reason=country`, so a block page that switches on the reason needs a case for it.
+- `getClientIp` returns `null` for private addresses, including `127.0.0.1` in local development.
+- `IPGEO_BLOCK_TOR` defaults to `false`. The 1.0.0 documentation said the default was `true`, but the code never did that. If you want Tor blocked, set the variable explicitly.
+- Update your matcher. The 1.0.0 matcher excluded every path beginning with `blocked`, including `/blockedlist`, and still ran on static files.
+
+## Troubleshooting
+
+### No `x-ipgeo-*` headers reach my application
+
+**Cause:** the middleware never ran, the lookup never happened, or the headers were dropped downstream.
+
+**Fix:** work through these in order.
+
+1. Confirm the file is at the project root next to `app/` or `pages/`, not inside `app/`. Next.js 16 expects `proxy.ts` rather than `middleware.ts`.
+2. Confirm the request path matches the matcher. A path excluded by the matcher never reaches the middleware.
+3. Confirm `IPGEOLOCATION_API_KEY` is set in the environment you are testing. Without a key the middleware passes every request through in silence.
+4. Set `IPGEO_LOG_LEVEL=debug` and look at the runtime logs for the lookup line.
+5. If you wrote your own middleware around this one, make sure you return `NextResponse.next({ request: { headers: geo.requestHeaders } })`. A bare `NextResponse.next()` discards them.
+
+### Build fails with `It mustn't be reexported`
+
+**Cause:** `config` was re-exported from the package, as in `export { middleware, config } from 'ipgeolocation-vercel-middleware/middleware'`. Next.js reads the matcher statically at build time and cannot resolve a value that lives in a dependency.
+
+**Fix:** re-export only the function and declare `config` in your own file:
+
+```typescript
+import { middleware } from 'ipgeolocation-vercel-middleware/middleware';
+
+export { middleware };
+
+export const config = { matcher: ['/((?!_next/static|_next/image).*)'] };
+```
+
+### TypeScript cannot find `ipgeolocation-vercel-middleware/middleware`
+
+**Cause:** `moduleResolution` is set to `node`, which predates package export maps and cannot see subpath exports.
+
+**Fix:** set `"moduleResolution": "bundler"` in `tsconfig.json`, which is what `create-next-app` generates. `node16` and `nodenext` also work.
+
+### Security rules do nothing, and the logs mention HTTP 401
----
+**Cause:** the security module needs a paid plan. A free key that asks for it receives HTTP 401.
-## Full `.env.example`
+**Fix:** upgrade at [ipgeolocation.io/pricing](https://ipgeolocation.io/pricing.html), or switch the security rules off and use country rules instead. The middleware retries the lookup without the module so your site keeps working, and it stops asking for five minutes rather than repeating the error on every request. While the module is unavailable, no VPN, proxy, Tor, bot, spam, attacker or threat score rule can fire. Set `IPGEO_REQUIRE_SECURITY=true` if you would rather block traffic than let it through unverified.
+
+### Everything is blocked after I switched on fail closed
+
+**Cause:** in fail closed mode any failed lookup becomes a block. An invalid key, an exhausted quota or a network problem takes the whole site down.
+
+**Fix:** check the logs for the failure reason. `unauthorized` points at the key or the subscription, `rate_limited` at the daily quota, `timeout` at network conditions. Verify the key directly:
```bash
-# Required
-IPGEOLOCATION_API_KEY=
+curl "https://api.ipgeolocation.io/v3/ipgeo?apiKey=YOUR_KEY&ip=8.8.8.8"
+```
-# Country controls
-IPGEO_ALLOWED_COUNTRIES=
-IPGEO_BLOCKED_COUNTRIES=
-IPGEO_COUNTRY_REDIRECTS={}
+While you investigate, set `IPGEO_FAIL_CLOSED=false` or `IPGEO_ENABLED=false`.
-# Security controls
-IPGEO_BLOCK_VPN=false
-IPGEO_BLOCK_PROXY=false
-IPGEO_BLOCK_TOR=true
-IPGEO_BLOCK_CLOUD_PROVIDER=false
-IPGEO_BLOCK_BOT=false
-IPGEO_BLOCK_SPAM=false
-IPGEO_BLOCK_KNOWN_ATTACKER=false
-IPGEO_THREAT_SCORE_BLOCK_THRESHOLD=
-
-# Behaviour
-IPGEO_BLOCK_PATH=/blocked
-IPGEO_HEADER_PREFIX=x-ipgeo
-IPGEO_TIMEOUT_MS=3000
-IPGEO_CACHE_TTL_MS=60000
-IPGEO_FAIL_CLOSED=false
-IPGEO_TRUST_FIRST_XFF=false
+### Redirect loop after setting up country redirects
+
+**Cause:** in 1.x a target with a trailing slash, such as `{"GB":"/uk/"}`, redirected forever. It can also happen when the target path does not exist and your application rewrites it back.
+
+**Fix:** upgrade to 2.0.0, which normalises the target and leaves a visitor alone once they are at or below it. Then confirm the target path really exists, and that no `next.config.js` rewrite sends it back to the root.
+
+### Locally I get no data, or the logs mention a private address
+
+**Cause:** in `next dev` there is no edge network in front of you, so the request comes from `127.0.0.1`. The API answers HTTP 423 for private and bogon ranges, so the middleware skips the lookup.
+
+**Fix:** send a public address yourself while testing:
+
+```bash
+curl -H 'x-forwarded-for: 91.128.103.196' http://localhost:3000/
```
----
+### Credit usage is higher than expected
-## Security notes
+**Cause:** the matcher is too broad, the cache window is too short, or the security module is being requested when you do not need it.
-**`x-forwarded-for` and IP spoofing**
-On Vercel, this middleware reads the *last* IP in `x-forwarded-for` by default. Vercel's edge network appends the real client IP as the final entry, making it the most trustworthy value. Only set `IPGEO_TRUST_FIRST_XFF=true` if you're behind a custom reverse proxy you control.
+**Fix:** exclude static files and Next.js internals from the matcher, as in the Quick Start. Raise `IPGEO_CACHE_TTL_MS`. Switch off any security rule you are not using, which takes an uncached lookup from 3 credits back to 1. Set `IPGEO_LOG_LEVEL=debug` to see the credits charged per lookup, and check your usage in the [dashboard](https://app.ipgeolocation.io).
-**Fail-open vs fail-closed**
-By default, if the IPGeolocation.io API is unavailable the middleware passes the request through (`fail-open`). This keeps your app available during API outages. Set `IPGEO_FAIL_CLOSED=true` if availability of geo-blocking is more important than uptime.
+### The wrong country is detected, or every visitor looks the same
----
+**Cause:** the address being looked up is a proxy rather than the visitor.
-## License
+**Fix:** log `x-ipgeo-ip` and compare it with the visitor's real address. If it belongs to your own infrastructure, set `IPGEO_TRUSTED_PROXY_COUNT` to the number of proxies you run in front of the application. On Vercel without a custom proxy, leave both `IPGEO_TRUST_FIRST_XFF` and `IPGEO_TRUSTED_PROXY_COUNT` at their defaults.
-MIT — see [LICENSE](LICENSE).
+### Search engines stopped indexing the site
----
+**Cause:** a crawler was blocked. Country rules and `IPGEO_BLOCK_CLOUD_PROVIDER` both catch crawlers, since they run from datacenter ranges.
-## Links
+**Fix:** keep `IPGEO_ALLOW_KNOWN_GOOD_BOTS=true`, which is the default, so crawlers the API marks as known good survive `IPGEO_BLOCK_BOT`. Be careful with `IPGEO_BLOCK_CLOUD_PROVIDER`, which does not make that distinction. Make sure your block page sends `noindex`, and exclude `robots.txt` and `sitemap.xml` from the matcher.
+
+### Health checks and webhooks are being blocked
+
+**Cause:** monitoring services and webhook senders run from cloud ranges and from countries your visitors do not use.
+
+**Fix:** list their paths in `IPGEO_BYPASS_PATHS`, list their addresses in `IPGEO_BYPASS_IPS`, or give the monitor a header with `IPGEO_BYPASS_TOKEN`.
+
+## Development
+
+```bash
+git clone https://github.com/ipgeolocation/vercel-middleware
+cd vercel-middleware
+npm install
+npm test # 177 unit tests across the library and the middleware
+npm run typecheck # strict TypeScript, no emit
+npm run build # ESM, CommonJS and declarations into dist/
+```
+
+Issues and pull requests are welcome at [github.com/ipgeolocation/vercel-middleware](https://github.com/ipgeolocation/vercel-middleware/issues).
+
+## Support and resources
+
+| Resource | Link |
+|---|---|
+| IPGeolocation.io homepage | [https://ipgeolocation.io](https://ipgeolocation.io) |
+| IP Geolocation API documentation | [https://ipgeolocation.io/documentation/ip-location-api.html](https://ipgeolocation.io/documentation/ip-location-api.html) |
+| IP Security API documentation | [https://ipgeolocation.io/documentation/ip-security-api.html](https://ipgeolocation.io/documentation/ip-security-api.html) |
+| Credits usage guide | [https://ipgeolocation.io/documentation/credits-usage.html](https://ipgeolocation.io/documentation/credits-usage.html) |
+| API pricing | [https://ipgeolocation.io/pricing.html](https://ipgeolocation.io/pricing.html) |
+| Account dashboard | [https://app.ipgeolocation.io/dashboard](https://app.ipgeolocation.io/dashboard) |
+| All integrations | [https://ipgeolocation.io/integrations.html](https://ipgeolocation.io/integrations.html) |
+| Contact support | [https://ipgeolocation.io/contact.html](https://ipgeolocation.io/contact.html) |
+
+## Frequently Asked Questions
+
+
+Do I need a paid plan to use this middleware?
+No. Geolocation headers, country allow and block lists, and country redirects all work on the free plan, which includes 1,000 requests per day. The security rules, which cover VPN, proxy, Tor, relay, bot, spam, attacker and threat score, need a paid plan. The middleware requests the security module only when one of those rules is switched on, so a free key is never asked for something it cannot have.
+
+
+
+How many credits does each request cost?
+An uncached lookup costs 1 credit with no security rules active, and 3 credits with any of them active, because the security module adds 2 credits to the base lookup. A cached lookup costs nothing. With the default 60 second cache, a visitor who loads ten pages in a minute costs one lookup on that edge isolate. The exact charge for a request is returned in the `X-Credits-Charged` header, which the middleware reports at debug log level.
+
+
+
+How much latency does this add?
+A cached lookup adds no network call. An uncached lookup adds one API round trip from the edge region, bounded by `IPGEO_TIMEOUT_MS`, which defaults to 3 seconds. During an API incident the circuit breaker stops the middleware from spending that timeout on every request.
+
+
+
+Does it work outside Vercel?
+Yes. The middleware uses standard Next.js APIs and the Web Fetch API, so it runs anywhere Next.js middleware runs, including self hosted Node.js. The only platform specific part is client IP resolution. Outside Vercel, check which header your proxy sets and configure `IPGEO_TRUSTED_PROXY_COUNT` or `IPGEO_TRUST_FIRST_XFF` accordingly.
+
+
+
+Does it work with Next.js 16?
+Yes. Next.js 16 renamed `middleware.ts` to `proxy.ts` and expects an exported function named `proxy`, which the package exports alongside `middleware`. Create `proxy.ts` at your project root and re-export `proxy` from it.
+
+
+
+Can a visitor fake the geolocation headers?
+Not through these headers. Every inbound header that uses your prefix is removed before the request continues, on every path, including those where no lookup runs. What a visitor can still do is use a VPN or a proxy to change which country the API sees, which is what the security rules are for.
+
+
+
+Why is there no country for some visitors?
+Either no usable client IP was present, or the lookup did not complete. In both cases the request passes through with no geo headers, so write your application to treat a missing header as unknown rather than assuming a default country. Set `IPGEO_FAIL_CLOSED=true` if an unknown visitor should be blocked instead.
+
+
+
+Is the cache shared between requests and regions?
+The cache lives in memory inside one edge isolate, so it is shared by the requests that isolate serves and is not shared across regions or across a new deployment. That is the correct trade off for middleware, where an external cache would add the latency the cache is meant to remove. Raise `IPGEO_CACHE_TTL_MS` if you want fewer lookups per isolate.
+
+
+
+Can I use both a country allow list and a block list?
+Yes. The allow list is evaluated first, then the block list, so a country missing from the allow list is blocked even if it is not on the block list. Most setups need only one of the two.
+
+
+
+Will blocking bots hurt my search ranking?
+It should not, as long as `IPGEO_ALLOW_KNOWN_GOOD_BOTS` stays at its default of `true`, which keeps crawlers the API identifies as known good. Take more care with `IPGEO_BLOCK_CLOUD_PROVIDER`, because crawlers run from datacenter ranges and that rule does not separate good from bad. Keep `robots.txt` and `sitemap.xml` out of the matcher, and send `noindex` from the block page.
+
+
+
+Can I test the rules without affecting real traffic?
+Yes. Vercel keeps environment variables separate for Production, Preview and Development, so switch the rules on for Preview first. `IPGEO_ENABLED=false` turns everything off without a redeploy of your code, and `IPGEO_BYPASS_TOKEN` lets you reach the site while a rule is active.
+
+
+
+Does it support IPv6?
+Yes. IPv6 addresses are validated and normalised, including bracketed forms with a port, zone indexes and IPv4 mapped addresses, and the API resolves IPv6 the same way it resolves IPv4.
+
+
+
+Can I add my own rules on top of it?
+Yes. Use `evaluateIpGeolocation` or `withIpGeolocation`, both shown in the composing section above. They return the decision and the prepared request headers so your own logic runs in the same middleware without losing the geo data.
+
+
+
+How do I get an API key?
+Sign up at app.ipgeolocation.io/signup and copy the key from your dashboard. The free plan needs no card. Keep the key in an environment variable and never in client side code, because the API authenticates with the key as a query parameter.
+
+
+## License
-- [IPGeolocation.io](https://ipgeolocation.io)
-- [API documentation](https://ipgeolocation.io/documentation.html)
-- [Get a free API key](https://app.ipgeolocation.io/signup)
-- [Report an issue](https://github.com/ipgeolocation/vercel-middleware/issues)
+MIT. See [LICENSE](https://github.com/ipgeolocation/vercel-middleware/blob/main/LICENSE).
\ No newline at end of file
diff --git a/examples/app/blocked/page.tsx b/examples/app/blocked/page.tsx
new file mode 100644
index 0000000..cfd6719
--- /dev/null
+++ b/examples/app/blocked/page.tsx
@@ -0,0 +1,46 @@
+// A block page for the App Router. Save as app/blocked/page.tsx.
+//
+// In Next.js 15 and later searchParams is a Promise. On Next.js 13 and 14 it is
+// a plain object, so drop the await and the Promise type.
+
+export const metadata = {
+ title: 'Access restricted',
+ robots: { index: false, follow: false }
+};
+
+const REASONS: Record = {
+ country: 'This site is not available in your country.',
+ vpn: 'Connections through a VPN are not accepted.',
+ proxy: 'Connections through a proxy are not accepted.',
+ residential_proxy: 'Connections through a residential proxy are not accepted.',
+ tor: 'Connections through the Tor network are not accepted.',
+ relay: 'Connections through a relay network are not accepted.',
+ cloud_provider: 'Connections from cloud and hosting networks are not accepted.',
+ bot: 'This request looks automated.',
+ spam: 'This address is associated with spam activity.',
+ known_attacker: 'This address is associated with attack activity.',
+ anonymous: 'Anonymised connections are not accepted.',
+ threat_score: 'This address has a high risk score.',
+ lookup_failed: 'We could not verify your connection.',
+ security_unavailable: 'We could not verify your connection.'
+};
+
+export default async function BlockedPage({
+ searchParams
+}: {
+ searchParams: Promise<{ reason?: string }>;
+}) {
+ const { reason } = await searchParams;
+ const message = (reason && REASONS[reason]) || 'Access to this site is restricted.';
+
+ return (
+
+ Access restricted
+ {message}
+
+ If you think this is a mistake, contact{' '}
+ support@example.com.
+
+
+ );
+}
diff --git a/examples/app/page.tsx b/examples/app/page.tsx
new file mode 100644
index 0000000..24b0bbe
--- /dev/null
+++ b/examples/app/page.tsx
@@ -0,0 +1,25 @@
+// Reading the geo headers in a Server Component. Save as app/page.tsx.
+//
+// In Next.js 15 and later headers() is async. On Next.js 13 and 14 it is
+// synchronous, so drop the await.
+
+import { headers } from 'next/headers';
+
+export default async function Page() {
+ const h = await headers();
+
+ const country = h.get('x-ipgeo-country');
+ const city = h.get('x-ipgeo-city');
+ const currency = h.get('x-ipgeo-currency');
+
+ if (!country) return We could not work out where you are.
;
+
+ return (
+
+
+ Hello from {city ?? 'your city'}, {country}
+
+ Prices are shown in {currency ?? 'USD'}.
+
+ );
+}
diff --git a/examples/middleware-composed.ts b/examples/middleware-composed.ts
new file mode 100644
index 0000000..453b314
--- /dev/null
+++ b/examples/middleware-composed.ts
@@ -0,0 +1,27 @@
+// Running IPGeolocation.io alongside your own middleware logic.
+//
+// evaluateIpGeolocation makes the decision without sending a response, so you
+// can add your own rules and still forward the geo headers to the application.
+
+import { NextResponse, type NextRequest } from 'next/server';
+import { evaluateIpGeolocation } from 'ipgeolocation-vercel-middleware/middleware';
+
+export async function middleware(request: NextRequest) {
+ const geo = await evaluateIpGeolocation(request);
+
+ // A block or a country redirect. Return it as is.
+ if (geo.response) return geo.response;
+
+ // Your own rules, with the geo data already available.
+ if (geo.country === 'DE' && request.nextUrl.pathname === '/') {
+ return NextResponse.redirect(new URL('/de', request.url));
+ }
+
+ // Forward the request with the geo headers attached. Skipping this line is
+ // what drops the x-ipgeo-* headers.
+ return NextResponse.next({ request: { headers: geo.requestHeaders } });
+}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
+};
diff --git a/examples/middleware.ts b/examples/middleware.ts
new file mode 100644
index 0000000..882d9cc
--- /dev/null
+++ b/examples/middleware.ts
@@ -0,0 +1,14 @@
+// Drop in middleware for Next.js 13, 14 and 15.
+// Copy this file to middleware.ts at your project root.
+
+import { middleware } from 'ipgeolocation-vercel-middleware/middleware';
+
+export { middleware };
+
+// Define the matcher here. Next.js reads it at build time and cannot follow a
+// value that was re-exported from a package.
+export const config = {
+ matcher: [
+ '/((?!_next/static|_next/image|_next/data|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|js|mjs|map|json|txt|xml|ico|png|jpg|jpeg|gif|webp|avif|svg|woff|woff2|ttf|otf|eot|mp4|webm|mp3|pdf|zip)$).*)'
+ ]
+};
diff --git a/examples/proxy.ts b/examples/proxy.ts
new file mode 100644
index 0000000..df223ae
--- /dev/null
+++ b/examples/proxy.ts
@@ -0,0 +1,13 @@
+// Drop in proxy for Next.js 16.
+// Copy this file to proxy.ts at your project root. Next.js 16 renamed
+// middleware.ts to proxy.ts and expects an exported function named proxy.
+
+import { proxy } from 'ipgeolocation-vercel-middleware/middleware';
+
+export { proxy };
+
+export const config = {
+ matcher: [
+ '/((?!_next/static|_next/image|_next/data|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|js|mjs|map|json|txt|xml|ico|png|jpg|jpeg|gif|webp|avif|svg|woff|woff2|ttf|otf|eot|mp4|webm|mp3|pdf|zip)$).*)'
+ ]
+};
diff --git a/index.ts b/index.ts
index c761d23..9cce548 100644
--- a/index.ts
+++ b/index.ts
@@ -1,13 +1,44 @@
+// =============================================================================
// ipgeolocation-vercel-middleware
-// Public API — re-exports everything consumers need
+//
+// Public API. The middleware itself lives at the ./middleware subpath so that
+// importing these helpers into a Server Component or a Route Handler does not
+// pull next/server into the bundle.
+// =============================================================================
-export type { IpGeoResponse, IpGeoSecurity } from './ipgeolocation-edge.js';
+export type {
+ ClientIpOptions,
+ GeoHeaderMap,
+ IpGeoLocation,
+ IpGeoResponse,
+ IpGeoSecurity,
+ LogLevel,
+ LookupFailureReason,
+ LookupOptions,
+ LookupResult,
+ SecurityBlockReason,
+ SecurityRules
+} from './ipgeolocation-edge.js';
export {
- lookupIpGeolocation,
+ IPGEO_API_BASE_URL,
+ buildGeoHeaders,
+ envFlag,
+ envNumber,
getClientIp,
- shouldBlockBySecurity,
+ getIpGeoRuntimeState,
+ getSecurityRulesFromEnv,
+ isPublicIp,
+ isUnderPath,
+ lookupIpGeolocation,
+ lookupIpGeolocationResult,
+ normalizeInternalPath,
+ normalizeIp,
parseCsvEnv,
+ parseList,
parseRedirectMap,
- envFlag
+ resetIpGeoRuntimeState,
+ securityRulesEnabled,
+ shouldBlockBySecurity,
+ stripSpoofedGeoHeaders
} from './ipgeolocation-edge.js';
diff --git a/ipgeolocation-edge.test.ts b/ipgeolocation-edge.test.ts
index 8071c29..5132830 100644
--- a/ipgeolocation-edge.test.ts
+++ b/ipgeolocation-edge.test.ts
@@ -1,391 +1,903 @@
-// ─────────────────────────────────────────────────────────────────────────────
-// Tests for src/lib/ipgeolocation-edge.ts
-// Run: npm test
-// ─────────────────────────────────────────────────────────────────────────────
+// =============================================================================
+// Tests for ipgeolocation-edge.ts
+// Run with: npm test
+// =============================================================================
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import {
+ buildGeoHeaders,
envFlag,
+ envNumber,
+ getClientIp,
+ getIpGeoRuntimeState,
+ getSecurityRulesFromEnv,
+ isPublicIp,
+ isUnderPath,
+ lookupIpGeolocation,
+ lookupIpGeolocationResult,
+ normalizeInternalPath,
+ normalizeIp,
parseCsvEnv,
+ parseList,
parseRedirectMap,
- getClientIp,
+ resetIpGeoRuntimeState,
+ securityRulesEnabled,
shouldBlockBySecurity,
- lookupIpGeolocation,
+ stripSpoofedGeoHeaders,
+ type IpGeoResponse,
type IpGeoSecurity,
- type IpGeoResponse
+ type SecurityRules
} from './ipgeolocation-edge.js';
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
// Helpers
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
+
+const IPGEO_KEYS = [
+ 'IPGEOLOCATION_API_KEY',
+ 'IPGEO_ALLOWED_COUNTRIES',
+ 'IPGEO_ALLOW_KNOWN_GOOD_BOTS',
+ 'IPGEO_BLOCKED_COUNTRIES',
+ 'IPGEO_BLOCK_ANONYMOUS',
+ 'IPGEO_BLOCK_BOT',
+ 'IPGEO_BLOCK_CLOUD_PROVIDER',
+ 'IPGEO_BLOCK_KNOWN_ATTACKER',
+ 'IPGEO_BLOCK_PROXY',
+ 'IPGEO_BLOCK_RELAY',
+ 'IPGEO_BLOCK_RESIDENTIAL_PROXY',
+ 'IPGEO_BLOCK_SPAM',
+ 'IPGEO_BLOCK_TOR',
+ 'IPGEO_BLOCK_VPN',
+ 'IPGEO_CACHE_MAX_ENTRIES',
+ 'IPGEO_CACHE_TTL_MS',
+ 'IPGEO_CIRCUIT_COOLDOWN_MS',
+ 'IPGEO_CIRCUIT_FAILURE_THRESHOLD',
+ 'IPGEO_COUNTRY_REDIRECTS',
+ 'IPGEO_LOG_LEVEL',
+ 'IPGEO_RETRIES',
+ 'IPGEO_THREAT_SCORE_BLOCK_THRESHOLD',
+ 'IPGEO_TIMEOUT_MS'
+];
+
+function clearIpGeoEnv(): void {
+ for (const key of IPGEO_KEYS) delete process.env[key];
+}
function makeHeaders(entries: Record): Headers {
- const h = new Headers();
- for (const [k, v] of Object.entries(entries)) h.set(k, v);
- return h;
+ const headers = new Headers();
+ for (const [key, value] of Object.entries(entries)) headers.set(key, value);
+ return headers;
}
-function setEnv(vars: Record) {
- for (const [k, v] of Object.entries(vars)) {
- if (v === undefined) delete process.env[k];
- else process.env[k] = v;
- }
+function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ ...init
+ });
}
-// ─────────────────────────────────────────────────────────────────────────────
+const SAMPLE: IpGeoResponse = {
+ ip: '91.128.103.196',
+ location: {
+ continent_code: 'EU',
+ country_code2: 'SE',
+ country_name: 'Sweden',
+ state_prov: 'Stockholms',
+ state_code: 'SE-AB',
+ city: 'Stockholm',
+ zipcode: '164 40',
+ latitude: '59.40510',
+ longitude: '17.95510',
+ is_eu: true
+ },
+ currency: { code: 'SEK' },
+ asn: { as_number: 'AS1257', organization: 'Tele2 Sverige AB' },
+ company: { name: 'Tele2 Sverige AB' },
+ time_zone: { name: 'Europe/Stockholm' }
+};
+
+beforeEach(() => {
+ clearIpGeoEnv();
+ process.env.IPGEO_LOG_LEVEL = 'silent';
+ resetIpGeoRuntimeState();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ clearIpGeoEnv();
+});
+
+// -----------------------------------------------------------------------------
// envFlag
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
describe('envFlag', () => {
- it('returns true for "true"', () => expect(envFlag('true')).toBe(true));
- it('returns true for "TRUE"', () => expect(envFlag('TRUE')).toBe(true));
- it('returns true for "True"', () => expect(envFlag('True')).toBe(true));
- it('returns false for "false"', () => expect(envFlag('false')).toBe(false));
- it('returns false for "1"', () => expect(envFlag('1')).toBe(false));
- it('returns false for empty string', () => expect(envFlag('')).toBe(false));
- it('returns false for undefined', () => expect(envFlag(undefined)).toBe(false));
+ it.each(['true', 'TRUE', 'True', '1', 'yes', 'on'])('reads %s as true', (value) => {
+ expect(envFlag(value)).toBe(true);
+ });
+
+ it.each(['false', '0', 'no', 'off', ''])('reads %s as false', (value) => {
+ expect(envFlag(value)).toBe(false);
+ });
+
+ it('returns the fallback for undefined', () => {
+ expect(envFlag(undefined)).toBe(false);
+ expect(envFlag(undefined, true)).toBe(true);
+ });
+
+ it('returns the fallback for an unrecognised value', () => {
+ expect(envFlag('maybe', true)).toBe(true);
+ });
});
-// ─────────────────────────────────────────────────────────────────────────────
-// parseCsvEnv
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
+// envNumber
+// -----------------------------------------------------------------------------
-describe('parseCsvEnv', () => {
- it('parses a single value', () => {
- expect(parseCsvEnv('US')).toEqual(new Set(['US']));
+describe('envNumber', () => {
+ it('parses a numeric string', () => {
+ expect(envNumber('250', 100)).toBe(250);
});
- it('parses multiple values', () => {
- expect(parseCsvEnv('US,CA,GB')).toEqual(new Set(['US', 'CA', 'GB']));
+ it('falls back for an empty or missing value', () => {
+ expect(envNumber(undefined, 42)).toBe(42);
+ expect(envNumber(' ', 42)).toBe(42);
});
- it('uppercases values', () => {
- expect(parseCsvEnv('us,ca')).toEqual(new Set(['US', 'CA']));
+ it('falls back for a value that is not a number', () => {
+ expect(envNumber('soon', 42)).toBe(42);
});
- it('trims whitespace', () => {
- expect(parseCsvEnv(' US , CA , GB ')).toEqual(new Set(['US', 'CA', 'GB']));
+ it('clamps to the range', () => {
+ expect(envNumber('500', 10, { max: 100 })).toBe(100);
+ expect(envNumber('-5', 10, { min: 0 })).toBe(0);
});
+});
+
+// -----------------------------------------------------------------------------
+// parseCsvEnv and parseList
+// -----------------------------------------------------------------------------
- it('filters empty segments', () => {
+describe('parseCsvEnv', () => {
+ it('parses, trims and uppercases country codes', () => {
+ expect(parseCsvEnv(' us , ca ,gb ')).toEqual(new Set(['US', 'CA', 'GB']));
+ });
+
+ it('drops empty segments', () => {
expect(parseCsvEnv('US,,CA,')).toEqual(new Set(['US', 'CA']));
});
- it('returns empty Set for undefined', () => {
+ it('drops values that are not two letter codes', () => {
+ expect(parseCsvEnv('US,USA,1,GB')).toEqual(new Set(['US', 'GB']));
+ });
+
+ it('returns an empty set for undefined', () => {
expect(parseCsvEnv(undefined)).toEqual(new Set());
});
+});
+
+describe('parseList', () => {
+ it('splits and trims', () => {
+ expect(parseList(' /health , /status ')).toEqual(['/health', '/status']);
+ });
- it('returns empty Set for empty string', () => {
- expect(parseCsvEnv('')).toEqual(new Set());
+ it('returns an empty array for undefined', () => {
+ expect(parseList(undefined)).toEqual([]);
});
});
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
+// normalizeInternalPath and isUnderPath
+// -----------------------------------------------------------------------------
+
+describe('normalizeInternalPath', () => {
+ it('keeps a simple path', () => {
+ expect(normalizeInternalPath('/uk')).toBe('/uk');
+ });
+
+ it('removes a trailing slash, which is what caused redirect loops in 1.x', () => {
+ expect(normalizeInternalPath('/uk/')).toBe('/uk');
+ });
+
+ it('keeps the root path', () => {
+ expect(normalizeInternalPath('/')).toBe('/');
+ });
+
+ it('rejects absolute URLs', () => {
+ expect(normalizeInternalPath('https://example.com/uk')).toBeNull();
+ });
+
+ it('rejects protocol relative values', () => {
+ expect(normalizeInternalPath('//evil.example')).toBeNull();
+ });
+
+ it('rejects backslash variants', () => {
+ expect(normalizeInternalPath('/\\evil.example')).toBeNull();
+ });
+
+ it('rejects values without a leading slash', () => {
+ expect(normalizeInternalPath('uk')).toBeNull();
+ });
+});
+
+describe('isUnderPath', () => {
+ it('matches the path itself', () => {
+ expect(isUnderPath('/blocked', '/blocked')).toBe(true);
+ });
+
+ it('matches a child path', () => {
+ expect(isUnderPath('/blocked/details', '/blocked')).toBe(true);
+ });
+
+ it('does not match a path that only shares a prefix', () => {
+ expect(isUnderPath('/blockedlist', '/blocked')).toBe(false);
+ });
+});
+
+// -----------------------------------------------------------------------------
// parseRedirectMap
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
describe('parseRedirectMap', () => {
- it('parses valid JSON', () => {
- expect(parseRedirectMap('{"US":"/us","GB":"/uk"}')).toEqual({
- US: '/us',
- GB: '/uk'
- });
+ it('parses and uppercases the keys', () => {
+ expect(parseRedirectMap('{"us":"/us","gb":"/uk"}')).toEqual({ US: '/us', GB: '/uk' });
});
- it('uppercases country keys', () => {
- expect(parseRedirectMap('{"us":"/us","pk":"/pk"}')).toEqual({
- US: '/us',
- PK: '/pk'
- });
+ it('normalizes trailing slashes in targets', () => {
+ expect(parseRedirectMap('{"GB":"/uk/"}')).toEqual({ GB: '/uk' });
});
- it('returns empty object for undefined', () => {
- expect(parseRedirectMap(undefined)).toEqual({});
+ it('returns an empty object for invalid JSON', () => {
+ expect(parseRedirectMap('{oops')).toEqual({});
});
- it('returns empty object for empty string', () => {
+ it('returns an empty object for an empty value', () => {
expect(parseRedirectMap('')).toEqual({});
+ expect(parseRedirectMap('{}')).toEqual({});
+ expect(parseRedirectMap(undefined)).toEqual({});
+ });
+
+ it('returns an empty object for a JSON array', () => {
+ expect(parseRedirectMap('["/uk"]')).toEqual({});
});
- it('returns empty object and warns on invalid JSON', () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
- expect(parseRedirectMap('not-json')).toEqual({});
- expect(warn).toHaveBeenCalledOnce();
- warn.mockRestore();
+ it('drops off site targets so the map cannot become an open redirect', () => {
+ expect(parseRedirectMap('{"GB":"https://evil.example"}')).toEqual({});
+ expect(parseRedirectMap('{"GB":"//evil.example"}')).toEqual({});
});
- it('includes the bad value in the warning', () => {
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
- parseRedirectMap('bad-value');
- expect(warn.mock.calls[0]?.[1]).toContain('bad-value');
- warn.mockRestore();
+ it('drops keys that are not country codes', () => {
+ expect(parseRedirectMap('{"EUROPE":"/eu","GB":"/uk"}')).toEqual({ GB: '/uk' });
});
});
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
+// normalizeIp and isPublicIp
+// -----------------------------------------------------------------------------
+
+describe('normalizeIp', () => {
+ it('accepts a plain IPv4 address', () => {
+ expect(normalizeIp('203.0.113.10')).toBe('203.0.113.10');
+ });
+
+ it('strips a port from an IPv4 address', () => {
+ expect(normalizeIp('203.0.113.10:51234')).toBe('203.0.113.10');
+ });
+
+ it('unwraps a bracketed IPv6 address with a port', () => {
+ expect(normalizeIp('[2001:4860:4860::8888]:443')).toBe('2001:4860:4860::8888');
+ });
+
+ it('unwraps an IPv4 mapped IPv6 address', () => {
+ expect(normalizeIp('::ffff:8.8.8.8')).toBe('8.8.8.8');
+ });
+
+ it('removes an IPv6 zone index', () => {
+ expect(normalizeIp('fe80::1%eth0')).toBe('fe80::1');
+ });
+
+ it('strips surrounding quotes', () => {
+ expect(normalizeIp('"8.8.8.8"')).toBe('8.8.8.8');
+ });
+
+ it('rejects malformed values', () => {
+ expect(normalizeIp('999.1.1.1')).toBeNull();
+ expect(normalizeIp('not-an-ip')).toBeNull();
+ expect(normalizeIp('')).toBeNull();
+ expect(normalizeIp(undefined)).toBeNull();
+ });
+});
+
+describe('isPublicIp', () => {
+ it.each(['8.8.8.8', '91.128.103.196', '2001:4860:4860::8888'])('accepts %s', (ip) => {
+ expect(isPublicIp(ip)).toBe(true);
+ });
+
+ it.each([
+ '127.0.0.1',
+ '10.0.0.5',
+ '172.16.0.1',
+ '192.168.1.1',
+ '169.254.1.1',
+ '100.64.0.1',
+ '0.0.0.0',
+ '224.0.0.1',
+ '::1',
+ 'fe80::1',
+ 'fd00::1'
+ ])('rejects %s', (ip) => {
+ expect(isPublicIp(ip)).toBe(false);
+ });
+});
+
+// -----------------------------------------------------------------------------
// getClientIp
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
describe('getClientIp', () => {
- it('returns last IP from x-forwarded-for by default (Vercel-safe)', () => {
- const headers = makeHeaders({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8, 9.10.11.12' });
- expect(getClientIp(headers)).toBe('9.10.11.12');
+ it('reads a single x-forwarded-for entry', () => {
+ expect(getClientIp(makeHeaders({ 'x-forwarded-for': '8.8.8.8' }))).toBe('8.8.8.8');
+ });
+
+ it('reads the rightmost entry by default', () => {
+ const headers = makeHeaders({ 'x-forwarded-for': '1.1.1.1, 9.9.9.9' });
+ expect(getClientIp(headers)).toBe('9.9.9.9');
+ });
+
+ it('reads the leftmost entry when trustFirstXff is set', () => {
+ const headers = makeHeaders({ 'x-forwarded-for': '1.1.1.1, 9.9.9.9' });
+ expect(getClientIp(headers, { trustFirstXff: true })).toBe('1.1.1.1');
});
- it('returns first IP from x-forwarded-for when trustFirstXff=true', () => {
- const headers = makeHeaders({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' });
- expect(getClientIp(headers, true)).toBe('1.2.3.4');
+ it('accepts a boolean for compatibility with 1.x', () => {
+ const headers = makeHeaders({ 'x-forwarded-for': '1.1.1.1, 9.9.9.9' });
+ expect(getClientIp(headers, true)).toBe('1.1.1.1');
});
- it('handles single IP in x-forwarded-for', () => {
- const headers = makeHeaders({ 'x-forwarded-for': '1.2.3.4' });
- expect(getClientIp(headers)).toBe('1.2.3.4');
+ it('skips the proxies you control when trustedProxyCount is set', () => {
+ const headers = makeHeaders({ 'x-forwarded-for': '8.8.8.8, 9.9.9.9, 1.1.1.1' });
+ expect(getClientIp(headers, { trustedProxyCount: 1 })).toBe('9.9.9.9');
});
- it('trims whitespace around IPs', () => {
- const headers = makeHeaders({ 'x-forwarded-for': ' 1.2.3.4 , 5.6.7.8 ' });
- expect(getClientIp(headers)).toBe('5.6.7.8');
+ it('prefers x-vercel-forwarded-for when a proxy sits in front of Vercel', () => {
+ const headers = makeHeaders({
+ 'x-vercel-forwarded-for': '8.8.8.8',
+ 'x-forwarded-for': '203.0.113.9'
+ });
+ expect(getClientIp(headers)).toBe('8.8.8.8');
});
it('falls back to x-real-ip', () => {
- const headers = makeHeaders({ 'x-real-ip': '1.2.3.4' });
- expect(getClientIp(headers)).toBe('1.2.3.4');
+ expect(getClientIp(makeHeaders({ 'x-real-ip': '8.8.4.4' }))).toBe('8.8.4.4');
});
it('falls back to cf-connecting-ip', () => {
- const headers = makeHeaders({ 'cf-connecting-ip': '1.2.3.4' });
- expect(getClientIp(headers)).toBe('1.2.3.4');
+ expect(getClientIp(makeHeaders({ 'cf-connecting-ip': '8.8.4.4' }))).toBe('8.8.4.4');
});
- it('prefers x-forwarded-for over x-real-ip', () => {
- const headers = makeHeaders({
- 'x-forwarded-for': '1.1.1.1, 2.2.2.2',
- 'x-real-ip': '9.9.9.9'
- });
- expect(getClientIp(headers)).toBe('2.2.2.2');
+ it('returns null when no header carries an IP', () => {
+ expect(getClientIp(makeHeaders({}))).toBeNull();
+ });
+
+ it('returns null for a private address, which is what happens locally', () => {
+ expect(getClientIp(makeHeaders({ 'x-forwarded-for': '127.0.0.1' }))).toBeNull();
+ });
+
+ it('returns a private address when allowPrivate is set', () => {
+ const headers = makeHeaders({ 'x-forwarded-for': '127.0.0.1' });
+ expect(getClientIp(headers, { allowPrivate: true })).toBe('127.0.0.1');
});
- it('returns null when no IP headers present', () => {
- expect(getClientIp(new Headers())).toBeNull();
+ it('ignores a garbage value and moves to the next header', () => {
+ const headers = makeHeaders({ 'x-forwarded-for': 'unknown', 'x-real-ip': '8.8.8.8' });
+ expect(getClientIp(headers)).toBe('8.8.8.8');
});
- it('returns null for empty x-forwarded-for', () => {
- const headers = makeHeaders({ 'x-forwarded-for': ' ' });
- expect(getClientIp(headers)).toBeNull();
+ it('normalizes a port suffix', () => {
+ expect(getClientIp(makeHeaders({ 'x-forwarded-for': '8.8.8.8:1234' }))).toBe('8.8.8.8');
});
});
-// ─────────────────────────────────────────────────────────────────────────────
-// shouldBlockBySecurity
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
+// Security rules
+// -----------------------------------------------------------------------------
+
+function rules(overrides: Partial = {}): SecurityRules {
+ return {
+ blockVpn: false,
+ blockProxy: false,
+ blockResidentialProxy: false,
+ blockTor: false,
+ blockRelay: false,
+ blockCloudProvider: false,
+ blockBot: false,
+ blockSpam: false,
+ blockKnownAttacker: false,
+ blockAnonymous: false,
+ allowKnownGoodBots: true,
+ threatScoreThreshold: 0,
+ ...overrides
+ };
+}
-describe('shouldBlockBySecurity', () => {
- beforeEach(() => {
- setEnv({
- IPGEO_BLOCK_VPN: 'false',
- IPGEO_BLOCK_PROXY: 'false',
- IPGEO_BLOCK_TOR: 'false',
- IPGEO_BLOCK_CLOUD_PROVIDER: 'false',
- IPGEO_BLOCK_BOT: 'false',
- IPGEO_BLOCK_SPAM: 'false',
- IPGEO_BLOCK_KNOWN_ATTACKER: 'false',
- IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: ''
- });
+describe('getSecurityRulesFromEnv', () => {
+ it('defaults every block rule to off', () => {
+ const parsed = getSecurityRulesFromEnv();
+ expect(securityRulesEnabled(parsed)).toBe(false);
+ expect(parsed.blockTor).toBe(false);
});
- it('returns null for undefined security', () => {
- expect(shouldBlockBySecurity(undefined)).toBeNull();
+ it('reads the flags from the environment', () => {
+ process.env.IPGEO_BLOCK_TOR = 'true';
+ process.env.IPGEO_THREAT_SCORE_BLOCK_THRESHOLD = '75';
+
+ const parsed = getSecurityRulesFromEnv();
+ expect(parsed.blockTor).toBe(true);
+ expect(parsed.threatScoreThreshold).toBe(75);
+ expect(securityRulesEnabled(parsed)).toBe(true);
});
- it('returns null when all flags disabled and score below threshold', () => {
- const security: IpGeoSecurity = {
- is_vpn: true,
- is_proxy: true,
- is_tor: true,
- threat_score: 99
- };
- expect(shouldBlockBySecurity(security)).toBeNull();
+ it('clamps the threat score threshold to 0 through 100', () => {
+ process.env.IPGEO_THREAT_SCORE_BLOCK_THRESHOLD = '500';
+ expect(getSecurityRulesFromEnv().threatScoreThreshold).toBe(100);
});
- it('blocks VPN when IPGEO_BLOCK_VPN=true', () => {
- setEnv({ IPGEO_BLOCK_VPN: 'true' });
- expect(shouldBlockBySecurity({ is_vpn: true })).toBe('vpn');
+ it('keeps known good bots by default', () => {
+ expect(getSecurityRulesFromEnv().allowKnownGoodBots).toBe(true);
});
+});
+
+describe('shouldBlockBySecurity', () => {
+ const security: IpGeoSecurity = {
+ threat_score: 80,
+ is_vpn: true,
+ is_proxy: true,
+ is_tor: true,
+ is_bot: true,
+ is_spam: true,
+ is_known_attacker: true,
+ is_cloud_provider: true,
+ is_relay: true,
+ is_anonymous: true,
+ is_residential_proxy: true
+ };
- it('does not block VPN when flag is true but is_vpn is false', () => {
- setEnv({ IPGEO_BLOCK_VPN: 'true' });
- expect(shouldBlockBySecurity({ is_vpn: false })).toBeNull();
+ it('returns null when no rule is active', () => {
+ expect(shouldBlockBySecurity(security, rules())).toBeNull();
});
- it('blocks proxy when IPGEO_BLOCK_PROXY=true', () => {
- setEnv({ IPGEO_BLOCK_PROXY: 'true' });
- expect(shouldBlockBySecurity({ is_proxy: true })).toBe('proxy');
+ it('returns null when there is no security object', () => {
+ expect(shouldBlockBySecurity(undefined, rules({ blockVpn: true }))).toBeNull();
});
- it('blocks Tor when IPGEO_BLOCK_TOR=true', () => {
- setEnv({ IPGEO_BLOCK_TOR: 'true' });
- expect(shouldBlockBySecurity({ is_tor: true })).toBe('tor');
+ it.each([
+ ['blockVpn', 'vpn'],
+ ['blockProxy', 'proxy'],
+ ['blockResidentialProxy', 'residential_proxy'],
+ ['blockTor', 'tor'],
+ ['blockRelay', 'relay'],
+ ['blockCloudProvider', 'cloud_provider'],
+ ['blockBot', 'bot'],
+ ['blockSpam', 'spam'],
+ ['blockKnownAttacker', 'known_attacker'],
+ ['blockAnonymous', 'anonymous']
+ ] as const)('%s produces the reason %s', (rule, reason) => {
+ expect(shouldBlockBySecurity(security, rules({ [rule]: true }))).toBe(reason);
});
- it('blocks cloud provider when IPGEO_BLOCK_CLOUD_PROVIDER=true', () => {
- setEnv({ IPGEO_BLOCK_CLOUD_PROVIDER: 'true' });
- expect(shouldBlockBySecurity({ is_cloud_provider: true })).toBe('cloud_provider');
+ it('blocks at or above the threat score threshold', () => {
+ expect(shouldBlockBySecurity({ threat_score: 75 }, rules({ threatScoreThreshold: 75 }))).toBe(
+ 'threat_score'
+ );
+ expect(shouldBlockBySecurity({ threat_score: 74 }, rules({ threatScoreThreshold: 75 }))).toBeNull();
});
- it('blocks bot when IPGEO_BLOCK_BOT=true', () => {
- setEnv({ IPGEO_BLOCK_BOT: 'true' });
- expect(shouldBlockBySecurity({ is_bot: true })).toBe('bot');
+ it('keeps a known good bot when bots are blocked', () => {
+ const goodBot: IpGeoSecurity = { is_bot: true, is_known_good_bot: true };
+ expect(shouldBlockBySecurity(goodBot, rules({ blockBot: true }))).toBeNull();
});
- it('blocks spam when IPGEO_BLOCK_SPAM=true', () => {
- setEnv({ IPGEO_BLOCK_SPAM: 'true' });
- expect(shouldBlockBySecurity({ is_spam: true })).toBe('spam');
+ it('blocks a known good bot when allowKnownGoodBots is off', () => {
+ const goodBot: IpGeoSecurity = { is_bot: true, is_known_good_bot: true };
+ expect(shouldBlockBySecurity(goodBot, rules({ blockBot: true, allowKnownGoodBots: false }))).toBe(
+ 'bot'
+ );
});
- it('blocks known attacker when IPGEO_BLOCK_KNOWN_ATTACKER=true', () => {
- setEnv({ IPGEO_BLOCK_KNOWN_ATTACKER: 'true' });
- expect(shouldBlockBySecurity({ is_known_attacker: true })).toBe('known_attacker');
+ it('reads the rules from the environment when none are passed', () => {
+ process.env.IPGEO_BLOCK_TOR = 'true';
+ expect(shouldBlockBySecurity({ is_tor: true })).toBe('tor');
});
+});
+
+// -----------------------------------------------------------------------------
+// Header helpers
+// -----------------------------------------------------------------------------
- it('blocks on threat score at threshold', () => {
- setEnv({ IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: '75' });
- expect(shouldBlockBySecurity({ threat_score: 75 })).toBe('threat_score');
+describe('buildGeoHeaders', () => {
+ it('maps the response onto prefixed headers', () => {
+ const headers = buildGeoHeaders(SAMPLE, '91.128.103.196');
+
+ expect(headers['x-ipgeo-country']).toBe('SE');
+ expect(headers['x-ipgeo-country-name']).toBe('Sweden');
+ expect(headers['x-ipgeo-city']).toBe('Stockholm');
+ expect(headers['x-ipgeo-timezone']).toBe('Europe/Stockholm');
+ expect(headers['x-ipgeo-asn']).toBe('AS1257');
+ expect(headers['x-ipgeo-is-eu']).toBe('true');
+ expect(headers['x-ipgeo-currency']).toBe('SEK');
});
- it('blocks on threat score above threshold', () => {
- setEnv({ IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: '75' });
- expect(shouldBlockBySecurity({ threat_score: 90 })).toBe('threat_score');
+ it('omits values the API did not return', () => {
+ const headers = buildGeoHeaders({ ip: '8.8.8.8' }, '8.8.8.8');
+ expect(headers['x-ipgeo-city']).toBeUndefined();
+ expect(headers['x-ipgeo-threat-score']).toBeUndefined();
});
- it('does not block on threat score below threshold', () => {
- setEnv({ IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: '75' });
- expect(shouldBlockBySecurity({ threat_score: 74 })).toBeNull();
+ it('includes the security flags when the module was requested', () => {
+ const withSecurity: IpGeoResponse = {
+ ...SAMPLE,
+ security: { threat_score: 80, is_vpn: true, is_tor: false }
+ };
+
+ const headers = buildGeoHeaders(withSecurity, '91.128.103.196');
+ expect(headers['x-ipgeo-threat-score']).toBe('80');
+ expect(headers['x-ipgeo-is-vpn']).toBe('true');
+ expect(headers['x-ipgeo-is-tor']).toBe('false');
});
- it('does not block when threshold is 0', () => {
- setEnv({ IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: '0' });
- expect(shouldBlockBySecurity({ threat_score: 100 })).toBeNull();
+ it('honours a custom prefix', () => {
+ const headers = buildGeoHeaders(SAMPLE, '91.128.103.196', 'x-acme-geo');
+ expect(headers['x-acme-geo-country']).toBe('SE');
});
- it('does not block when threshold is not a number', () => {
- setEnv({ IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: 'abc' });
- expect(shouldBlockBySecurity({ threat_score: 100 })).toBeNull();
+ it('falls back to the supplied IP when the response has none', () => {
+ const headers = buildGeoHeaders(null, '8.8.8.8');
+ expect(headers['x-ipgeo-ip']).toBe('8.8.8.8');
+ });
+});
+
+describe('stripSpoofedGeoHeaders', () => {
+ it('removes client supplied geo headers', () => {
+ const headers = makeHeaders({
+ 'x-ipgeo-country': 'US',
+ 'x-ipgeo-is-vpn': 'false',
+ 'user-agent': 'test'
+ });
+
+ stripSpoofedGeoHeaders(headers);
+
+ expect(headers.get('x-ipgeo-country')).toBeNull();
+ expect(headers.get('x-ipgeo-is-vpn')).toBeNull();
+ expect(headers.get('user-agent')).toBe('test');
});
- it('respects priority order — vpn checked before threat_score', () => {
- setEnv({ IPGEO_BLOCK_VPN: 'true', IPGEO_THREAT_SCORE_BLOCK_THRESHOLD: '10' });
- expect(shouldBlockBySecurity({ is_vpn: true, threat_score: 90 })).toBe('vpn');
+ it('respects a custom prefix', () => {
+ const headers = makeHeaders({ 'x-acme-geo-country': 'US', 'x-ipgeo-country': 'US' });
+ stripSpoofedGeoHeaders(headers, 'x-acme-geo');
+
+ expect(headers.get('x-acme-geo-country')).toBeNull();
+ expect(headers.get('x-ipgeo-country')).toBe('US');
});
});
-// ─────────────────────────────────────────────────────────────────────────────
-// lookupIpGeolocation — fetch mocking
-// ─────────────────────────────────────────────────────────────────────────────
+// -----------------------------------------------------------------------------
+// lookupIpGeolocation
+// -----------------------------------------------------------------------------
describe('lookupIpGeolocation', () => {
- const mockGeo: IpGeoResponse = {
- ip: '1.2.3.4',
- location: {
- country_code2: 'US',
- country_name: 'United States',
- city: 'New York',
- state_prov: 'New York',
- latitude: '40.7128',
- longitude: '-74.0060'
- },
- asn: { as_number: '15169', organization: 'Google LLC' },
- time_zone: { name: 'America/New_York' },
- security: { is_vpn: false, is_tor: false, threat_score: 0 }
- };
+ it('returns the parsed response', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
- beforeEach(() => {
- vi.stubGlobal('fetch', vi.fn());
- // Clear internal cache between tests by resetting the module would be
- // complex; instead we use unique IPs per test to avoid stale cache hits.
+ const result = await lookupIpGeolocation({ apiKey: 'key', ip: '91.128.103.196' });
+
+ expect(result?.location?.country_code2).toBe('SE');
+ expect(fetchMock).toHaveBeenCalledTimes(1);
});
- afterEach(() => {
- vi.unstubAllGlobals();
+ it('does not request the security module unless it is asked for', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocation({ apiKey: 'key', ip: '91.128.103.196' });
+
+ const url = String(fetchMock.mock.calls[0]?.[0]);
+ expect(url).not.toContain('include=');
});
- function mockFetchOk(body: unknown) {
- vi.mocked(fetch).mockResolvedValueOnce(
- new Response(JSON.stringify(body), { status: 200 })
- );
- }
+ it('requests the security module when includeSecurity is set', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
- function mockFetchError(status: number, text = 'error') {
- vi.mocked(fetch).mockResolvedValueOnce(
- new Response(text, { status })
- );
- }
+ await lookupIpGeolocation({ apiKey: 'key', ip: '91.128.103.196', includeSecurity: true });
- it('returns null when apiKey is empty', async () => {
- expect(await lookupIpGeolocation({ apiKey: '', ip: '1.2.3.4' })).toBeNull();
+ const url = String(fetchMock.mock.calls[0]?.[0]);
+ expect(url).toContain('include=security');
});
- it('returns null when ip is empty', async () => {
- expect(await lookupIpGeolocation({ apiKey: 'key', ip: '' })).toBeNull();
+ it('sends the API key and the IP as query parameters', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocation({ apiKey: 'abc123', ip: '8.8.8.8' });
+
+ const url = new URL(String(fetchMock.mock.calls[0]?.[0]));
+ expect(url.origin + url.pathname).toBe('https://api.ipgeolocation.io/v3/ipgeo');
+ expect(url.searchParams.get('apiKey')).toBe('abc123');
+ expect(url.searchParams.get('ip')).toBe('8.8.8.8');
});
- it('fetches and returns geo data', async () => {
- mockFetchOk(mockGeo);
- const result = await lookupIpGeolocation({ apiKey: 'key', ip: '10.0.0.1' });
- expect(result).toEqual(mockGeo);
+ it('returns null without calling the API when the key is missing', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await lookupIpGeolocation({ apiKey: '', ip: '8.8.8.8' })).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
});
- it('includes security param by default', async () => {
- mockFetchOk(mockGeo);
- await lookupIpGeolocation({ apiKey: 'key', ip: '10.0.0.2' });
- const url = vi.mocked(fetch).mock.calls[0]?.[0] as string;
- expect(url).toContain('include=security');
+ it('returns null without calling the API for a private address', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await lookupIpGeolocation({ apiKey: 'key', ip: '127.0.0.1' })).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
});
- it('omits security param when includeSecurity=false', async () => {
- mockFetchOk(mockGeo);
- await lookupIpGeolocation({ apiKey: 'key', ip: '10.0.0.3', includeSecurity: false });
- const url = vi.mocked(fetch).mock.calls[0]?.[0] as string;
- expect(url).not.toContain('include=security');
+ it('returns null without calling the API for a malformed address', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await lookupIpGeolocation({ apiKey: 'key', ip: 'nope' })).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
});
- it('returns null on non-OK response', async () => {
- mockFetchError(403, 'Forbidden');
- const result = await lookupIpGeolocation({ apiKey: 'key', ip: '10.0.0.4' });
- expect(result).toBeNull();
+ it('caches by IP for the configured lifetime', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' });
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps separate cache entries per module set', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' });
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8', includeSecurity: true });
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('bypasses the cache when IPGEO_CACHE_TTL_MS is 0', async () => {
+ process.env.IPGEO_CACHE_TTL_MS = '0';
+
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' });
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' });
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('shares one request between concurrent lookups for the same IP', async () => {
+ const fetchMock = vi.fn().mockImplementation(
+ () => new Promise((resolve) => setTimeout(() => resolve(jsonResponse(SAMPLE)), 20))
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const [first, second] = await Promise.all([
+ lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' }),
+ lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' })
+ ]);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(first?.location?.country_code2).toBe('SE');
+ expect(second?.location?.country_code2).toBe('SE');
});
- it('returns null on network error', async () => {
- vi.mocked(fetch).mockRejectedValueOnce(new Error('network error'));
- const result = await lookupIpGeolocation({ apiKey: 'key', ip: '10.0.0.5' });
+ it('evicts the oldest entry when the cache is full', async () => {
+ process.env.IPGEO_CACHE_MAX_ENTRIES = '2';
+
+ const fetchMock = vi.fn().mockImplementation(async () => jsonResponse(SAMPLE));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' });
+ await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.4.4' });
+ await lookupIpGeolocation({ apiKey: 'key', ip: '1.1.1.1' });
+
+ expect(getIpGeoRuntimeState().cacheSize).toBe(2);
+ });
+
+ it('returns null on a 4xx response', async () => {
+ process.env.IPGEO_RETRIES = '0';
+
+ const fetchMock = vi.fn().mockResolvedValue(new Response('bad request', { status: 400 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' })).toBeNull();
+ });
+
+ it('returns null and does not cache when the response is not JSON', async () => {
+ process.env.IPGEO_RETRIES = '0';
+
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue(new Response('maintenance', { status: 200 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8' })).toBeNull();
+ expect(getIpGeoRuntimeState().cacheSize).toBe(0);
+ });
+
+ it('returns null when the request times out', async () => {
+ process.env.IPGEO_RETRIES = '0';
+
+ const abortError = new Error('The operation was aborted.');
+ abortError.name = 'AbortError';
+
+ const fetchMock = vi.fn().mockRejectedValue(abortError);
+ vi.stubGlobal('fetch', fetchMock);
+
+ const result = await lookupIpGeolocation({ apiKey: 'key', ip: '8.8.8.8', timeoutMs: 250 });
expect(result).toBeNull();
});
+});
+
+describe('lookupIpGeolocationResult', () => {
+ it('reports the failure reason and status', async () => {
+ process.env.IPGEO_RETRIES = '0';
+
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 429 })));
+
+ const result = await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.8' });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.reason).toBe('rate_limited');
+ expect(result.status).toBe(429);
+ }
+ });
+
+ it('reports a private address without calling the API', async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ const result = await lookupIpGeolocationResult({ apiKey: 'key', ip: '10.0.0.1' });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.reason).toBe('private_ip');
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('reports the credits charged for the request', async () => {
+ const response = jsonResponse(SAMPLE, {
+ headers: { 'content-type': 'application/json', 'x-credits-charged': '3' }
+ });
+
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
+
+ const result = await lookupIpGeolocationResult({
+ apiKey: 'key',
+ ip: '8.8.8.8',
+ includeSecurity: true
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) expect(result.creditsCharged).toBe(3);
+ });
+
+ it('marks a cached response as cached', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(SAMPLE)));
+
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.8' });
+ const second = await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.8' });
+
+ expect(second.ok).toBe(true);
+ if (second.ok) expect(second.cached).toBe(true);
+ });
+
+ it('retries a 5xx response once by default', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('server error', { status: 503 }))
+ .mockResolvedValueOnce(jsonResponse(SAMPLE));
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ const result = await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.8' });
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(result.ok).toBe(true);
+ });
+
+ it('does not retry a 4xx response', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(new Response('bad key', { status: 403 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.8' });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('retries without the security module when a free plan key rejects it', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('security requires a paid plan', { status: 401 }))
+ .mockResolvedValueOnce(jsonResponse(SAMPLE));
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ const result = await lookupIpGeolocationResult({
+ apiKey: 'free-key',
+ ip: '8.8.8.8',
+ includeSecurity: true
+ });
+
+ expect(result.ok).toBe(true);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain('include=security');
+ expect(String(fetchMock.mock.calls[1]?.[0])).not.toContain('include=security');
+ });
+
+ it('stops asking for the security module after it was rejected', async () => {
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('security requires a paid plan', { status: 401 }))
+ .mockResolvedValue(jsonResponse(SAMPLE));
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocationResult({ apiKey: 'free-key', ip: '8.8.8.8', includeSecurity: true });
+ await lookupIpGeolocationResult({ apiKey: 'free-key', ip: '1.1.1.1', includeSecurity: true });
+
+ expect(getIpGeoRuntimeState().securityModuleAvailable).toBe(false);
+ expect(String(fetchMock.mock.calls[2]?.[0])).not.toContain('include=security');
+ });
+
+ it('opens the circuit after repeated failures and stops calling the API', async () => {
+ process.env.IPGEO_RETRIES = '0';
+ process.env.IPGEO_CIRCUIT_FAILURE_THRESHOLD = '3';
+ process.env.IPGEO_CIRCUIT_COOLDOWN_MS = '10000';
+
+ const fetchMock = vi.fn().mockResolvedValue(new Response('boom', { status: 500 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.1' });
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.2' });
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.3' });
+
+ expect(getIpGeoRuntimeState().circuitOpen).toBe(true);
+
+ const blocked = await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.4' });
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(blocked.ok).toBe(false);
+ if (!blocked.ok) expect(blocked.reason).toBe('circuit_open');
+ });
+
+ it('closes the circuit again after a success', async () => {
+ process.env.IPGEO_RETRIES = '0';
+ process.env.IPGEO_CIRCUIT_FAILURE_THRESHOLD = '2';
+
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('boom', { status: 500 }))
+ .mockResolvedValueOnce(jsonResponse(SAMPLE));
+
+ vi.stubGlobal('fetch', fetchMock);
+
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.1' });
+ await lookupIpGeolocationResult({ apiKey: 'key', ip: '8.8.8.2' });
- it('returns cached result on second call for same IP', async () => {
- mockFetchOk(mockGeo);
- const ip = '10.0.1.1'; // unique IP for this test
- await lookupIpGeolocation({ apiKey: 'key', ip });
- await lookupIpGeolocation({ apiKey: 'key', ip });
- // fetch should have only been called once — second call hits cache
- expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
- });
-
- it('bypasses cache when IPGEO_CACHE_TTL_MS=0', async () => {
- setEnv({ IPGEO_CACHE_TTL_MS: '0' });
- mockFetchOk(mockGeo);
- mockFetchOk(mockGeo);
- const ip = '10.0.1.2';
- await lookupIpGeolocation({ apiKey: 'key', ip });
- await lookupIpGeolocation({ apiKey: 'key', ip });
- expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
- setEnv({ IPGEO_CACHE_TTL_MS: undefined });
- });
-
- it('logs an AbortError with timeout message', async () => {
- const abortError = new DOMException('The operation was aborted.', 'AbortError');
- vi.mocked(fetch).mockRejectedValueOnce(abortError);
- const error = vi.spyOn(console, 'error').mockImplementation(() => {});
- await lookupIpGeolocation({ apiKey: 'key', ip: '10.0.0.6', timeoutMs: 1 });
- expect(error.mock.calls.some((c) => String(c[0]).includes('timed out'))).toBe(true);
- error.mockRestore();
+ expect(getIpGeoRuntimeState().consecutiveFailures).toBe(0);
+ expect(getIpGeoRuntimeState().circuitOpen).toBe(false);
});
});
diff --git a/ipgeolocation-edge.ts b/ipgeolocation-edge.ts
index 64a696c..5434edf 100644
--- a/ipgeolocation-edge.ts
+++ b/ipgeolocation-edge.ts
@@ -1,232 +1,1215 @@
-// ─────────────────────────────────────────────────────────────────────────────
-// IPGeolocation.io – Edge utility library
-// Compatible with Next.js Middleware (Edge Runtime)
-// ─────────────────────────────────────────────────────────────────────────────
+// =============================================================================
+// IPGeolocation.io edge utility library
+//
+// Runs in the Vercel Edge Runtime, in Node.js 18 and later, and in any runtime
+// that provides fetch, URL, Headers and AbortController.
+//
+// No runtime dependencies.
+// =============================================================================
+
+// -----------------------------------------------------------------------------
+// Response types
+// -----------------------------------------------------------------------------
export type IpGeoSecurity = {
threat_score?: number;
is_tor?: boolean;
is_proxy?: boolean;
+ proxy_provider_names?: string[];
+ proxy_confidence_score?: number;
+ proxy_last_seen?: string;
is_residential_proxy?: boolean;
is_vpn?: boolean;
+ vpn_provider_names?: string[];
+ vpn_confidence_score?: number;
+ vpn_last_seen?: string;
is_relay?: boolean;
+ relay_provider_name?: string;
is_anonymous?: boolean;
is_known_attacker?: boolean;
is_bot?: boolean;
+ bot_confidence_score?: number;
+ bot_operator_name?: string;
+ bot_type?: string;
+ is_known_good_bot?: boolean;
+ bot_last_seen?: string;
is_spam?: boolean;
is_cloud_provider?: boolean;
cloud_provider_name?: string;
+ is_corporate_gateway?: boolean;
+ corporate_gateway_type?: string;
+ corporate_gateway_provider_name?: string;
+};
+
+export type IpGeoLocation = {
+ continent_code?: string;
+ continent_name?: string;
+ country_code2?: string;
+ country_code3?: string;
+ country_name?: string;
+ country_capital?: string;
+ state_prov?: string;
+ state_code?: string;
+ district?: string;
+ city?: string;
+ zipcode?: string;
+ latitude?: string;
+ longitude?: string;
+ is_eu?: boolean;
+ geoname_id?: string;
};
export type IpGeoResponse = {
ip?: string;
- location?: {
- country_code2?: string;
- country_name?: string;
- state_prov?: string;
- city?: string;
- latitude?: string;
- longitude?: string;
+ domain?: string;
+ hostname?: string;
+ location?: IpGeoLocation;
+ country_metadata?: {
+ calling_code?: string;
+ tld?: string;
+ languages?: string[];
+ };
+ currency?: {
+ code?: string;
+ name?: string;
+ symbol?: string;
+ };
+ network?: {
+ connection_type?: string;
+ route?: string;
+ is_anycast?: boolean;
+ is_cdn?: boolean;
+ cdn_provider_name?: string;
};
asn?: {
as_number?: string;
organization?: string;
+ country?: string;
+ type?: string;
+ domain?: string;
+ };
+ company?: {
+ name?: string;
+ type?: string;
+ domain?: string;
};
time_zone?: {
name?: string;
+ offset?: number;
+ offset_with_dst?: number;
+ current_time?: string;
+ is_dst?: boolean;
};
security?: IpGeoSecurity;
};
-type LookupOptions = {
- apiKey: string;
- ip: string;
- includeSecurity?: boolean;
- timeoutMs?: number;
+// -----------------------------------------------------------------------------
+// Logging
+//
+// Every line is prefixed so it can be filtered in Vercel runtime logs. The
+// default level is "warn", which keeps per request noise out of your logs while
+// still surfacing configuration and API problems.
+// -----------------------------------------------------------------------------
+
+export type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug';
+
+const LOG_WEIGHTS: Record = {
+ silent: 0,
+ error: 1,
+ warn: 2,
+ info: 3,
+ debug: 4
};
-// ─────────────────────────────────────────────────────────────────────────────
-// In-memory cache (edge-runtime safe — module-level Map)
-// TTL defaults to 60 seconds. Override via IPGEO_CACHE_TTL_MS.
-// ─────────────────────────────────────────────────────────────────────────────
+const LOG_PREFIX = '[IPGeolocation.io]';
+
+function activeLogWeight(): number {
+ const raw = String(process.env.IPGEO_LOG_LEVEL ?? 'warn').toLowerCase();
+ const weight = LOG_WEIGHTS[raw as LogLevel];
+ return typeof weight === 'number' ? weight : LOG_WEIGHTS.warn;
+}
+
+function logError(message: string, ...rest: unknown[]): void {
+ if (activeLogWeight() >= LOG_WEIGHTS.error) console.error(LOG_PREFIX, message, ...rest);
+}
+
+function logWarn(message: string, ...rest: unknown[]): void {
+ if (activeLogWeight() >= LOG_WEIGHTS.warn) console.warn(LOG_PREFIX, message, ...rest);
+}
+
+function logDebug(message: string, ...rest: unknown[]): void {
+ if (activeLogWeight() >= LOG_WEIGHTS.debug) console.log(LOG_PREFIX, message, ...rest);
+}
+
+// -----------------------------------------------------------------------------
+// Environment helpers
+// -----------------------------------------------------------------------------
+
+const TRUTHY = new Set(['true', '1', 'yes', 'y', 'on']);
+const FALSY = new Set(['false', '0', 'no', 'n', 'off', '']);
+
+/**
+ * Parses a boolean environment variable.
+ * Accepts true, 1, yes, y and on (case insensitive) as true.
+ */
+export function envFlag(value?: string, fallback = false): boolean {
+ if (value === undefined || value === null) return fallback;
+
+ const normalized = String(value).trim().toLowerCase();
+ if (TRUTHY.has(normalized)) return true;
+ if (FALSY.has(normalized)) return false;
+
+ logWarn(`Unrecognised boolean value "${value}". Falling back to ${fallback}.`);
+ return fallback;
+}
+
+/**
+ * Parses a numeric environment variable with an optional inclusive range.
+ * Values outside the range are clamped. Anything unparseable falls back.
+ */
+export function envNumber(
+ value: string | undefined,
+ fallback: number,
+ range?: { min?: number; max?: number }
+): number {
+ if (value === undefined || value === null || String(value).trim() === '') return fallback;
+
+ const parsed = Number(String(value).trim());
+ if (!Number.isFinite(parsed)) {
+ logWarn(`Expected a number but received "${value}". Falling back to ${fallback}.`);
+ return fallback;
+ }
+
+ const min = range?.min;
+ const max = range?.max;
+
+ if (typeof min === 'number' && parsed < min) return min;
+ if (typeof max === 'number' && parsed > max) return max;
+
+ return parsed;
+}
+
+/**
+ * Parses a comma separated list of ISO 3166-1 alpha-2 country codes into a Set
+ * of uppercase codes. Values that are not two letters are dropped with a warning.
+ */
+export function parseCsvEnv(value?: string): Set {
+ const result = new Set();
+ if (!value) return result;
+
+ for (const raw of String(value).split(',')) {
+ const code = raw.trim().toUpperCase();
+ if (!code) continue;
+
+ if (!/^[A-Z]{2}$/.test(code)) {
+ logWarn(`Ignoring "${raw.trim()}" because it is not a two letter country code.`);
+ continue;
+ }
+
+ result.add(code);
+ }
+
+ return result;
+}
+
+/** Parses a comma separated list into a trimmed array with empties removed. */
+export function parseList(value?: string): string[] {
+ if (!value) return [];
+ return String(value)
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
+
+/**
+ * Normalizes a same origin path.
+ *
+ * Returns null for absolute URLs, protocol relative values and backslash
+ * variants. A trailing slash is removed so a value such as "/uk/" cannot cause
+ * a redirect loop.
+ */
+export function normalizeInternalPath(value: string | undefined | null): string | null {
+ if (typeof value !== 'string') return null;
+
+ const trimmed = value.trim();
+ if (!trimmed.startsWith('/')) return null;
+ if (trimmed.startsWith('//')) return null;
+ if (trimmed.includes('\\')) return null;
+
+ const withoutTrailingSlash = trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed;
+ return withoutTrailingSlash === '' ? '/' : withoutTrailingSlash;
+}
+
+/**
+ * Parses the country to path redirect map.
+ *
+ * Only same origin paths are accepted, so a misconfigured variable cannot turn
+ * the middleware into an open redirect.
+ */
+export function parseRedirectMap(value?: string): Record {
+ if (!value || value.trim() === '' || value.trim() === '{}') return {};
+
+ let parsed: unknown;
+
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ logWarn(
+ 'IPGEO_COUNTRY_REDIRECTS is not valid JSON, so no country redirects are active. First 120 characters:',
+ value.slice(0, 120)
+ );
+ return {};
+ }
+
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ logWarn('IPGEO_COUNTRY_REDIRECTS must be a JSON object such as {"GB":"/uk"}.');
+ return {};
+ }
+
+ const result: Record = {};
+
+ for (const [rawCountry, rawPath] of Object.entries(parsed as Record)) {
+ const country = rawCountry.trim().toUpperCase();
+
+ if (!/^[A-Z]{2}$/.test(country)) {
+ logWarn(`Ignoring redirect key "${rawCountry}" because it is not a two letter country code.`);
+ continue;
+ }
+
+ if (typeof rawPath !== 'string') {
+ logWarn(`Ignoring the redirect for ${country} because the target is not a string.`);
+ continue;
+ }
+
+ const path = normalizeInternalPath(rawPath);
+
+ if (!path) {
+ logWarn(
+ `Ignoring the redirect for ${country} because "${rawPath}" is not a same origin path starting with "/".`
+ );
+ continue;
+ }
+
+ result[country] = path;
+ }
+
+ return result;
+}
+
+/** True when pathname is the prefix itself or sits underneath it. */
+export function isUnderPath(pathname: string, prefix: string): boolean {
+ if (prefix === '/') return true;
+ return pathname === prefix || pathname.startsWith(prefix + '/');
+}
+
+// -----------------------------------------------------------------------------
+// IP address handling
+// -----------------------------------------------------------------------------
+
+const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
+
+function isIpv4(value: string): boolean {
+ const match = IPV4_PATTERN.exec(value);
+ if (!match) return false;
+
+ for (let i = 1; i <= 4; i += 1) {
+ const part = match[i];
+ if (part === undefined) return false;
+ if (part.length > 1 && part.startsWith('0')) return false;
+ if (Number(part) > 255) return false;
+ }
+
+ return true;
+}
+
+function isIpv6(value: string): boolean {
+ if (!value.includes(':')) return false;
+ if (!/^[0-9a-fA-F:.]+$/.test(value)) return false;
+ if ((value.match(/::/g) ?? []).length > 1) return false;
+
+ const groups = value.split(':');
+ if (groups.length > 8) return false;
+
+ const last = groups[groups.length - 1];
+ if (last !== undefined && last.includes('.') && !isIpv4(last)) return false;
+
+ return true;
+}
+
+/**
+ * Cleans a single IP candidate taken from a header.
+ *
+ * Handles the shapes proxies produce in the wild: an IPv4 address with a port,
+ * a bracketed IPv6 address with a port, quoted values, zone indexes, and IPv4
+ * mapped IPv6 such as ::ffff:203.0.113.10.
+ *
+ * Returns null when the value is not a valid IPv4 or IPv6 address.
+ */
+export function normalizeIp(value: string | undefined | null): string | null {
+ if (typeof value !== 'string') return null;
+
+ let candidate = value.trim().replace(/^"|"$/g, '');
+ if (!candidate) return null;
+
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(candidate);
+
+ if (bracketed && bracketed[1]) {
+ candidate = bracketed[1];
+ } else if (candidate.includes('.') && candidate.includes(':') && !candidate.includes('::')) {
+ const host = candidate.split(':')[0];
+ if (host) candidate = host;
+ }
+
+ const percent = candidate.indexOf('%');
+ if (percent > -1) candidate = candidate.slice(0, percent);
+
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(candidate);
+ if (mapped && mapped[1]) candidate = mapped[1];
+
+ if (isIpv4(candidate)) return candidate;
+ if (isIpv6(candidate)) return candidate.toLowerCase();
+
+ return null;
+}
+
+/**
+ * True when the address is a globally routable public IP.
+ *
+ * The API answers HTTP 423 for private and bogon ranges, so filtering them here
+ * avoids a failed lookup and a wasted round trip. This is also what stops local
+ * development from looking like an API outage.
+ */
+export function isPublicIp(value: string | undefined | null): boolean {
+ const ip = normalizeIp(value);
+ if (!ip) return false;
+
+ if (isIpv4(ip)) {
+ const parts = ip.split('.').map(Number);
+ const a = parts[0] ?? 0;
+ const b = parts[1] ?? 0;
+
+ if (a === 0) return false; // this network
+ if (a === 10) return false; // private
+ if (a === 127) return false; // loopback
+ if (a === 100 && b >= 64 && b <= 127) return false; // carrier grade NAT
+ if (a === 169 && b === 254) return false; // link local
+ if (a === 172 && b >= 16 && b <= 31) return false; // private
+ if (a === 192 && b === 0) return false; // protocol assignments and documentation
+ if (a === 192 && b === 168) return false; // private
+ if (a === 198 && (b === 18 || b === 19)) return false; // benchmarking
+ if (a === 198 && b === 51) return false; // documentation
+ if (a === 203 && b === 0) return false; // documentation
+ if (a >= 224) return false; // multicast, reserved and broadcast
+
+ return true;
+ }
+
+ const lower = ip.toLowerCase();
+
+ if (lower === '::' || lower === '::1') return false;
+ if (lower.startsWith('fe80')) return false; // link local
+ if (/^f[cd]/.test(lower)) return false; // unique local
+ if (lower.startsWith('ff')) return false; // multicast
+ if (lower.startsWith('2001:db8')) return false; // documentation
+ if (lower.startsWith('100:')) return false; // discard only
+
+ return true;
+}
+
+export type ClientIpOptions = {
+ /** Read the leftmost entry of x-forwarded-for instead of the rightmost. */
+ trustFirstXff?: boolean;
+ /**
+ * Number of proxies you operate in front of the application. The client IP is
+ * read that many positions to the left of the rightmost entry. Ignored when
+ * trustFirstXff is true.
+ */
+ trustedProxyCount?: number;
+ /** Accept private, loopback and bogon addresses. Off by default. */
+ allowPrivate?: boolean;
+};
+
+const IP_HEADERS = [
+ 'x-vercel-forwarded-for',
+ 'x-forwarded-for',
+ 'x-real-ip',
+ 'cf-connecting-ip',
+ 'true-client-ip'
+] as const;
+
+/**
+ * Extracts the client IP from the request headers.
+ *
+ * On Vercel the platform sets x-forwarded-for from the real connection and does
+ * not forward an externally supplied value, so reading the rightmost entry is
+ * correct. Behind a reverse proxy you operate yourself, set trustedProxyCount
+ * to the number of hops you control.
+ *
+ * The second argument also accepts a boolean for compatibility with 1.x, where
+ * it meant trustFirstXff.
+ */
+export function getClientIp(
+ headers: Headers,
+ options: ClientIpOptions | boolean = {}
+): string | null {
+ const opts: ClientIpOptions = typeof options === 'boolean' ? { trustFirstXff: options } : options;
+ const allowPrivate = opts.allowPrivate === true;
+
+ const accept = (candidate: string | null | undefined): string | null => {
+ if (!candidate) return null;
+ const ip = normalizeIp(candidate);
+ if (!ip) return null;
+ if (!allowPrivate && !isPublicIp(ip)) return null;
+ return ip;
+ };
+
+ for (const headerName of IP_HEADERS) {
+ const raw = headers.get(headerName);
+ if (!raw) continue;
+
+ const entries = raw
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+
+ if (entries.length === 0) continue;
+
+ if (entries.length === 1 || opts.trustFirstXff) {
+ const first = accept(entries[0]);
+ if (first) return first;
+ continue;
+ }
+
+ const hops = Math.max(0, Math.trunc(opts.trustedProxyCount ?? 0));
+ const index = Math.max(0, entries.length - 1 - hops);
+ const selected = accept(entries[index]);
+ if (selected) return selected;
+ }
+
+ return null;
+}
+
+// -----------------------------------------------------------------------------
+// Security rules
+// -----------------------------------------------------------------------------
+
+export type SecurityRules = {
+ blockVpn: boolean;
+ blockProxy: boolean;
+ blockResidentialProxy: boolean;
+ blockTor: boolean;
+ blockRelay: boolean;
+ blockCloudProvider: boolean;
+ blockBot: boolean;
+ blockSpam: boolean;
+ blockKnownAttacker: boolean;
+ blockAnonymous: boolean;
+ /** Keep bots the API marks as known good, such as search engine crawlers. */
+ allowKnownGoodBots: boolean;
+ /** Block at or above this threat score. 0 disables the check. */
+ threatScoreThreshold: number;
+};
+
+export type SecurityBlockReason =
+ | 'vpn'
+ | 'proxy'
+ | 'residential_proxy'
+ | 'tor'
+ | 'relay'
+ | 'cloud_provider'
+ | 'bot'
+ | 'spam'
+ | 'known_attacker'
+ | 'anonymous'
+ | 'threat_score';
+
+/** Reads the security rules from the environment. */
+export function getSecurityRulesFromEnv(): SecurityRules {
+ return {
+ blockVpn: envFlag(process.env.IPGEO_BLOCK_VPN),
+ blockProxy: envFlag(process.env.IPGEO_BLOCK_PROXY),
+ blockResidentialProxy: envFlag(process.env.IPGEO_BLOCK_RESIDENTIAL_PROXY),
+ blockTor: envFlag(process.env.IPGEO_BLOCK_TOR),
+ blockRelay: envFlag(process.env.IPGEO_BLOCK_RELAY),
+ blockCloudProvider: envFlag(process.env.IPGEO_BLOCK_CLOUD_PROVIDER),
+ blockBot: envFlag(process.env.IPGEO_BLOCK_BOT),
+ blockSpam: envFlag(process.env.IPGEO_BLOCK_SPAM),
+ blockKnownAttacker: envFlag(process.env.IPGEO_BLOCK_KNOWN_ATTACKER),
+ blockAnonymous: envFlag(process.env.IPGEO_BLOCK_ANONYMOUS),
+ allowKnownGoodBots: envFlag(process.env.IPGEO_ALLOW_KNOWN_GOOD_BOTS, true),
+ threatScoreThreshold: envNumber(process.env.IPGEO_THREAT_SCORE_BLOCK_THRESHOLD, 0, {
+ min: 0,
+ max: 100
+ })
+ };
+}
+
+/** True when at least one rule needs the security module. */
+export function securityRulesEnabled(rules: SecurityRules): boolean {
+ return (
+ rules.blockVpn ||
+ rules.blockProxy ||
+ rules.blockResidentialProxy ||
+ rules.blockTor ||
+ rules.blockRelay ||
+ rules.blockCloudProvider ||
+ rules.blockBot ||
+ rules.blockSpam ||
+ rules.blockKnownAttacker ||
+ rules.blockAnonymous ||
+ rules.threatScoreThreshold > 0
+ );
+}
+
+/**
+ * Evaluates the security object against the active rules and returns the reason
+ * for the first rule that matches, or null to allow the request.
+ */
+export function shouldBlockBySecurity(
+ security: IpGeoSecurity | undefined,
+ rules?: SecurityRules
+): SecurityBlockReason | null {
+ if (!security) return null;
+
+ const active = rules ?? getSecurityRulesFromEnv();
+
+ if (active.blockVpn && security.is_vpn) return 'vpn';
+ if (active.blockProxy && security.is_proxy) return 'proxy';
+ if (active.blockResidentialProxy && security.is_residential_proxy) return 'residential_proxy';
+ if (active.blockTor && security.is_tor) return 'tor';
+ if (active.blockRelay && security.is_relay) return 'relay';
+ if (active.blockCloudProvider && security.is_cloud_provider) return 'cloud_provider';
+
+ if (active.blockBot && security.is_bot) {
+ const goodBot = active.allowKnownGoodBots && security.is_known_good_bot === true;
+ if (!goodBot) return 'bot';
+ }
+
+ if (active.blockSpam && security.is_spam) return 'spam';
+ if (active.blockKnownAttacker && security.is_known_attacker) return 'known_attacker';
+ if (active.blockAnonymous && security.is_anonymous) return 'anonymous';
+
+ if (active.threatScoreThreshold > 0) {
+ const score = Number(security.threat_score ?? 0);
+ if (Number.isFinite(score) && score >= active.threatScoreThreshold) return 'threat_score';
+ }
+
+ return null;
+}
+
+// -----------------------------------------------------------------------------
+// Cache, request coalescing and circuit breaker
+//
+// All state is module level. In the Edge Runtime that means it is scoped to one
+// isolate and shared by the requests that isolate serves.
+// -----------------------------------------------------------------------------
type CacheEntry = { data: IpGeoResponse; expiresAt: number };
+
const geoCache = new Map();
+const inFlight = new Map>();
+
+const DEFAULT_CACHE_TTL_MS = 60_000;
+const DEFAULT_CACHE_MAX_ENTRIES = 1_000;
+const DEFAULT_TIMEOUT_MS = 3_000;
+const DEFAULT_RETRIES = 1;
+const DEFAULT_CIRCUIT_THRESHOLD = 5;
+const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000;
+const SECURITY_LATCH_MS = 300_000;
+
+let consecutiveFailures = 0;
+let circuitOpenUntil = 0;
+let securityUnavailableUntil = 0;
-function getCacheTtlMs(): number {
- const val = Number(process.env.IPGEO_CACHE_TTL_MS || '60000');
- return Number.isNaN(val) || val < 0 ? 60_000 : val;
+function cacheTtlMs(override?: number): number {
+ if (typeof override === 'number' && Number.isFinite(override) && override >= 0) return override;
+ return envNumber(process.env.IPGEO_CACHE_TTL_MS, DEFAULT_CACHE_TTL_MS, { min: 0 });
}
-function getCached(ip: string): IpGeoResponse | null {
- const entry = geoCache.get(ip);
+function cacheMaxEntries(): number {
+ return envNumber(process.env.IPGEO_CACHE_MAX_ENTRIES, DEFAULT_CACHE_MAX_ENTRIES, { min: 0 });
+}
+
+function readCache(key: string): IpGeoResponse | null {
+ const entry = geoCache.get(key);
if (!entry) return null;
- if (entry.expiresAt < Date.now()) {
- geoCache.delete(ip);
+
+ if (entry.expiresAt <= Date.now()) {
+ geoCache.delete(key);
return null;
}
+
+ // Refresh insertion order so eviction drops the coldest keys first.
+ geoCache.delete(key);
+ geoCache.set(key, entry);
+
return entry.data;
}
-function setCache(ip: string, data: IpGeoResponse): void {
- const ttl = getCacheTtlMs();
- if (ttl === 0) return; // caching disabled
- geoCache.set(ip, { data, expiresAt: Date.now() + ttl });
+function writeCache(key: string, data: IpGeoResponse, ttlOverride?: number): void {
+ const ttl = cacheTtlMs(ttlOverride);
+ if (ttl === 0) return;
+
+ const max = cacheMaxEntries();
+ if (max === 0) return;
+
+ geoCache.set(key, { data, expiresAt: Date.now() + ttl });
+
+ if (geoCache.size <= max) return;
+
+ const now = Date.now();
+ for (const [existingKey, entry] of geoCache) {
+ if (entry.expiresAt <= now) geoCache.delete(existingKey);
+ }
+
+ while (geoCache.size > max) {
+ const oldest = geoCache.keys().next();
+ if (oldest.done) break;
+ geoCache.delete(oldest.value);
+ }
}
-// ─────────────────────────────────────────────────────────────────────────────
-// IP Geolocation lookup
-// ─────────────────────────────────────────────────────────────────────────────
+function circuitIsOpen(): boolean {
+ if (circuitOpenUntil === 0) return false;
-export async function lookupIpGeolocation(
- options: LookupOptions
-): Promise {
- const { apiKey, ip, includeSecurity = true, timeoutMs = 3000 } = options;
+ if (Date.now() >= circuitOpenUntil) {
+ circuitOpenUntil = 0;
+ consecutiveFailures = 0;
+ return false;
+ }
- if (!apiKey || !ip) return null;
+ return true;
+}
- const cached = getCached(ip);
- if (cached) return cached;
+function recordSuccess(): void {
+ consecutiveFailures = 0;
+ circuitOpenUntil = 0;
+}
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
+function recordFailure(): void {
+ const threshold = envNumber(
+ process.env.IPGEO_CIRCUIT_FAILURE_THRESHOLD,
+ DEFAULT_CIRCUIT_THRESHOLD,
+ { min: 0 }
+ );
+
+ if (threshold === 0) return;
+
+ consecutiveFailures += 1;
+ if (consecutiveFailures < threshold) return;
+
+ const cooldown = envNumber(process.env.IPGEO_CIRCUIT_COOLDOWN_MS, DEFAULT_CIRCUIT_COOLDOWN_MS, {
+ min: 0
+ });
+
+ if (cooldown === 0) return;
+
+ circuitOpenUntil = Date.now() + cooldown;
+ logWarn(
+ `${consecutiveFailures} lookups failed in a row, so lookups pause for ${cooldown}ms. While the pause lasts, traffic follows your fail open or fail closed setting.`
+ );
+}
+
+/**
+ * Clears the cache, the in flight map and the circuit breaker state.
+ * Intended for tests and for long running processes that need a clean slate.
+ */
+export function resetIpGeoRuntimeState(): void {
+ geoCache.clear();
+ inFlight.clear();
+ consecutiveFailures = 0;
+ circuitOpenUntil = 0;
+ securityUnavailableUntil = 0;
+}
+
+/** A snapshot of cache and circuit breaker state, useful for a health route. */
+export function getIpGeoRuntimeState(): {
+ cacheSize: number;
+ inFlight: number;
+ consecutiveFailures: number;
+ circuitOpen: boolean;
+ securityModuleAvailable: boolean;
+} {
+ return {
+ cacheSize: geoCache.size,
+ inFlight: inFlight.size,
+ consecutiveFailures,
+ circuitOpen: circuitIsOpen(),
+ securityModuleAvailable: securityUnavailableUntil === 0 || Date.now() >= securityUnavailableUntil
+ };
+}
+
+// -----------------------------------------------------------------------------
+// Lookup
+// -----------------------------------------------------------------------------
+
+export const IPGEO_API_BASE_URL = 'https://api.ipgeolocation.io/v3/ipgeo';
+
+export type LookupFailureReason =
+ | 'invalid_input'
+ | 'private_ip'
+ | 'circuit_open'
+ | 'unauthorized'
+ | 'rate_limited'
+ | 'http_error'
+ | 'timeout'
+ | 'network_error'
+ | 'invalid_response';
+
+export type LookupResult =
+ | { ok: true; data: IpGeoResponse; cached: boolean; creditsCharged: number | null }
+ | { ok: false; reason: LookupFailureReason; status: number | null; message: string };
+
+export type LookupOptions = {
+ apiKey: string;
+ ip: string;
+ /**
+ * Optional modules to request, for example ["security"]. Modules beyond the
+ * base lookup cost extra credits and need a paid plan.
+ */
+ include?: string[];
+ /** Compatibility with 1.x. Adds "security" to include. Defaults to false. */
+ includeSecurity?: boolean;
+ /** Restrict the response to these fields, which shrinks the payload. */
+ fields?: string[];
+ /** Remove these fields from the response. */
+ excludes?: string[];
+ timeoutMs?: number;
+ /** Retries for timeouts, network errors and 5xx responses. Defaults to 1. */
+ retries?: number;
+ /** Cache lifetime for this call in milliseconds. Overrides IPGEO_CACHE_TTL_MS. */
+ cacheTtlMs?: number;
+ /** Allow private and bogon addresses to reach the API. Off by default. */
+ allowPrivateIp?: boolean;
+ /** Override the endpoint. Mainly for testing against a mock server. */
+ baseUrl?: string;
+};
+
+function buildInclude(options: LookupOptions): string[] {
+ const requested = new Set();
+
+ for (const value of options.include ?? []) {
+ const trimmed = value.trim();
+ if (trimmed) requested.add(trimmed);
+ }
+
+ if (options.includeSecurity) requested.add('security');
+
+ const latched = securityUnavailableUntil > 0 && Date.now() < securityUnavailableUntil;
+ if (latched && requested.delete('security')) {
+ logDebug('Skipping the security module because the API key rejected it earlier.');
+ }
+
+ return [...requested];
+}
- const url = new URL('https://api.ipgeolocation.io/v3/ipgeo');
- url.searchParams.set('apiKey', apiKey);
- url.searchParams.set('ip', ip);
+function buildRequestUrl(options: LookupOptions, include: string[]): string {
+ const url = new URL(options.baseUrl ?? IPGEO_API_BASE_URL);
+
+ url.searchParams.set('apiKey', options.apiKey);
+ url.searchParams.set('ip', options.ip);
url.searchParams.set('output', 'json');
- if (includeSecurity) url.searchParams.set('include', 'security');
+ if (include.length > 0) url.searchParams.set('include', include.join(','));
+
+ if (options.fields && options.fields.length > 0) {
+ url.searchParams.set('fields', options.fields.join(','));
+ }
+
+ if (options.excludes && options.excludes.length > 0) {
+ url.searchParams.set('excludes', options.excludes.join(','));
+ }
+
+ return url.toString();
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function readErrorMessage(response: Response): Promise {
try {
- console.log(`[IPGeolocation.io] Looking up ${ip}`);
+ const text = await response.text();
+ return text.slice(0, 300);
+ } catch {
+ return '';
+ }
+}
+
+type RequestOutcome =
+ | { kind: 'ok'; data: IpGeoResponse; creditsCharged: number | null }
+ | { kind: 'http'; status: number; message: string }
+ | { kind: 'timeout' }
+ | { kind: 'network'; message: string }
+ | { kind: 'invalid'; message: string };
- const response = await fetch(url.toString(), {
+async function performRequest(url: string, timeoutMs: number): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+ try {
+ const response = await fetch(url, {
method: 'GET',
signal: controller.signal,
headers: { accept: 'application/json' }
});
if (!response.ok) {
- console.error(
- `[IPGeolocation.io] Lookup failed — status ${response.status}:`,
- await response.text()
- );
- return null;
+ return { kind: 'http', status: response.status, message: await readErrorMessage(response) };
+ }
+
+ const creditsHeader = response.headers.get('x-credits-charged');
+ const credits = creditsHeader === null ? null : Number(creditsHeader);
+
+ let data: unknown;
+
+ try {
+ data = await response.json();
+ } catch {
+ return { kind: 'invalid', message: 'The API response was not valid JSON.' };
}
- const data = (await response.json()) as IpGeoResponse;
- setCache(ip, data);
- return data;
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
+ return { kind: 'invalid', message: 'The API response was not a JSON object.' };
+ }
+
+ return {
+ kind: 'ok',
+ data: data as IpGeoResponse,
+ creditsCharged: credits !== null && Number.isFinite(credits) ? credits : null
+ };
} catch (error) {
- if (error instanceof Error && error.name === 'AbortError') {
- console.error(
- `[IPGeolocation.io] Lookup timed out after ${timeoutMs}ms for ${ip}`
- );
- } else {
- console.error('[IPGeolocation.io] Lookup error:', error);
+ if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
+ return { kind: 'timeout' };
}
- return null;
+
+ return { kind: 'network', message: error instanceof Error ? error.message : String(error) };
} finally {
- clearTimeout(timeout);
+ clearTimeout(timer);
}
}
-// ─────────────────────────────────────────────────────────────────────────────
-// Client IP extraction
-// ─────────────────────────────────────────────────────────────────────────────
+/**
+ * Looks up an IP address and returns a detailed result.
+ *
+ * Behaviour worth knowing about:
+ * - Responses are cached per IP and per module set for IPGEO_CACHE_TTL_MS.
+ * - Concurrent lookups for the same key share one request.
+ * - Timeouts, network errors and 5xx responses are retried once by default.
+ * - Repeated failures open a circuit breaker, so an API outage does not add
+ * the full timeout to every request.
+ * - If the security module is rejected with HTTP 401, the call is retried
+ * without it and the module is skipped for the next five minutes.
+ */
+export async function lookupIpGeolocationResult(options: LookupOptions): Promise {
+ const apiKey = String(options.apiKey ?? '').trim();
+ const rawIp = String(options.ip ?? '').trim();
-export function getClientIp(
- headers: Headers,
- trustFirstXff: boolean = false
-): string | null {
- const forwardedFor = headers.get('x-forwarded-for');
+ if (!apiKey) {
+ return { ok: false, reason: 'invalid_input', status: null, message: 'No API key was provided.' };
+ }
- if (forwardedFor) {
- const ips = forwardedFor
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean);
+ const ip = normalizeIp(rawIp);
- if (ips.length === 0) return null;
- return (trustFirstXff ? ips[0] : ips[ips.length - 1]) ?? null;
+ if (!ip) {
+ return {
+ ok: false,
+ reason: 'invalid_input',
+ status: null,
+ message: `"${rawIp}" is not a valid IP address.`
+ };
}
- return (
- headers.get('x-real-ip') ||
- headers.get('cf-connecting-ip') ||
- null
- );
-}
+ if (!options.allowPrivateIp && !isPublicIp(ip)) {
+ return {
+ ok: false,
+ reason: 'private_ip',
+ status: null,
+ message: `${ip} is a private or reserved address, which the API cannot resolve.`
+ };
+ }
+
+ const include = buildInclude(options);
+ const cacheKey = `${ip}|${include.slice().sort().join(',')}|${(options.fields ?? []).join(',')}`;
+
+ const cached = readCache(cacheKey);
+ if (cached) {
+ logDebug(`Cache hit for ${ip}.`);
+ return { ok: true, data: cached, cached: true, creditsCharged: 0 };
+ }
+
+ if (circuitIsOpen()) {
+ return {
+ ok: false,
+ reason: 'circuit_open',
+ status: null,
+ message: 'Lookups are paused because recent requests to the API failed.'
+ };
+ }
+
+ const existing = inFlight.get(cacheKey);
+ if (existing) return existing;
+
+ const promise = executeLookup(options, apiKey, ip, include, cacheKey).finally(() => {
+ inFlight.delete(cacheKey);
+ });
-// ─────────────────────────────────────────────────────────────────────────────
-// Env helpers
-// ─────────────────────────────────────────────────────────────────────────────
+ inFlight.set(cacheKey, promise);
-export function envFlag(value?: string): boolean {
- return String(value ?? '').toLowerCase() === 'true';
+ return promise;
}
-export function parseCsvEnv(value?: string): Set {
- return new Set(
- String(value ?? '')
- .split(',')
- .map((item) => item.trim().toUpperCase())
- .filter(Boolean)
+async function executeLookup(
+ options: LookupOptions,
+ apiKey: string,
+ ip: string,
+ include: string[],
+ cacheKey: string
+): Promise {
+ const timeoutMs = Math.max(
+ 250,
+ options.timeoutMs ?? envNumber(process.env.IPGEO_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, { min: 250 })
);
-}
-export function parseRedirectMap(value?: string): Record {
- if (!value) return {};
+ const retries = Math.max(
+ 0,
+ options.retries ?? envNumber(process.env.IPGEO_RETRIES, DEFAULT_RETRIES, { min: 0, max: 3 })
+ );
- try {
- const parsed = JSON.parse(value) as Record;
- return Object.fromEntries(
- Object.entries(parsed).map(([country, path]) => [
- country.toUpperCase(),
- path
- ])
- );
- } catch {
- console.warn(
- '[IPGeolocation.io] Invalid IPGEO_COUNTRY_REDIRECTS JSON (first 120 chars):',
- value.slice(0, 120)
- );
- return {};
+ let activeInclude = include;
+ let attempt = 0;
+
+ let lastFailure: LookupResult = {
+ ok: false,
+ reason: 'network_error',
+ status: null,
+ message: 'The lookup did not run.'
+ };
+
+ while (attempt <= retries) {
+ const url = buildRequestUrl({ ...options, apiKey, ip }, activeInclude);
+ const suffix = activeInclude.length > 0 ? ` (include: ${activeInclude.join(',')})` : '';
+ logDebug(`Looking up ${ip}${suffix}.`);
+
+ const outcome = await performRequest(url, timeoutMs);
+
+ if (outcome.kind === 'ok') {
+ recordSuccess();
+ writeCache(cacheKey, outcome.data, options.cacheTtlMs);
+
+ if (outcome.creditsCharged !== null) {
+ logDebug(`The lookup for ${ip} was charged ${outcome.creditsCharged} credit(s).`);
+ }
+
+ return {
+ ok: true,
+ data: outcome.data,
+ cached: false,
+ creditsCharged: outcome.creditsCharged
+ };
+ }
+
+ if (outcome.kind === 'http') {
+ const status = outcome.status;
+
+ // A free plan key cannot request the security module. Drop it, retry once
+ // and remember the answer so later requests do not repeat the mistake.
+ if (status === 401 && activeInclude.includes('security')) {
+ securityUnavailableUntil = Date.now() + SECURITY_LATCH_MS;
+ activeInclude = activeInclude.filter((item) => item !== 'security');
+ logError(
+ 'The API rejected the security module with HTTP 401, which means this key is not on a paid plan. Retrying without it. VPN, proxy, Tor, bot, spam, attacker and threat score rules cannot be applied until the plan is upgraded. See https://ipgeolocation.io/pricing.html'
+ );
+ continue;
+ }
+
+ if (status === 401 || status === 403) {
+ recordFailure();
+ return {
+ ok: false,
+ reason: 'unauthorized',
+ status,
+ message: `The API rejected the request with HTTP ${status}. Check IPGEOLOCATION_API_KEY and your subscription status. ${outcome.message}`
+ };
+ }
+
+ if (status === 423) {
+ return {
+ ok: false,
+ reason: 'private_ip',
+ status,
+ message: `${ip} is a private or bogon address. ${outcome.message}`
+ };
+ }
+
+ if (status === 429) {
+ recordFailure();
+ return {
+ ok: false,
+ reason: 'rate_limited',
+ status,
+ message: `The plan quota is exhausted (HTTP 429). ${outcome.message}`
+ };
+ }
+
+ if (status >= 500 && attempt < retries) {
+ attempt += 1;
+ await sleep(100 * attempt);
+ continue;
+ }
+
+ recordFailure();
+ return {
+ ok: false,
+ reason: 'http_error',
+ status,
+ message: `The API answered HTTP ${status}. ${outcome.message}`
+ };
+ }
+
+ if (outcome.kind === 'invalid') {
+ recordFailure();
+ return { ok: false, reason: 'invalid_response', status: null, message: outcome.message };
+ }
+
+ lastFailure =
+ outcome.kind === 'timeout'
+ ? {
+ ok: false,
+ reason: 'timeout',
+ status: null,
+ message: `The lookup for ${ip} timed out after ${timeoutMs}ms.`
+ }
+ : {
+ ok: false,
+ reason: 'network_error',
+ status: null,
+ message: `The lookup for ${ip} failed: ${outcome.message}`
+ };
+
+ if (attempt < retries) {
+ attempt += 1;
+ await sleep(100 * attempt);
+ continue;
+ }
+
+ break;
}
+
+ recordFailure();
+ return lastFailure;
}
-// ─────────────────────────────────────────────────────────────────────────────
-// Security block evaluation
-// ─────────────────────────────────────────────────────────────────────────────
+/**
+ * Looks up an IP address and returns the response, or null when the lookup
+ * cannot be completed. Use lookupIpGeolocationResult when you need the reason.
+ */
+export async function lookupIpGeolocation(options: LookupOptions): Promise {
+ const result = await lookupIpGeolocationResult(options);
-export function shouldBlockBySecurity(
- security: IpGeoSecurity | undefined
-): string | null {
- if (!security) return null;
+ if (result.ok) return result.data;
- if (envFlag(process.env.IPGEO_BLOCK_VPN) && security.is_vpn)
- return 'vpn';
- if (envFlag(process.env.IPGEO_BLOCK_PROXY) && security.is_proxy)
- return 'proxy';
- if (envFlag(process.env.IPGEO_BLOCK_TOR) && security.is_tor)
- return 'tor';
- if (envFlag(process.env.IPGEO_BLOCK_CLOUD_PROVIDER) && security.is_cloud_provider)
- return 'cloud_provider';
- if (envFlag(process.env.IPGEO_BLOCK_BOT) && security.is_bot)
- return 'bot';
- if (envFlag(process.env.IPGEO_BLOCK_SPAM) && security.is_spam)
- return 'spam';
- if (envFlag(process.env.IPGEO_BLOCK_KNOWN_ATTACKER) && security.is_known_attacker)
- return 'known_attacker';
-
- const threshold = Number(process.env.IPGEO_THREAT_SCORE_BLOCK_THRESHOLD || '');
- if (
- !Number.isNaN(threshold) &&
- threshold > 0 &&
- Number(security.threat_score ?? 0) >= threshold
- ) {
- return 'threat_score';
+ if (result.reason === 'private_ip' || result.reason === 'invalid_input') {
+ logDebug(result.message);
+ } else if (result.reason === 'circuit_open') {
+ logWarn(result.message);
+ } else {
+ logError(result.message);
}
return null;
}
+
+// -----------------------------------------------------------------------------
+// Header helpers
+// -----------------------------------------------------------------------------
+
+export type GeoHeaderMap = Record;
+
+/**
+ * Builds the geo headers for a request.
+ *
+ * Empty values are left out, so a missing header means the data was not
+ * available rather than an empty string.
+ */
+export function buildGeoHeaders(
+ geo: IpGeoResponse | null,
+ fallbackIp: string,
+ prefix = 'x-ipgeo'
+): GeoHeaderMap {
+ const headers: GeoHeaderMap = {};
+
+ const set = (name: string, value: string | undefined | null): void => {
+ if (value === undefined || value === null) return;
+ const text = String(value);
+ if (text === '') return;
+ headers[`${prefix}-${name}`] = text;
+ };
+
+ set('ip', geo?.ip ?? fallbackIp);
+
+ if (!geo) return headers;
+
+ const location = geo.location;
+
+ set('country', location?.country_code2?.toUpperCase());
+ set('country-name', location?.country_name);
+ set('continent', location?.continent_code);
+ set('state', location?.state_prov);
+ set('state-code', location?.state_code);
+ set('city', location?.city);
+ set('zipcode', location?.zipcode);
+ set('latitude', location?.latitude);
+ set('longitude', location?.longitude);
+ if (typeof location?.is_eu === 'boolean') set('is-eu', String(location.is_eu));
+
+ set('timezone', geo.time_zone?.name);
+ set('currency', geo.currency?.code);
+ set('asn', geo.asn?.as_number);
+ set('asn-organization', geo.asn?.organization);
+ set('company', geo.company?.name);
+
+ const security = geo.security;
+ if (!security) return headers;
+
+ if (typeof security.threat_score === 'number') set('threat-score', String(security.threat_score));
+
+ const flags: Array<[string, boolean | undefined]> = [
+ ['is-vpn', security.is_vpn],
+ ['is-proxy', security.is_proxy],
+ ['is-residential-proxy', security.is_residential_proxy],
+ ['is-tor', security.is_tor],
+ ['is-relay', security.is_relay],
+ ['is-anonymous', security.is_anonymous],
+ ['is-bot', security.is_bot],
+ ['is-known-good-bot', security.is_known_good_bot],
+ ['is-spam', security.is_spam],
+ ['is-known-attacker', security.is_known_attacker],
+ ['is-cloud-provider', security.is_cloud_provider]
+ ];
+
+ for (const [name, value] of flags) {
+ if (typeof value === 'boolean') set(name, String(value));
+ }
+
+ set('cloud-provider-name', security.cloud_provider_name);
+
+ return headers;
+}
+
+/**
+ * Removes every inbound header that uses the geo prefix.
+ *
+ * Without this, a visitor can send x-ipgeo-country themselves and your
+ * application cannot tell that value apart from one the middleware produced.
+ */
+export function stripSpoofedGeoHeaders(headers: Headers, prefix = 'x-ipgeo'): void {
+ const marker = `${prefix.toLowerCase()}-`;
+ const toDelete: string[] = [];
+
+ headers.forEach((_value, key) => {
+ if (key.toLowerCase().startsWith(marker)) toDelete.push(key);
+ });
+
+ for (const key of toDelete) headers.delete(key);
+}
diff --git a/middleware.test.ts b/middleware.test.ts
new file mode 100644
index 0000000..918929c
--- /dev/null
+++ b/middleware.test.ts
@@ -0,0 +1,705 @@
+// =============================================================================
+// Tests for middleware.ts
+// Run with: npm test
+// =============================================================================
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest, NextResponse } from 'next/server';
+
+import { resetIpGeoRuntimeState, type IpGeoResponse } from './ipgeolocation-edge.js';
+import { evaluateIpGeolocation, middleware, proxy, withIpGeolocation } from './middleware.js';
+
+// -----------------------------------------------------------------------------
+// Helpers
+// -----------------------------------------------------------------------------
+
+const ENV_KEYS = [
+ 'IPGEOLOCATION_API_KEY',
+ 'IPGEO_ALLOWED_COUNTRIES',
+ 'IPGEO_ALLOW_UNKNOWN_COUNTRY',
+ 'IPGEO_BLOCKED_COUNTRIES',
+ 'IPGEO_BLOCK_BOT',
+ 'IPGEO_BLOCK_INCLUDE_REASON',
+ 'IPGEO_BLOCK_MESSAGE',
+ 'IPGEO_BLOCK_MODE',
+ 'IPGEO_BLOCK_PATH',
+ 'IPGEO_BLOCK_STATUS',
+ 'IPGEO_BLOCK_TOR',
+ 'IPGEO_BLOCK_VPN',
+ 'IPGEO_BYPASS_IPS',
+ 'IPGEO_BYPASS_PATHS',
+ 'IPGEO_BYPASS_TOKEN',
+ 'IPGEO_CACHE_TTL_MS',
+ 'IPGEO_COUNTRY_REDIRECTS',
+ 'IPGEO_ENABLED',
+ 'IPGEO_FAIL_CLOSED',
+ 'IPGEO_HEADER_PREFIX',
+ 'IPGEO_LOG_LEVEL',
+ 'IPGEO_REDIRECT_PRESERVE_PATH',
+ 'IPGEO_REDIRECT_RESPECT_EXISTING',
+ 'IPGEO_REDIRECT_SKIP_COOKIE',
+ 'IPGEO_REDIRECT_STATUS',
+ 'IPGEO_REQUIRE_SECURITY',
+ 'IPGEO_RETRIES',
+ 'IPGEO_SET_RESPONSE_HEADERS',
+ 'IPGEO_THREAT_SCORE_BLOCK_THRESHOLD'
+];
+
+function clearEnv(): void {
+ for (const key of ENV_KEYS) delete process.env[key];
+}
+
+function makeRequest(
+ url: string,
+ init: { ip?: string; headers?: Record; method?: string } = {}
+): NextRequest {
+ const headers = new Headers(init.headers ?? {});
+ if (init.ip !== undefined) headers.set('x-forwarded-for', init.ip);
+
+ const requestInit: { headers: Headers; method?: string } = { headers };
+ if (init.method) requestInit.method = init.method;
+
+ return new NextRequest(url, requestInit);
+}
+
+function geoFor(countryCode: string, security?: IpGeoResponse['security']): IpGeoResponse {
+ const response: IpGeoResponse = {
+ ip: '8.8.8.8',
+ location: {
+ country_code2: countryCode,
+ country_name: countryCode === 'SE' ? 'Sweden' : 'United States',
+ city: 'Stockholm',
+ latitude: '59.40510',
+ longitude: '17.95510'
+ },
+ asn: { as_number: 'AS1257', organization: 'Tele2 Sverige AB' },
+ time_zone: { name: 'Europe/Stockholm' }
+ };
+
+ if (security) response.security = security;
+
+ return response;
+}
+
+function mockApi(body: IpGeoResponse | null, status = 200): ReturnType {
+ const fetchMock = vi.fn().mockImplementation(
+ async () =>
+ new Response(body === null ? 'error' : JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' }
+ })
+ );
+
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+/** Reads a request header that the middleware forwarded to the application. */
+function forwardedHeader(response: NextResponse, name: string): string | null {
+ return response.headers.get(`x-middleware-request-${name}`);
+}
+
+beforeEach(() => {
+ clearEnv();
+ process.env.IPGEO_LOG_LEVEL = 'silent';
+ process.env.IPGEOLOCATION_API_KEY = 'test-key';
+ process.env.IPGEO_RETRIES = '0';
+ resetIpGeoRuntimeState();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ clearEnv();
+});
+
+// -----------------------------------------------------------------------------
+// Pass through and configuration guards
+// -----------------------------------------------------------------------------
+
+describe('middleware guards', () => {
+ it('does nothing when no API key is set', async () => {
+ delete process.env.IPGEOLOCATION_API_KEY;
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when IPGEO_ENABLED is false', async () => {
+ process.env.IPGEO_ENABLED = 'false';
+ const fetchMock = mockApi(geoFor('SE'));
+
+ await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('skips the block page so it cannot loop', async () => {
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/blocked?reason=tor', { ip: '8.8.8.8' })
+ );
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('does not treat a path that merely starts with the block path as the block page', async () => {
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/blockedlist', { ip: '8.8.8.8' })
+ );
+
+ expect(response.status).toBe(307);
+ });
+
+ it('skips paths listed in IPGEO_BYPASS_PATHS', async () => {
+ process.env.IPGEO_BYPASS_PATHS = '/health,/api/webhooks';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/api/webhooks/stripe', { ip: '8.8.8.8' })
+ );
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('skips a request that carries the bypass token', async () => {
+ process.env.IPGEO_BYPASS_TOKEN = 'uptime-robot';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/', {
+ ip: '8.8.8.8',
+ headers: { 'x-ipgeo-bypass-token': 'uptime-robot' }
+ })
+ );
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('skips IPs listed in IPGEO_BYPASS_IPS', async () => {
+ process.env.IPGEO_BYPASS_IPS = '8.8.8.8';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('passes a local request through without calling the API', async () => {
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '127.0.0.1' }));
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it('passes through when no IP header is present', async () => {
+ const fetchMock = mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/'));
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Header hardening
+// -----------------------------------------------------------------------------
+
+describe('header hardening', () => {
+ it('removes client supplied geo headers before the request reaches the app', async () => {
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/', {
+ ip: '8.8.8.8',
+ headers: { 'x-ipgeo-country': 'US', 'x-ipgeo-is-vpn': 'false' }
+ })
+ );
+
+ expect(forwardedHeader(response, 'x-ipgeo-country')).toBe('SE');
+ expect(forwardedHeader(response, 'x-ipgeo-is-vpn')).toBeNull();
+ });
+
+ it('removes client supplied geo headers even when no lookup runs', async () => {
+ delete process.env.IPGEOLOCATION_API_KEY;
+
+ const evaluation = await evaluateIpGeolocation(
+ makeRequest('https://example.com/', {
+ ip: '8.8.8.8',
+ headers: { 'x-ipgeo-country': 'US' }
+ })
+ );
+
+ expect(evaluation.requestHeaders.get('x-ipgeo-country')).toBeNull();
+ });
+
+ it('forwards the geo headers on a normal request', async () => {
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(forwardedHeader(response, 'x-ipgeo-country')).toBe('SE');
+ expect(forwardedHeader(response, 'x-ipgeo-city')).toBe('Stockholm');
+ expect(forwardedHeader(response, 'x-ipgeo-timezone')).toBe('Europe/Stockholm');
+ expect(forwardedHeader(response, 'x-ipgeo-asn-organization')).toBe('Tele2 Sverige AB');
+ });
+
+ it('honours a custom header prefix', async () => {
+ process.env.IPGEO_HEADER_PREFIX = 'x-acme-geo';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(forwardedHeader(response, 'x-acme-geo-country')).toBe('SE');
+ });
+
+ it('adds the geo headers to the response when IPGEO_SET_RESPONSE_HEADERS is on', async () => {
+ process.env.IPGEO_SET_RESPONSE_HEADERS = 'true';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.headers.get('x-ipgeo-country')).toBe('SE');
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Country rules
+// -----------------------------------------------------------------------------
+
+describe('country rules', () => {
+ it('blocks a country on the block list', async () => {
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'se,ru';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get('location')).toBe('https://example.com/blocked?reason=country');
+ expect(response.headers.get('x-ipgeo-block-reason')).toBe('country');
+ expect(response.headers.get('cache-control')).toBe('no-store');
+ });
+
+ it('allows a country that is not on the block list', async () => {
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'RU';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('blocks a country that is not on the allow list', async () => {
+ process.env.IPGEO_ALLOWED_COUNTRIES = 'US,CA';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ });
+
+ it('allows a country on the allow list', async () => {
+ process.env.IPGEO_ALLOWED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('blocks an unknown country when an allow list is configured', async () => {
+ process.env.IPGEO_ALLOWED_COUNTRIES = 'US';
+ mockApi({ ip: '8.8.8.8', location: {} });
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ });
+
+ it('allows an unknown country when IPGEO_ALLOW_UNKNOWN_COUNTRY is on', async () => {
+ process.env.IPGEO_ALLOWED_COUNTRIES = 'US';
+ process.env.IPGEO_ALLOW_UNKNOWN_COUNTRY = 'true';
+ mockApi({ ip: '8.8.8.8', location: {} });
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('leaves the reason out when IPGEO_BLOCK_INCLUDE_REASON is off', async () => {
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ process.env.IPGEO_BLOCK_INCLUDE_REASON = 'false';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.headers.get('location')).toBe('https://example.com/blocked');
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Block modes
+// -----------------------------------------------------------------------------
+
+describe('block modes', () => {
+ it('answers 403 in deny mode without needing a block page', async () => {
+ process.env.IPGEO_BLOCK_MODE = 'deny';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(403);
+ expect(await response.text()).toBe('Access to this site is restricted.');
+ });
+
+ it('uses a custom status and message in deny mode', async () => {
+ process.env.IPGEO_BLOCK_MODE = 'deny';
+ process.env.IPGEO_BLOCK_STATUS = '451';
+ process.env.IPGEO_BLOCK_MESSAGE = 'Not available in your region.';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(451);
+ expect(await response.text()).toBe('Not available in your region.');
+ });
+
+ it('keeps the visitor URL in rewrite mode', async () => {
+ process.env.IPGEO_BLOCK_MODE = 'rewrite';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/pricing', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('x-middleware-rewrite')).toBe(
+ 'https://example.com/blocked?reason=country'
+ );
+ });
+
+ it('honours a custom block path', async () => {
+ process.env.IPGEO_BLOCK_PATH = '/access-denied';
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.headers.get('location')).toBe(
+ 'https://example.com/access-denied?reason=country'
+ );
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Security rules
+// -----------------------------------------------------------------------------
+
+describe('security rules', () => {
+ it('does not request the security module when no security rule is on', async () => {
+ const fetchMock = mockApi(geoFor('SE'));
+
+ await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(String(fetchMock.mock.calls[0]?.[0])).not.toContain('include=');
+ });
+
+ it('requests the security module when a security rule is on', async () => {
+ process.env.IPGEO_BLOCK_VPN = 'true';
+ const fetchMock = mockApi(geoFor('SE', { is_vpn: false }));
+
+ await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain('include=security');
+ });
+
+ it('blocks a VPN with the matching reason', async () => {
+ process.env.IPGEO_BLOCK_VPN = 'true';
+ mockApi(geoFor('SE', { is_vpn: true }));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get('location')).toBe('https://example.com/blocked?reason=vpn');
+ });
+
+ it('blocks on the threat score threshold', async () => {
+ process.env.IPGEO_THREAT_SCORE_BLOCK_THRESHOLD = '75';
+ mockApi(geoFor('SE', { threat_score: 80 }));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.headers.get('location')).toBe('https://example.com/blocked?reason=threat_score');
+ });
+
+ it('passes the request through when security data is missing', async () => {
+ process.env.IPGEO_BLOCK_VPN = 'true';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('blocks when security data is missing and IPGEO_REQUIRE_SECURITY is on', async () => {
+ process.env.IPGEO_BLOCK_VPN = 'true';
+ process.env.IPGEO_REQUIRE_SECURITY = 'true';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.headers.get('location')).toBe(
+ 'https://example.com/blocked?reason=security_unavailable'
+ );
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Country redirects
+// -----------------------------------------------------------------------------
+
+describe('country redirects', () => {
+ it('redirects to the mapped path', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get('location')).toBe('https://example.com/se');
+ });
+
+ it('keeps the path and the query string', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/pricing?ref=ad', { ip: '8.8.8.8' })
+ );
+
+ expect(response.headers.get('location')).toBe('https://example.com/se/pricing?ref=ad');
+ });
+
+ it('drops the path when IPGEO_REDIRECT_PRESERVE_PATH is off', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ process.env.IPGEO_REDIRECT_PRESERVE_PATH = 'false';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/pricing?ref=ad', { ip: '8.8.8.8' })
+ );
+
+ expect(response.headers.get('location')).toBe('https://example.com/se');
+ });
+
+ it('does not redirect a visitor who is already on the target path', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/se', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('does not loop when the configured target has a trailing slash', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se/"}';
+ mockApi(geoFor('SE'));
+
+ const first = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+ expect(first.headers.get('location')).toBe('https://example.com/se');
+
+ const second = await middleware(makeRequest('https://example.com/se', { ip: '8.8.8.8' }));
+ expect(second.status).toBe(200);
+ });
+
+ it('leaves a visitor alone on another mapped locale', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se","US":"/us"}';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/us', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('redirects across locales when IPGEO_REDIRECT_RESPECT_EXISTING is off', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se","US":"/us"}';
+ process.env.IPGEO_REDIRECT_RESPECT_EXISTING = 'false';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/us', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ });
+
+ it('does not redirect a POST request', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/checkout', { ip: '8.8.8.8', method: 'POST' })
+ );
+
+ expect(response.status).toBe(200);
+ });
+
+ it('skips the redirect when the opt out cookie is present', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ process.env.IPGEO_REDIRECT_SKIP_COOKIE = 'ipgeo_no_redirect';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(
+ makeRequest('https://example.com/', {
+ ip: '8.8.8.8',
+ headers: { cookie: 'ipgeo_no_redirect=1' }
+ })
+ );
+
+ expect(response.status).toBe(200);
+ });
+
+ it('uses the configured redirect status', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"/se"}';
+ process.env.IPGEO_REDIRECT_STATUS = '302';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(302);
+ });
+
+ it('ignores an off site redirect target', async () => {
+ process.env.IPGEO_COUNTRY_REDIRECTS = '{"SE":"https://evil.example"}';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Failure handling
+// -----------------------------------------------------------------------------
+
+describe('failure handling', () => {
+ it('passes the request through when the lookup fails', async () => {
+ mockApi(null, 500);
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('blocks when the lookup fails and IPGEO_FAIL_CLOSED is on', async () => {
+ process.env.IPGEO_FAIL_CLOSED = 'true';
+ mockApi(null, 500);
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get('location')).toBe(
+ 'https://example.com/blocked?reason=lookup_failed'
+ );
+ });
+
+ it('does not block a local request in fail closed mode', async () => {
+ process.env.IPGEO_FAIL_CLOSED = 'true';
+ mockApi(geoFor('SE'));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '10.0.0.4' }));
+
+ expect(response.status).toBe(200);
+ });
+
+ it('passes the request through when fetch throws', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
+
+ const response = await middleware(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ });
+});
+
+// -----------------------------------------------------------------------------
+// Composition
+// -----------------------------------------------------------------------------
+
+describe('composition', () => {
+ it('exposes the evaluation so your own middleware can forward the headers', async () => {
+ mockApi(geoFor('SE'));
+
+ const evaluation = await evaluateIpGeolocation(
+ makeRequest('https://example.com/', { ip: '8.8.8.8' })
+ );
+
+ expect(evaluation.action).toBe('pass');
+ expect(evaluation.response).toBeNull();
+ expect(evaluation.country).toBe('SE');
+ expect(evaluation.requestHeaders.get('x-ipgeo-country')).toBe('SE');
+ });
+
+ it('keeps the geo headers when the wrapped handler passes the request on', async () => {
+ mockApi(geoFor('SE'));
+
+ const composed = withIpGeolocation(async (_request, geo) =>
+ NextResponse.next({ request: { headers: geo.requestHeaders } })
+ );
+
+ const response = await composed(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(forwardedHeader(response as NextResponse, 'x-ipgeo-country')).toBe('SE');
+ });
+
+ it('does not call the wrapped handler when the request is blocked', async () => {
+ process.env.IPGEO_BLOCKED_COUNTRIES = 'SE';
+ mockApi(geoFor('SE'));
+
+ const handler = vi.fn().mockResolvedValue(NextResponse.next());
+ const composed = withIpGeolocation(handler);
+
+ const response = await composed(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(handler).not.toHaveBeenCalled();
+ expect(response.status).toBe(307);
+ });
+
+ it('accepts configuration overrides in code', async () => {
+ mockApi(geoFor('SE'));
+
+ const evaluation = await evaluateIpGeolocation(
+ makeRequest('https://example.com/', { ip: '8.8.8.8' }),
+ { blockedCountries: new Set(['SE']) }
+ );
+
+ expect(evaluation.action).toBe('block');
+ expect(evaluation.reason).toBe('country');
+ });
+
+ it('exports proxy as an alias for Next.js 16', async () => {
+ mockApi(geoFor('SE'));
+
+ const response = await proxy(makeRequest('https://example.com/', { ip: '8.8.8.8' }));
+
+ expect(response.status).toBe(200);
+ expect(forwardedHeader(response, 'x-ipgeo-country')).toBe('SE');
+ });
+});
diff --git a/middleware.ts b/middleware.ts
index 2315cab..8721696 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,134 +1,527 @@
-// ─────────────────────────────────────────────────────────────────────────────
-// IPGeolocation.io – Next.js Middleware
+// =============================================================================
+// IPGeolocation.io middleware for Next.js
//
-// Usage (Next.js ≥ 13):
-// Copy this file to `middleware.ts` at your project root, or import the
-// middleware function and call it from your own middleware.ts:
+// Next.js 13, 14 and 15 read this from middleware.ts at the project root.
+// Next.js 16 reads proxy.ts and expects an exported function named proxy, which
+// this module also exports.
//
-// import { middleware, config } from 'ipgeolocation-vercel-middleware/middleware';
-// export { middleware, config };
+// Create middleware.ts (or proxy.ts) at your project root with:
//
-// ─────────────────────────────────────────────────────────────────────────────
+// import { middleware } from 'ipgeolocation-vercel-middleware/middleware';
+// export { middleware };
+// export const config = { matcher: ['/((?!_next/static|_next/image).*)'] };
+//
+// Define config locally. Next.js reads the matcher at build time and cannot
+// follow a re-exported value.
+// =============================================================================
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
+
import {
+ buildGeoHeaders,
+ envFlag,
+ envNumber,
getClientIp,
- lookupIpGeolocation,
+ getSecurityRulesFromEnv,
+ isUnderPath,
+ lookupIpGeolocationResult,
+ normalizeInternalPath,
parseCsvEnv,
+ parseList,
parseRedirectMap,
+ securityRulesEnabled,
shouldBlockBySecurity,
- envFlag
+ stripSpoofedGeoHeaders,
+ type IpGeoResponse,
+ type SecurityRules
} from './ipgeolocation-edge.js';
-export async function middleware(request: NextRequest): Promise {
- const apiKey = process.env.IPGEOLOCATION_API_KEY;
+// -----------------------------------------------------------------------------
+// Configuration
+// -----------------------------------------------------------------------------
+
+export type BlockMode = 'redirect' | 'rewrite' | 'deny';
+
+export type IpGeoMiddlewareConfig = {
+ enabled: boolean;
+ apiKey: string;
+ headerPrefix: string;
+ blockPath: string;
+ blockMode: BlockMode;
+ blockStatus: number;
+ blockMessage: string;
+ includeBlockReason: boolean;
+ failClosed: boolean;
+ timeoutMs: number;
+ trustFirstXff: boolean;
+ trustedProxyCount: number;
+ allowedCountries: Set;
+ blockedCountries: Set;
+ allowUnknownCountry: boolean;
+ countryRedirects: Record;
+ redirectStatus: 307 | 308 | 302 | 301;
+ redirectPreservePath: boolean;
+ redirectRespectExisting: boolean;
+ redirectSkipCookie: string;
+ securityRules: SecurityRules;
+ requireSecurity: boolean;
+ bypassPaths: string[];
+ bypassIps: Set;
+ bypassToken: string;
+ responseHeaders: boolean;
+ fields: string[];
+};
- if (!apiKey) return NextResponse.next();
+const REDIRECT_STATUSES = new Set([301, 302, 307, 308]);
- // ── Config ────────────────────────────────────────────────────────────────
+let redirectMapCacheKey: string | undefined;
+let redirectMapCacheValue: Record = {};
+
+function readRedirectMap(): Record {
+ const raw = process.env.IPGEO_COUNTRY_REDIRECTS ?? '';
+
+ if (raw !== redirectMapCacheKey) {
+ redirectMapCacheKey = raw;
+ redirectMapCacheValue = parseRedirectMap(raw);
+ }
+
+ return redirectMapCacheValue;
+}
- const blockPath = process.env.IPGEO_BLOCK_PATH || '/blocked';
- const headerPrefix = process.env.IPGEO_HEADER_PREFIX || 'x-ipgeo';
- const failClosed = envFlag(process.env.IPGEO_FAIL_CLOSED);
- const trustFirstXff = envFlag(process.env.IPGEO_TRUST_FIRST_XFF);
+/** Builds the active configuration from environment variables. */
+export function getMiddlewareConfig(
+ overrides: Partial = {}
+): IpGeoMiddlewareConfig {
+ const blockMode = String(process.env.IPGEO_BLOCK_MODE ?? 'redirect').trim().toLowerCase();
+ const redirectStatus = envNumber(process.env.IPGEO_REDIRECT_STATUS, 307);
- // ── Guard: never re-run on the block page itself ──────────────────────────
+ const base: IpGeoMiddlewareConfig = {
+ enabled: envFlag(process.env.IPGEO_ENABLED, true),
+ apiKey: String(process.env.IPGEOLOCATION_API_KEY ?? '').trim(),
+ headerPrefix: (process.env.IPGEO_HEADER_PREFIX || 'x-ipgeo').trim().toLowerCase(),
+ blockPath: normalizeInternalPath(process.env.IPGEO_BLOCK_PATH || '/blocked') ?? '/blocked',
+ blockMode: (blockMode === 'rewrite' || blockMode === 'deny' ? blockMode : 'redirect') as BlockMode,
+ blockStatus: envNumber(process.env.IPGEO_BLOCK_STATUS, 403, { min: 400, max: 599 }),
+ blockMessage: process.env.IPGEO_BLOCK_MESSAGE || 'Access to this site is restricted.',
+ includeBlockReason: envFlag(process.env.IPGEO_BLOCK_INCLUDE_REASON, true),
+ failClosed: envFlag(process.env.IPGEO_FAIL_CLOSED),
+ timeoutMs: envNumber(process.env.IPGEO_TIMEOUT_MS, 3000, { min: 250, max: 20_000 }),
+ trustFirstXff: envFlag(process.env.IPGEO_TRUST_FIRST_XFF),
+ trustedProxyCount: envNumber(process.env.IPGEO_TRUSTED_PROXY_COUNT, 0, { min: 0, max: 10 }),
+ allowedCountries: parseCsvEnv(process.env.IPGEO_ALLOWED_COUNTRIES),
+ blockedCountries: parseCsvEnv(process.env.IPGEO_BLOCKED_COUNTRIES),
+ allowUnknownCountry: envFlag(process.env.IPGEO_ALLOW_UNKNOWN_COUNTRY),
+ countryRedirects: readRedirectMap(),
+ redirectStatus: (REDIRECT_STATUSES.has(redirectStatus) ? redirectStatus : 307) as 307,
+ redirectPreservePath: envFlag(process.env.IPGEO_REDIRECT_PRESERVE_PATH, true),
+ redirectRespectExisting: envFlag(process.env.IPGEO_REDIRECT_RESPECT_EXISTING, true),
+ redirectSkipCookie: (process.env.IPGEO_REDIRECT_SKIP_COOKIE || '').trim(),
+ securityRules: getSecurityRulesFromEnv(),
+ requireSecurity: envFlag(process.env.IPGEO_REQUIRE_SECURITY),
+ bypassPaths: parseList(process.env.IPGEO_BYPASS_PATHS)
+ .map((path) => normalizeInternalPath(path))
+ .filter((path): path is string => path !== null),
+ bypassIps: new Set(parseList(process.env.IPGEO_BYPASS_IPS)),
+ bypassToken: (process.env.IPGEO_BYPASS_TOKEN || '').trim(),
+ responseHeaders: envFlag(process.env.IPGEO_SET_RESPONSE_HEADERS),
+ fields: parseList(process.env.IPGEO_FIELDS)
+ };
+
+ return { ...base, ...overrides };
+}
+
+// -----------------------------------------------------------------------------
+// Evaluation result
+// -----------------------------------------------------------------------------
+
+export type IpGeoAction = 'disabled' | 'bypass' | 'pass' | 'block' | 'redirect';
+
+export type IpGeoEvaluation = {
+ /** What the middleware decided to do. */
+ action: IpGeoAction;
+ /** The response to return, or null when the request should continue. */
+ response: NextResponse | null;
+ /** Request headers with the geo values added and spoofed values removed. */
+ requestHeaders: Headers;
+ /** The API response, or null when no lookup ran or the lookup failed. */
+ geo: IpGeoResponse | null;
+ /** The resolved client IP, or null when none could be read. */
+ ip: string | null;
+ /** ISO 3166-1 alpha-2 country code, or null when it is unknown. */
+ country: string | null;
+ /** Why the request was blocked or redirected. */
+ reason: string | null;
+};
+
+function passThrough(
+ action: IpGeoAction,
+ requestHeaders: Headers,
+ extra: Partial = {}
+): IpGeoEvaluation {
+ return {
+ action,
+ response: null,
+ requestHeaders,
+ geo: null,
+ ip: null,
+ country: null,
+ reason: null,
+ ...extra
+ };
+}
+
+function buildBlockResponse(
+ request: NextRequest,
+ config: IpGeoMiddlewareConfig,
+ reason: string
+): NextResponse {
+ let response: NextResponse;
+
+ if (config.blockMode === 'deny') {
+ response = new NextResponse(config.blockMessage, {
+ status: config.blockStatus,
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
+ });
+ } else if (config.blockMode === 'rewrite') {
+ const url = new URL(config.blockPath, request.url);
+ if (config.includeBlockReason) url.searchParams.set('reason', reason);
+ response = NextResponse.rewrite(url);
+ } else {
+ const url = new URL(config.blockPath, request.url);
+ if (config.includeBlockReason) url.searchParams.set('reason', reason);
+ response = NextResponse.redirect(url, 307);
+ }
+
+ response.headers.set(`${config.headerPrefix}-block-reason`, reason);
+ response.headers.set('cache-control', 'no-store');
+
+ return response;
+}
+
+function buildRedirectResponse(
+ request: NextRequest,
+ config: IpGeoMiddlewareConfig,
+ target: string
+): NextResponse {
+ const url = new URL(target, request.url);
+
+ if (config.redirectPreservePath) {
+ const { pathname, search } = request.nextUrl;
+ const suffix = pathname === '/' ? '' : pathname;
+ url.pathname = `${target}${suffix}`;
+ url.search = search;
+ }
+
+ const response = NextResponse.redirect(url, config.redirectStatus);
+ response.headers.set('cache-control', 'no-store');
+
+ return response;
+}
+
+// -----------------------------------------------------------------------------
+// Core evaluation
+// -----------------------------------------------------------------------------
+
+/**
+ * Runs the full IPGeolocation.io decision for a request without sending a
+ * response. Use it when you want to combine geo rules with your own middleware
+ * and still forward the geo headers.
+ *
+ * const result = await evaluateIpGeolocation(request);
+ * if (result.response) return result.response;
+ * return NextResponse.next({ request: { headers: result.requestHeaders } });
+ */
+export async function evaluateIpGeolocation(
+ request: NextRequest,
+ overrides: Partial = {}
+): Promise {
+ const config = getMiddlewareConfig(overrides);
+
+ const requestHeaders = new Headers(request.headers);
+
+ // A visitor can send x-ipgeo-* headers. Remove them on every path so the
+ // application can trust what it receives.
+ stripSpoofedGeoHeaders(requestHeaders, config.headerPrefix);
+
+ if (!config.enabled) return passThrough('disabled', requestHeaders);
+ if (!config.apiKey) return passThrough('disabled', requestHeaders);
const { pathname } = request.nextUrl;
- if (pathname === blockPath || pathname.startsWith(blockPath + '/')) {
- return NextResponse.next();
+
+ if (isUnderPath(pathname, config.blockPath)) return passThrough('bypass', requestHeaders);
+
+ for (const bypassPath of config.bypassPaths) {
+ if (isUnderPath(pathname, bypassPath)) return passThrough('bypass', requestHeaders);
+ }
+
+ if (config.bypassToken) {
+ const token = request.headers.get(`${config.headerPrefix}-bypass-token`);
+ if (token && token === config.bypassToken) return passThrough('bypass', requestHeaders);
}
- // ── Extract client IP ─────────────────────────────────────────────────────
+ const ip = getClientIp(request.headers, {
+ trustFirstXff: config.trustFirstXff,
+ trustedProxyCount: config.trustedProxyCount
+ });
- const ip = getClientIp(request.headers, trustFirstXff);
- if (!ip) return NextResponse.next();
+ if (!ip) return passThrough('pass', requestHeaders);
+ if (config.bypassIps.has(ip)) return passThrough('bypass', requestHeaders, { ip });
- // ── Geo lookup ────────────────────────────────────────────────────────────
+ const needsSecurity = securityRulesEnabled(config.securityRules);
- const geo = await lookupIpGeolocation({
- apiKey,
+ const lookup = await lookupIpGeolocationResult({
+ apiKey: config.apiKey,
ip,
- includeSecurity: true,
- timeoutMs: Number(process.env.IPGEO_TIMEOUT_MS || '3000')
+ include: needsSecurity ? ['security'] : [],
+ timeoutMs: config.timeoutMs,
+ ...(config.fields.length > 0 ? { fields: config.fields } : {})
});
- if (!geo) {
- if (failClosed) {
- const url = new URL(blockPath, request.url);
- url.searchParams.set('reason', 'lookup_failed');
- return NextResponse.redirect(url);
+ if (!lookup.ok) {
+ // A private address is not a failure. It means the request did not come
+ // through the edge, which is normal in local development.
+ const lookupFailed = lookup.reason !== 'private_ip' && lookup.reason !== 'invalid_input';
+
+ if (lookupFailed && config.failClosed) {
+ return {
+ action: 'block',
+ response: buildBlockResponse(request, config, 'lookup_failed'),
+ requestHeaders,
+ geo: null,
+ ip,
+ country: null,
+ reason: 'lookup_failed'
+ };
}
- return NextResponse.next();
+
+ return passThrough('pass', requestHeaders, { ip });
}
- // ── Country controls ──────────────────────────────────────────────────────
+ const geo = lookup.data;
+ const country = geo.location?.country_code2?.toUpperCase() || null;
- const countryCode = geo.location?.country_code2?.toUpperCase() ?? '';
- const allowedCountries = parseCsvEnv(process.env.IPGEO_ALLOWED_COUNTRIES);
- const blockedCountries = parseCsvEnv(process.env.IPGEO_BLOCKED_COUNTRIES);
+ // Country allow list. An unknown country fails the allow list unless
+ // IPGEO_ALLOW_UNKNOWN_COUNTRY is on, because an allow list that lets unknown
+ // traffic through is not an allow list.
+ if (config.allowedCountries.size > 0) {
+ const allowed = country ? config.allowedCountries.has(country) : config.allowUnknownCountry;
- if (countryCode && allowedCountries.size > 0 && !allowedCountries.has(countryCode)) {
- return NextResponse.redirect(new URL(blockPath, request.url));
+ if (!allowed) {
+ return {
+ action: 'block',
+ response: buildBlockResponse(request, config, 'country'),
+ requestHeaders,
+ geo,
+ ip,
+ country,
+ reason: 'country'
+ };
+ }
}
- if (countryCode && blockedCountries.has(countryCode)) {
- return NextResponse.redirect(new URL(blockPath, request.url));
+ if (country && config.blockedCountries.has(country)) {
+ return {
+ action: 'block',
+ response: buildBlockResponse(request, config, 'country'),
+ requestHeaders,
+ geo,
+ ip,
+ country,
+ reason: 'country'
+ };
}
- // ── Security controls ─────────────────────────────────────────────────────
+ // Security rules.
+ if (needsSecurity) {
+ if (!geo.security) {
+ if (config.requireSecurity) {
+ return {
+ action: 'block',
+ response: buildBlockResponse(request, config, 'security_unavailable'),
+ requestHeaders,
+ geo,
+ ip,
+ country,
+ reason: 'security_unavailable'
+ };
+ }
+ } else {
+ const securityReason = shouldBlockBySecurity(geo.security, config.securityRules);
- const securityBlockReason = shouldBlockBySecurity(geo.security);
+ if (securityReason) {
+ return {
+ action: 'block',
+ response: buildBlockResponse(request, config, securityReason),
+ requestHeaders,
+ geo,
+ ip,
+ country,
+ reason: securityReason
+ };
+ }
+ }
+ }
- if (securityBlockReason) {
- const url = new URL(blockPath, request.url);
- url.searchParams.set('reason', securityBlockReason);
- return NextResponse.redirect(url);
+ // Country redirects.
+ const target = country ? config.countryRedirects[country] : undefined;
+
+ if (target && shouldRedirect(request, config, target)) {
+ const response = buildRedirectResponse(request, config, target);
+
+ return {
+ action: 'redirect',
+ response,
+ requestHeaders,
+ geo,
+ ip,
+ country,
+ reason: `redirect:${target}`
+ };
}
- // ── Country-based redirects ───────────────────────────────────────────────
+ // Pass through with geo headers attached.
+ const geoHeaders = buildGeoHeaders(geo, ip, config.headerPrefix);
- const redirectMap = parseRedirectMap(process.env.IPGEO_COUNTRY_REDIRECTS);
- const redirectPath = countryCode ? redirectMap[countryCode] : undefined;
+ for (const [name, value] of Object.entries(geoHeaders)) {
+ requestHeaders.set(name, value);
+ }
- if (redirectPath && !pathname.startsWith(redirectPath)) {
- return NextResponse.redirect(new URL(redirectPath, request.url));
+ return {
+ action: 'pass',
+ response: null,
+ requestHeaders,
+ geo,
+ ip,
+ country,
+ reason: null
+ };
+}
+
+function shouldRedirect(
+ request: NextRequest,
+ config: IpGeoMiddlewareConfig,
+ target: string
+): boolean {
+ const method = request.method.toUpperCase();
+ if (method !== 'GET' && method !== 'HEAD') return false;
+
+ const { pathname } = request.nextUrl;
+
+ // Already at or below the target.
+ if (isUnderPath(pathname, target)) return false;
+
+ // The visitor chose another locale that is also in the map.
+ if (config.redirectRespectExisting) {
+ for (const candidate of Object.values(config.countryRedirects)) {
+ if (isUnderPath(pathname, candidate)) return false;
+ }
}
- // ── Forward geo headers to the origin ────────────────────────────────────
+ if (config.redirectSkipCookie) {
+ const cookie = request.cookies.get(config.redirectSkipCookie);
+ if (cookie) return false;
+ }
- const requestHeaders = new Headers(request.headers);
+ return true;
+}
+
+// -----------------------------------------------------------------------------
+// Middleware entry points
+// -----------------------------------------------------------------------------
- requestHeaders.set(`${headerPrefix}-ip`, geo.ip ?? ip);
- requestHeaders.set(`${headerPrefix}-country`, countryCode);
- requestHeaders.set(`${headerPrefix}-country-name`, geo.location?.country_name ?? '');
- requestHeaders.set(`${headerPrefix}-state`, geo.location?.state_prov ?? '');
- requestHeaders.set(`${headerPrefix}-city`, geo.location?.city ?? '');
- requestHeaders.set(`${headerPrefix}-latitude`, geo.location?.latitude ?? '');
- requestHeaders.set(`${headerPrefix}-longitude`, geo.location?.longitude ?? '');
- requestHeaders.set(`${headerPrefix}-timezone`, geo.time_zone?.name ?? '');
- requestHeaders.set(`${headerPrefix}-asn`, geo.asn?.as_number ?? '');
- requestHeaders.set(`${headerPrefix}-asn-organization`, geo.asn?.organization ?? '');
- requestHeaders.set(`${headerPrefix}-threat-score`, String(geo.security?.threat_score ?? ''));
- requestHeaders.set(`${headerPrefix}-is-vpn`, String(Boolean(geo.security?.is_vpn)));
- requestHeaders.set(`${headerPrefix}-is-proxy`, String(Boolean(geo.security?.is_proxy)));
- requestHeaders.set(`${headerPrefix}-is-tor`, String(Boolean(geo.security?.is_tor)));
- requestHeaders.set(`${headerPrefix}-is-bot`, String(Boolean(geo.security?.is_bot)));
- requestHeaders.set(`${headerPrefix}-is-spam`, String(Boolean(geo.security?.is_spam)));
- requestHeaders.set(`${headerPrefix}-is-known-attacker`, String(Boolean(geo.security?.is_known_attacker)));
- requestHeaders.set(`${headerPrefix}-is-cloud-provider`, String(Boolean(geo.security?.is_cloud_provider)));
- requestHeaders.set(`${headerPrefix}-cloud-provider-name`, geo.security?.cloud_provider_name ?? '');
-
- return NextResponse.next({ request: { headers: requestHeaders } });
+function applyResponseHeaders(
+ response: NextResponse,
+ evaluation: IpGeoEvaluation,
+ prefix: string
+): NextResponse {
+ const geoHeaders = buildGeoHeaders(evaluation.geo, evaluation.ip ?? '', prefix);
+
+ for (const [name, value] of Object.entries(geoHeaders)) {
+ response.headers.set(name, value);
+ }
+
+ return response;
+}
+
+/**
+ * Builds a middleware function with configuration overrides applied on top of
+ * the environment variables.
+ */
+export function createIpGeoMiddleware(
+ overrides: Partial = {}
+): (request: NextRequest) => Promise {
+ return async function ipGeoMiddleware(request: NextRequest): Promise {
+ try {
+ const evaluation = await evaluateIpGeolocation(request, overrides);
+
+ if (evaluation.response) return evaluation.response;
+
+ const response = NextResponse.next({ request: { headers: evaluation.requestHeaders } });
+
+ const config = getMiddlewareConfig(overrides);
+ if (config.responseHeaders && evaluation.geo) {
+ applyResponseHeaders(response, evaluation, config.headerPrefix);
+ }
+
+ return response;
+ } catch (error) {
+ // Middleware runs in front of every request, so an unexpected error must
+ // never take the site down. Fail open unless fail closed is requested.
+ console.error('[IPGeolocation.io] The middleware threw an unexpected error:', error);
+
+ if (envFlag(process.env.IPGEO_FAIL_CLOSED)) {
+ const blockPath = normalizeInternalPath(process.env.IPGEO_BLOCK_PATH || '/blocked') ?? '/blocked';
+ const url = new URL(blockPath, request.url);
+ url.searchParams.set('reason', 'middleware_error');
+ return NextResponse.redirect(url, 307);
+ }
+
+ return NextResponse.next();
+ }
+ };
+}
+
+const defaultMiddleware = createIpGeoMiddleware();
+
+/** The ready to use middleware. Next.js 13, 14 and 15 expect this name. */
+export async function middleware(request: NextRequest): Promise {
+ return defaultMiddleware(request);
}
-// ─────────────────────────────────────────────────────────────────────────────
-// Matcher — skips static assets, favicons, sitemaps, and the block page
-// ─────────────────────────────────────────────────────────────────────────────
+/** The same function under the name Next.js 16 expects in proxy.ts. */
+export const proxy = middleware;
+
+/**
+ * Wraps your own handler. The geo decision runs first, and your handler
+ * receives the evaluation so it can forward the geo headers.
+ *
+ * export const middleware = withIpGeolocation(async (request, geo) => {
+ * if (!isSignedIn(request)) return NextResponse.redirect(new URL('/login', request.url));
+ * return NextResponse.next({ request: { headers: geo.requestHeaders } });
+ * });
+ */
+export function withIpGeolocation(
+ handler: (request: NextRequest, evaluation: IpGeoEvaluation) => Promise | Response,
+ overrides: Partial = {}
+): (request: NextRequest) => Promise {
+ return async function composedMiddleware(request: NextRequest): Promise {
+ const evaluation = await evaluateIpGeolocation(request, overrides);
+ if (evaluation.response) return evaluation.response;
+ return handler(request, evaluation);
+ };
+}
+
+// -----------------------------------------------------------------------------
+// Matcher
+//
+// Skips Next.js internals and common static file extensions so that images,
+// fonts and stylesheets never trigger a lookup. Every lookup you avoid is a
+// credit you keep.
+// -----------------------------------------------------------------------------
export const config = {
matcher: [
- '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|blocked).*)'
+ '/((?!_next/static|_next/image|_next/data|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:css|js|mjs|map|json|txt|xml|ico|png|jpg|jpeg|gif|webp|avif|svg|woff|woff2|ttf|otf|eot|mp4|webm|mp3|pdf|zip)$).*)'
]
};
diff --git a/package-lock.json b/package-lock.json
index 70ef141..4c843db 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,17 @@
{
"name": "ipgeolocation-vercel-middleware",
- "version": "1.0.0",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ipgeolocation-vercel-middleware",
- "version": "1.0.0",
+ "version": "2.0.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "^1.0.0",
+ "next": "^15.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
@@ -97,9 +98,9 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -550,9 +551,9 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -564,12 +565,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -587,12 +588,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -610,12 +611,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -627,12 +628,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -644,12 +645,12 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -661,12 +662,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -678,12 +679,12 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -695,12 +696,12 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -712,12 +713,12 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -729,12 +730,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -746,12 +747,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -763,12 +764,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -780,12 +781,12 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -803,12 +804,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -826,12 +827,12 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -849,12 +850,12 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -872,12 +873,12 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -895,12 +896,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -918,12 +919,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -941,12 +942,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -964,9 +965,9 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
@@ -984,12 +985,12 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -1004,12 +1005,12 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -1024,12 +1025,12 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -1100,144 +1101,144 @@
}
},
"node_modules/@next/env": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
- "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
- "license": "MIT",
- "peer": true
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz",
+ "integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
- "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz",
+ "integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
- "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz",
+ "integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
- "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz",
+ "integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
- "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz",
+ "integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
- "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz",
+ "integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
- "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz",
+ "integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
- "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz",
+ "integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==",
"cpu": [
"arm64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
- "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz",
+ "integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==",
"cpu": [
"x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">= 10"
}
@@ -1603,8 +1604,8 @@
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "dev": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -1805,19 +1806,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/baseline-browser-mapping": {
- "version": "2.10.29",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
- "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
- "license": "Apache-2.0",
- "peer": true,
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
@@ -1859,6 +1847,7 @@
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -1873,8 +1862,7 @@
"url": "https://github.com/sponsors/ai"
}
],
- "license": "CC-BY-4.0",
- "peer": true
+ "license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "4.5.0",
@@ -1928,8 +1916,8 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
- "license": "MIT",
- "peer": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
@@ -2015,9 +2003,9 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -2506,6 +2494,7 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -2521,15 +2510,14 @@
}
},
"node_modules/next": {
- "version": "16.2.6",
- "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
- "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
+ "version": "15.5.25",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz",
+ "integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@next/env": "16.2.6",
+ "@next/env": "15.5.25",
"@swc/helpers": "0.5.15",
- "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -2538,18 +2526,18 @@
"next": "dist/bin/next"
},
"engines": {
- "node": ">=20.9.0"
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.2.6",
- "@next/swc-darwin-x64": "16.2.6",
- "@next/swc-linux-arm64-gnu": "16.2.6",
- "@next/swc-linux-arm64-musl": "16.2.6",
- "@next/swc-linux-x64-gnu": "16.2.6",
- "@next/swc-linux-x64-musl": "16.2.6",
- "@next/swc-win32-arm64-msvc": "16.2.6",
- "@next/swc-win32-x64-msvc": "16.2.6",
- "sharp": "^0.34.5"
+ "@next/swc-darwin-arm64": "15.5.25",
+ "@next/swc-darwin-x64": "15.5.25",
+ "@next/swc-linux-arm64-gnu": "15.5.25",
+ "@next/swc-linux-arm64-musl": "15.5.25",
+ "@next/swc-linux-x64-gnu": "15.5.25",
+ "@next/swc-linux-x64-musl": "15.5.25",
+ "@next/swc-win32-arm64-msvc": "15.5.25",
+ "@next/swc-win32-x64-msvc": "15.5.25",
+ "sharp": "^0.34.3 || ^0.35.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -2696,6 +2684,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2737,6 +2726,7 @@
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "dev": true,
"funding": [
{
"type": "opencollective",
@@ -2752,7 +2742,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -2824,6 +2813,7 @@
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+ "dev": true,
"license": "MIT",
"peer": true,
"engines": {
@@ -2834,6 +2824,7 @@
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+ "dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -2923,6 +2914,7 @@
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "dev": true,
"license": "MIT",
"peer": true
},
@@ -2930,7 +2922,7 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
- "devOptional": true,
+ "dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2943,10 +2935,10 @@
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
- "peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -3042,6 +3034,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3091,8 +3084,8 @@
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"client-only": "0.0.1"
},
@@ -3257,8 +3250,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD",
- "peer": true
+ "dev": true,
+ "license": "0BSD"
},
"node_modules/tsup": {
"version": "8.5.1",
diff --git a/package.json b/package.json
index c232f43..4078bae 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,23 @@
{
"name": "ipgeolocation-vercel-middleware",
- "version": "1.0.0",
- "description": "Official IPGeolocation.io middleware for Next.js — geo-blocking, country redirects, VPN/proxy/bot detection at the edge.",
+ "version": "2.0.0",
+ "description": "Official IPGeolocation.io middleware for Next.js on Vercel. Country blocking, country redirects, and VPN, proxy, Tor, bot and threat score detection at the edge.",
"keywords": [
"ipgeolocation",
"vercel",
"nextjs",
- "middleware",
+ "next-middleware",
"edge",
+ "edge-runtime",
"geolocation",
"geo-blocking",
+ "geoip",
+ "country-redirect",
"vpn-detection",
+ "proxy-detection",
"bot-detection",
- "security",
- "edge-runtime"
+ "threat-score",
+ "security"
],
"homepage": "https://github.com/ipgeolocation/vercel-middleware#readme",
"bugs": {
@@ -26,6 +30,7 @@
"license": "MIT",
"author": "IPGeolocation.io (https://ipgeolocation.io)",
"type": "module",
+ "sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -36,7 +41,8 @@
"types": "./dist/middleware.d.ts",
"import": "./dist/middleware.js",
"require": "./dist/middleware.cjs"
- }
+ },
+ "./package.json": "./package.json"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
@@ -53,7 +59,6 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
- "lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run typecheck && npm run test && npm run build"
},
@@ -63,6 +68,7 @@
"devDependencies": {
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "^1.0.0",
+ "next": "^15.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
@@ -73,4 +79,4 @@
"publishConfig": {
"access": "public"
}
-}
\ No newline at end of file
+}
diff --git a/tsconfig.json b/tsconfig.json
index 6882b7b..a2f51ba 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,17 +4,21 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM"],
+ "types": ["node"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./",
"skipLibCheck": true,
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true
},
- "include": ["src", "index.ts"],
+ "include": ["index.ts", "middleware.ts", "ipgeolocation-edge.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
diff --git a/tsup.config.ts b/tsup.config.ts
index 65274da..7cc3dcd 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -11,10 +11,11 @@ export default defineConfig({
clean: true,
splitting: false,
treeshake: true,
- // Edge Runtime does not support Node.js built-ins — keep the bundle clean
+ // The Edge Runtime has no Node.js built-ins, so keep the bundle free of them.
platform: 'browser',
target: 'es2020',
+ external: ['next', 'next/server'],
banner: {
- js: '/* ipgeolocation-vercel-middleware — https://ipgeolocation.io */'
+ js: '/* ipgeolocation-vercel-middleware | https://ipgeolocation.io */'
}
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 843ff25..bb9ab59 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,8 +7,8 @@ export default defineConfig({
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
- include: ['src/**/*.ts'],
- exclude: ['src/**/*.test.ts']
+ include: ['index.ts', 'middleware.ts', 'ipgeolocation-edge.ts'],
+ exclude: ['**/*.test.ts']
}
}
});