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.
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 receives403and stops resolving stack traces. -
IPv6 CIDRs require separate nginx geo blocks. The
geomodule 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
.mappaths and set an action ofallow, 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 usingand/oroperators. -
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.
Related
- Securing Hidden Source Maps from Public Access — parent page: hidden-source-map devtool, upload-then-delete, and private bucket storage
- Source Map Generation & Stack Trace Debugging — full reference for the build-to-symbolication pipeline
- Serving Source Maps Behind Authentication — complement: token/signed-URL auth instead of (or in addition to) IP filtering
- Configuring Webpack for Production Source Maps — devtool settings and chunk hashing that produce the .map files you’re protecting