Skip to content

Add per-website IP blocklist to exclude IPs from statistics#4378

Open
mchev wants to merge 2 commits into
umami-software:devfrom
mchev:feature/per-website-ip-blocklist
Open

Add per-website IP blocklist to exclude IPs from statistics#4378
mchev wants to merge 2 commits into
umami-software:devfrom
mchev:feature/per-website-ip-blocklist

Conversation

@mchev

@mchev mchev commented Jul 5, 2026

Copy link
Copy Markdown

Summary

Adds the ability to configure IP addresses (with CIDR support) to exclude from statistics on a per-website basis. This is useful for developers who want to exclude their own traffic from analytics without relying on the global IGNORE_IP environment variable.

Changes

Database

  • Added blocked_ips VARCHAR(500) column to the website table (via migration prisma/migrations/21_add_blocked_ips/)
  • Updated Prisma schema with the new blockedIps field on the Website model

Backend

  • Extended hasBlockedIp() in src/lib/detect.ts to accept an optional extraIps parameter, merging it with the global IGNORE_IP env var
  • Improved IP list parsing to support both comma and newline/whitespace separators
  • Added per-website IP blocking check in POST /api/send (main tracking endpoint) and POST /api/record (session replay/heatmap recording)
  • Blocked IPs silently drop the event (returns { beep: "boop" }) — same pattern as the bot check, so blocked visitors are not aware

API

  • Added blockedIps field to the website update schema (POST /api/websites/[id])

Frontend

  • Added WebsiteBlocklist component in website settings with a textarea for entering comma/newline-separated IPs and CIDR ranges
  • Integrated into the settings page alongside existing panels

i18n

  • Added blocked-ips label and blocked-ips-description message in en-US and en-GB locales

Tests

  • Added 7 test cases for hasBlockedIp() covering:
    • Exact IP match with extraIps
    • CIDR range match with extraIps
    • Non-matching IP
    • Empty string handling
    • Newlines as separators
    • Mixed separators
    • Combined global IGNORE_IP + extraIps

Usage

  1. Go to Settings for a website
  2. Find the Blocked IPs panel
  3. Enter IPs (comma or newline separated, supports CIDR like 192.168.1.0/24)
  4. Click Save

Visitors whose IP matches any entry will be silently excluded from analytics for that website.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Add a new field blockedIps to the Website model to allow excluding specific IP
addresses from statistics on a per-website basis.

- Database: Add blocked_ips VARCHAR(500) column to website table
- Backend: Check per-website blocked IPs in /api/send and /api/record endpoints
- API: Add blockedIps to website update schema
- Frontend: Add WebsiteBlocklist component in website settings
- i18n: Add English locale strings for the feature
- Tests: Add tests for hasBlockedIp with extra IPs parameter
@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

@mchev is attempting to deploy a commit to the Umami Software Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps

greptile-apps Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds per-website IP blocking to umami, allowing site owners to exclude specific IPs and CIDR ranges from their analytics without relying solely on the global IGNORE_IP environment variable. The feature adds a blocked_ips column to the website table, extends hasBlockedIp() with an extraIps parameter, wires the check into both the /api/send and /api/record tracking endpoints, and surfaces a new settings panel in the frontend.

  • Core logic (detect.ts): cleanly extended to merge global and per-website IP lists, with correct CIDR support and multi-separator parsing.
  • API update endpoint (/api/websites/[id]): contains a bug where clearing the blocklist silently no-ops because null ?? undefined is passed to Prisma, which treats undefined as "leave unchanged."
  • Tracking endpoint (/api/send): removes a cache guard that previously skipped fetchWebsite on repeat requests with valid session tokens, adding a lookup on every tracking event.

Confidence Score: 3/5

Two defects in the changed code should be fixed before merging: one prevents users from ever clearing their blocklist, and the other removes a deliberate cache optimization on every tracking request.

The update route has a data-loss bug where a user who clears the textarea and saves will silently keep their old blocklist forever with no error. Separately, the tracking route now unconditionally calls fetchWebsite on every POST, bypassing the cache guard that was specifically there to skip the lookup for cached sessions. Both issues are on actively-used code paths.

src/app/api/websites/[websiteId]/route.ts (blockedIps clear bug) and src/app/api/send/route.ts (unconditional fetchWebsite call).

Important Files Changed

Filename Overview
src/app/api/websites/[websiteId]/route.ts Adds blockedIps to the POST update handler, but null ?? undefined means clearing the field silently no-ops in Prisma — the old blocklist is never actually removed.
src/app/api/send/route.ts Adds per-website IP block check, but removes the cache guard that prevented fetchWebsite from being called on every tracking request — a performance regression on the hot path.
src/lib/detect.ts Extends hasBlockedIp with optional extraIps parameter; correctly merges global IGNORE_IP with per-website list, handles whitespace/comma separators, and preserves CIDR matching logic.
src/lib/detect.test.ts Adds 7 tests covering the new extraIps logic; the last test sets process.env.IGNORE_IP without cleanup, risking env pollution in subsequent test modules within the same worker.
src/app/api/record/route.ts Correctly adds per-website IP block check after the global hasBlockedIp check; website is already fetched unconditionally in this route so no additional query overhead.
src/app/(main)/websites/[websiteId]/settings/WebsiteBlocklist.tsx New settings panel for blocked IPs; missing maxLength constraint on the textarea means users discover the 500-char limit only on save failure.
prisma/migrations/21_add_blocked_ips/migration.sql Adds nullable VARCHAR(500) column to the website table; 500 chars may be limiting for users with several IPv6 CIDR ranges, but is consistent with the domain column length.
prisma/schema.prisma Adds nullable blockedIps String field mapped to blocked_ips; correctly matches migration column definition.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant S as POST /api/send
    participant DB as fetchWebsite (Redis/DB)
    participant D as hasBlockedIp()

    C->>S: "POST {websiteId, cache-token, ...}"
    S->>DB: fetchWebsite(websiteId) [always, even if cache valid]
    DB-->>S: website (incl. blockedIps)
    S->>D: hasBlockedIp(ip) [global IGNORE_IP]
    D-->>S: false
    S->>D: hasBlockedIp(ip, website.blockedIps) [per-website]
    alt IP is blocked
        D-->>S: true
        S-->>C: "{beep: boop}"
    else IP is allowed
        D-->>S: false
        S->>S: createSession / saveEvent
        S-->>C: "{cache, sessionId, visitId}"
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant S as POST /api/send
    participant DB as fetchWebsite (Redis/DB)
    participant D as hasBlockedIp()

    C->>S: "POST {websiteId, cache-token, ...}"
    S->>DB: fetchWebsite(websiteId) [always, even if cache valid]
    DB-->>S: website (incl. blockedIps)
    S->>D: hasBlockedIp(ip) [global IGNORE_IP]
    D-->>S: false
    S->>D: hasBlockedIp(ip, website.blockedIps) [per-website]
    alt IP is blocked
        D-->>S: true
        S-->>C: "{beep: boop}"
    else IP is allowed
        D-->>S: false
        S->>S: createSession / saveEvent
        S-->>C: "{cache, sessionId, visitId}"
    end
Loading

Reviews (1): Last reviewed commit: "Add per-website IP blocklist feature" | Re-trigger Greptile

Comment thread src/app/api/websites/[websiteId]/route.ts Outdated
Comment thread src/app/api/send/route.ts Outdated
Comment on lines 122 to 127
// Find website
if (!cache?.websiteId) {
const website = await fetchWebsite(websiteId);
website = await fetchWebsite(websiteId);

if (!website) {
return badRequest({ message: 'Website not found.' });
}
if (!website) {
return badRequest({ message: 'Website not found.' });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cache optimization removed on every tracking request. The original code guarded fetchWebsite behind !cache?.websiteId, so repeat hits from cached sessions (the vast majority of tracking traffic) never triggered a lookup. The new code unconditionally calls fetchWebsite on every POST, even when the cache token is fully valid. On a high-traffic site this adds a Redis or DB round-trip to every single tracking event. If fetchWebsite is already Redis-cached the overhead per request is small, but the cumulative cost at scale is real and the deliberate cache skip is gone.

Comment thread src/lib/detect.test.ts
Comment on lines +103 to +107
test('hasBlockedIp: combines IGNORE_IP and extraIps correctly', () => {
process.env.IGNORE_IP = '10.0.0.0/8';

expect(hasBlockedIp('10.0.0.5', '192.168.1.0/24')).toBe(true);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 process.env.IGNORE_IP is set at the end of the file but never cleaned up. The beforeEach hook deletes it before every test in this file, but if any other test module runs in the same worker process after this one, it will inherit the dirty env state. Add a reset inside this test's finally block.

Suggested change
test('hasBlockedIp: combines IGNORE_IP and extraIps correctly', () => {
process.env.IGNORE_IP = '10.0.0.0/8';
expect(hasBlockedIp('10.0.0.5', '192.168.1.0/24')).toBe(true);
});
test('hasBlockedIp: combines IGNORE_IP and extraIps correctly', () => {
process.env.IGNORE_IP = '10.0.0.0/8';
try {
expect(hasBlockedIp('10.0.0.5', '192.168.1.0/24')).toBe(true);
} finally {
delete process.env.IGNORE_IP;
}
});

Comment thread src/app/(main)/websites/[websiteId]/settings/WebsiteBlocklist.tsx
…axLength

- Fix blockedIps null coalescing: blockedIps ?? undefined → blockedIps !== undefined ? blockedIps : undefined
  so that explicitly passing null actually clears the field in Prisma
- Restore cache guard on fetchWebsite in /api/send to avoid
  unnecessary Redis/DB round-trips on cached sessions
- Clean up process.env.IGNORE_IP in test with try/finally
- Add maxLength={500} to blocked IPs textarea for early UX feedback
@mchev mchev force-pushed the feature/per-website-ip-blocklist branch from 8c4e5fa to 2e93ae7 Compare July 5, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant