Restricting Source Map Access by IP Allowlist

When your error tracker fetches .map files from a fixed set of IP ranges, an IP allowlist is the simplest and most auditable access control you can put in front of those endpoints. This page shows you how to configure nginx’s geo module, a Cloudflare WAF custom rule, and an AWS CloudFront function to return 403 for every IP outside the allowlist — with commands to verify the result from both an allowed and a blocked address. It extends the strategies in Securing Hidden Source Maps from Public Access and fits within the broader Source Map Generation & Stack Trace Debugging reference.

IP Allowlist Source Map Access Control Decision diagram: incoming GET *.map request arrives at the CDN/proxy layer; the IP is checked against the allowlist (Sentry/Datadog CIDRs + RFC1918 internal ranges); allowed IPs are proxied to the private S3 bucket and receive the .map file; all other IPs receive 403 Forbidden immediately. GET *.map any client IP IP in allowlist? YES Private S3 Bucket 200 + .map body Allowed CIDRs 10.0.0.0/8 35.184.238.0/24 (Sentry) + Datadog ranges NO 403 Forbidden public / unknown IP Error Tracker symbolication

Symptom / Trigger

Any HTTP client — browser, scanner, competitor — can download your .map files by guessing the predictable URL pattern:

# From a public IP with no allowlist in place
curl -I https://cdn.example.com/assets/main.a1b2c3d4.js.map
# HTTP/2 200
# content-type: application/json
# content-length: 2107432

# The map downloads completely
curl -s https://cdn.example.com/assets/main.a1b2c3d4.js.map | python3 -c "
import json, sys
m = json.load(sys.stdin)
print('sources:', len(m['sources']))
print('first file:', m['sources'][0])
"
# sources: 47
# first file: src/billing/checkout.ts

Your error tracker’s symbolication continues to work — but so does any external request. The CDN access logs show 200 GET /assets/*.js.map from IP ranges that are not your observability provider.

Root Cause Explanation

There is no IP-based access restriction on the .map path. The CDN or reverse proxy applies the same open delivery policy to .map files as to .js and .css files:

# BROKEN: no distinction between map files and other static assets
server {
  root /var/www/dist;
  location / {
    try_files $uri $uri/ =404;   # *.map served identically to *.js
  }
}

Even with hidden-source-map in the bundler (so the bundle contains no sourceMappingURL comment), the file is still accessible at its predictable path to anyone who knows the naming convention.

Step-by-Step Fix

1. nginx — geo module CIDR allowlist

nginx’s built-in geo module maps client IPs to a variable. Set the variable to 1 for allowed CIDRs and 0 for everything else, then use it to gate the .map location block.

Identify the current published CIDR ranges for your observability provider (Sentry publishes theirs in their documentation; Datadog publishes an IP ranges endpoint at https://ip-ranges.datadoghq.com/). Combine them with your internal RFC 1918 ranges.

# /etc/nginx/conf.d/map-allowlist.conf
# geo module evaluates $remote_addr by default
geo $map_allowed {
  default          0;          # deny everyone not listed below

  # Internal / RFC 1918
  10.0.0.0/8      1;
  172.16.0.0/12   1;
  192.168.0.0/16  1;

  # CI runner static IPs (replace with your actual ranges)
  203.0.113.10/32  1;

  # Sentry ingest crawler ranges (check sentry.io/ip-ranges for current list)
  35.184.238.0/24  1;
  34.105.112.0/24  1;

  # Datadog symbolication ranges (check ip-ranges.datadoghq.com for current list)
  18.214.75.0/24   1;
  52.20.45.0/24    1;
}
# /etc/nginx/sites-available/cdn.conf
server {
  listen 443 ssl;
  server_name cdn.example.com;
  root /var/www/dist;

  location ~* \.map$ {
    if ($map_allowed = 0) {
      return 403;                              # block every IP not in the allowlist
    }
    # Serve to allowed IPs only
    try_files $uri =404;
    add_header Cache-Control "private, no-store" always;
    add_header X-Robots-Tag "noindex" always;
  }

  location / {
    try_files $uri $uri/ =404;               # normal assets unaffected
  }
}

Reload nginx without downtime:

nginx -t && systemctl reload nginx

2. Cloudflare WAF custom rule

In Cloudflare, create a WAF custom rule that blocks .map requests from IPs outside your allowlist. Use the Cloudflare dashboard Security → WAF → Custom Rules → Create rule, or deploy it via Terraform.

The WAF rule expression uses Cloudflare’s Ruleset Language:

# Block *.map requests from IPs not in the allowlist
# ip.src.is_in_list checks against a Cloudflare IP List named "sourcemap_allowlist"
(http.request.uri.path matches "\\.map$" and not ip.src in $sourcemap_allowlist)

Action: Block (returns 403 with Cloudflare’s default block page).

Create the IP list first via the Cloudflare API:

# Create a new IP list named "sourcemap_allowlist"
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/rules/lists" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "sourcemap_allowlist",
    "description": "CIDRs allowed to fetch .map files",
    "kind": "ip"
  }'

# Add items to the list (repeat for each CIDR)
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/rules/lists/${LIST_ID}/items" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '[
    {"ip": "10.0.0.0/8",       "comment": "Internal RFC1918"},
    {"ip": "35.184.238.0/24",  "comment": "Sentry crawler"},
    {"ip": "18.214.75.0/24",   "comment": "Datadog symbolication"}
  ]'

Deploy the WAF rule via Terraform:

# cloudflare_ruleset.tf
resource "cloudflare_ruleset" "sourcemap_block" {
  zone_id     = var.cf_zone_id
  name        = "Block public source map access"
  description = "403 for *.map requests from non-allowlisted IPs"
  kind        = "zone"
  phase       = "http_request_firewall_custom"

  rules {
    action      = "block"
    description = "Block .map outside allowlist"
    enabled     = true
    expression  = "(http.request.uri.path matches \"\\\\.map$\" and not ip.src in $sourcemap_allowlist)"
  }
}

3. AWS CloudFront — Lambda@Edge IP check

For CloudFront distributions, use a Lambda@Edge Viewer Request function to evaluate CloudFront-Viewer-Address against an allowlist before the request reaches the origin.

// lambda/check-map-ip/index.mjs  (Node.js 20.x, Lambda@Edge Viewer Request)
const ALLOWED_CIDRS = [
  '10.0.0.0/8',
  '172.16.0.0/12',
  '192.168.0.0/16',
  '35.184.238.0/24',   // Sentry
  '18.214.75.0/24',    // Datadog
];

// Minimal CIDR match for IPv4 — no external dependencies
function ipToInt(ip) {
  return ip.split('.').reduce((acc, oct) => (acc << 8) + parseInt(oct, 10), 0) >>> 0;
}

function inCidr(ip, cidr) {
  const [base, bits] = cidr.split('/');
  const mask = bits === '32' ? 0xffffffff : (~0 << (32 - parseInt(bits, 10))) >>> 0;
  return (ipToInt(ip) & mask) === (ipToInt(base) & mask);
}

function isAllowed(ip) {
  return ALLOWED_CIDRS.some(cidr => inCidr(ip, cidr));
}

export const handler = async (event) => {
  const request = event.Records[0].cf.request;
  const uri = request.uri;

  if (!uri.endsWith('.map')) return request;  // not a map request — pass through

  // CloudFront-Viewer-Address header: "203.0.113.45:12345"
  const viewerIp = (request.headers['cloudfront-viewer-address']?.[0]?.value ?? '')
    .split(':')[0];                            // strip port

  if (isAllowed(viewerIp)) return request;    // allowed — forward to origin

  // Blocked
  return {
    status: '403',
    statusDescription: 'Forbidden',
    headers: {
      'content-type': [{ key: 'Content-Type', value: 'text/plain' }],
      'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
    },
    body: 'Forbidden',
  };
};

Deploy and associate the function with your CloudFront distribution’s Viewer Request event for all behaviors that match *.map.

4. Verify — curl from external and allowed IPs

From a public IP (or a cloud VM with no allowlisted address):

# External IP — must return 403
curl -s -o /dev/null -w "%{http_code}\n" \
  https://cdn.example.com/assets/main.a1b2c3d4.js.map
# Expected output: 403

# Bundle itself must still be accessible
curl -s -o /dev/null -w "%{http_code}\n" \
  https://cdn.example.com/assets/main.a1b2c3d4.js
# Expected output: 200

From an allowlisted host (e.g., a CI runner or VPN exit node):

# Allowlisted IP — must return 200
curl -s -o /dev/null -w "%{http_code}\n" \
  https://cdn.example.com/assets/main.a1b2c3d4.js.map
# Expected output: 200

Verification

Run this script in CI after every deploy to catch regressions:

#!/usr/bin/env bash
# verify-map-ip-block.sh
# Requires: a public HTTP proxy or Hurl, and access to an internal host

CDN="https://cdn.example.com"
BUNDLE="assets/main.${RELEASE_VERSION}.js"
MAP="${BUNDLE}.map"

# 1. Map must be blocked publicly
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${CDN}/${MAP}")
[ "$STATUS" = "403" ] || { echo "FAIL: map returned $STATUS publicly (expected 403)"; exit 1; }
echo "OK: public map → 403"

# 2. Bundle must still load
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${CDN}/${BUNDLE}")
[ "$STATUS" = "200" ] || { echo "FAIL: bundle returned $STATUS (expected 200)"; exit 1; }
echo "OK: bundle → 200"

# 3. Check nginx access log for the 403 (run on the nginx host)
# ssh nginx-host "tail -5 /var/log/nginx/access.log"
# Expected log entry: "GET /assets/main.a1b2c3d4.js.map" 403

nginx access log line confirming the block:

203.0.113.99 - - [19/Jun/2026:08:15:42 +0000] "GET /assets/main.a1b2c3d4.js.map HTTP/2.0" 403 0 "-" "curl/8.1.2"

Edge Cases & Gotchas

  • Sentry and Datadog publish changing IP ranges. Both services add and remove CIDR blocks as they scale. Subscribe to their IP range changelogs (Sentry: check their security advisories; Datadog: poll https://ip-ranges.datadoghq.com/ weekly and diff against your allowlist). A stale allowlist breaks symbolication without any obvious error — the error tracker silently receives 403 and stops resolving stack traces.

  • IPv6 CIDRs require separate nginx geo blocks. The geo module handles IPv4 and IPv6 separately. If your CDN or observability provider uses IPv6 crawler addresses, add them explicitly:

    geo $map_allowed {
      default               0;
      # IPv6 internal
      ::1/128               1;
      fc00::/7              1;
      # Sentry IPv6 range (example — verify current value)
      2600:1901:0::/48      1;
    }
  • WAF rule ordering matters in Cloudflare. If you have existing rules with a higher priority that match .map paths and set an action of allow, the block rule will never fire. Ensure the IP allowlist block rule has a lower priority number (higher precedence) than any blanket allow rules, or restructure the logic into a single rule using and/or operators.

  • Attackers can bypass CDN IP restrictions by hitting your origin directly. If your origin server’s IP is discoverable (via DNS history, certificate transparency logs, or other means), an attacker can send requests directly to the origin, bypassing Cloudflare or CloudFront entirely. Apply the same geo-based nginx rule at the origin server as well, so even direct-origin requests are blocked. This is a mandatory companion to any CDN-layer allowlist.

FAQ

How do I find the current CIDR ranges for Sentry and Datadog crawlers? Datadog publishes a machine-readable JSON file of all its IP ranges. For Sentry, check their support documentation or contact their support team — they do not publish a single canonical IP ranges endpoint, so subscribing to their changelog or following their status page announcements is the reliable method. For both providers, automate a weekly diff against your nginx or Cloudflare IP list and alert on changes.

Will adding an IP allowlist break local development? Not if you target the rule only at the production CDN or nginx location block. Local development uses vite dev or webpack serve, which reads maps from the local filesystem — no HTTP allowlist applies. The allowlist only affects the production static-file endpoint.

What if my error tracker fetches maps from a SaaS product with dynamic IPs? Combine the IP allowlist with the Serving Source Maps Behind Authentication pattern: require both a valid Bearer token AND membership in a broad CIDR range (e.g. cloud provider ASN). Defense-in-depth with two independent controls is significantly harder to bypass than either alone.