Feat(asn-history): query by BGP-floor prefix for cross-user cache reuse

/api/asn-history previously took ?ip=, producing a unique CF cache key
per IP — wasteful since BGP routes are announced at /24 (v4) and /48
(v6) at minimum, so every IP in the same prefix has identical routing
history. Frontend now quantizes the user's IP to its DFZ-floor prefix
via toBgpPrefix() and hits /api/asn-history?prefix=...; CF can dedupe
256 v4 addresses (or a /48's worth of v6) onto one edge cache entry.

Backend swaps requireValidIP() for a new requireValidPrefix() guard
that validates any well-formed CIDR. RIPEstat returns identical
results whether queried by IP or its covering prefix (verified
empirically), so the upstream call is unchanged in shape.

Shared via common/bgp-prefix.js with a frontend re-export at
frontend/utils/bgp-prefix.js, matching the valid-ip.js pattern. 50
new tests cover v4/v6 quantization (including various IPv6 shorthand
forms, mixed case, leading-zero normalization) and the prefix guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.7 committed 2026-05-13 00:08:53 +08:00
1 parent dbdd71d13f
commit b08fb9189f
11 files changed
+291 -31

No files matched your search

+2 -1
View File
@@ -68,6 +68,7 @@ common/
- `requireReferer` is mounted globally on `/api/*` in `backend-server.js`. It rejects any request whose `Referer` header isn't on the `ALLOWED_DOMAINS` list (plus `localhost` always). **Handlers must not repeat the referer check.**
- `requireValidIP()` is attached per-route to every handler that takes `?ip=`. It rejects missing or malformed IPs before the handler runs. **Handlers must not repeat the IP check** — inside the handler body, `req.query.ip` is already known to be a well-formed string.
- `requireValidPrefix()` is the same pattern for `?prefix=` (CIDR-shaped param). Used by `asn-history` so the frontend can quantize the user's IP to its BGP DFZ-floor (/24 v4 or /48 v6) before the request lands, maximizing CF edge cache reuse across every IP in the same prefix.
- If you add a new handler that needs a different-shape param guard, add the guard to `common/guards.js` and attach it in `backend-server.js` rather than open-coding the check in the handler.
### Private-API header pass-through (intentional exception)
@@ -90,7 +91,7 @@ Default: every `/api/*` response gets `Cache-Control: no-store` from the global
```js
app.get('/api/cfradar', cacheable(60 * 60), cfHander);
app.get('/api/asn-history', requireValidIP(), cacheable(24 * 60 * 60), asnHistoryHandler);
app.get('/api/asn-history', requireValidPrefix(), cacheable(24 * 60 * 60), asnHistoryHandler);
```
Write the TTL as a multiplied expression (`60 * 60` / `24 * 60 * 60` / `30 * 24 * 60 * 60`) rather than a raw second count — the intent is self-evident at a glance, and JS folds the constant at call time so there's no runtime cost.
+18 -12
View File
@@ -1,5 +1,7 @@
// /api/asn-history — RIPEstat routing-history for an IP, enriched with
// per-ASN org names from RIPEstat as-overview (in parallel, best-effort).
// /api/asn-history — RIPEstat routing-history for a CIDR prefix (the
// frontend quantizes the user's IP to /24 v4 or /48 v6 first, so all IPs
// in the same prefix collapse to one CF edge cache entry). Org names per
// ASN come from RIPEstat as-overview, fetched in parallel, best-effort.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
@@ -21,8 +23,8 @@ const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip';
// timeout for a slow secondary lookup.
const ORG_FETCH_TIMEOUT_MS = 8000;
function summarizeOrigin(entry, family) {
const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= MIN_PREFIX[family]);
function summarizeOrigin(entry, minLen) {
const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= minLen);
if (acceptedPrefixes.length === 0) return null;
const allTimes = [];
@@ -76,22 +78,26 @@ async function fetchAsOrgName(asn) {
}
export default async (req, res) => {
const ip = req.query.ip;
// Prefix presence + validity guaranteed by requireValidPrefix middleware.
// Frontend quantizes the user's IP to /24 (v4) or /48 (v6) so every IP in
// the same prefix collapses to one cache entry at CF's edge.
const prefix = req.query.prefix;
const family = prefix.includes(':') ? 'v6' : 'v4';
const minLen = MIN_PREFIX[family];
try {
const url = `https://stat.ripe.net/data/routing-history/data.json`
+ `?resource=${encodeURIComponent(ip)}&sourceapp=${SOURCE_APP}`;
+ `?resource=${encodeURIComponent(prefix)}&sourceapp=${SOURCE_APP}`;
const apiRes = await fetchUpstream(url);
if (!apiRes.ok) {
logger.warn({ ip, status: apiRes.status }, 'RIPEstat routing-history non-2xx');
logger.warn({ prefix, status: apiRes.status }, 'RIPEstat routing-history non-2xx');
return res.status(502).json({ error: 'Upstream error' });
}
const payload = await apiRes.json();
const origins = payload?.data?.by_origin || [];
const family = ip.includes(':') ? 'v6' : 'v4';
const history = origins
.map(entry => summarizeOrigin(entry, family))
.map(entry => summarizeOrigin(entry, minLen))
.filter(Boolean)
.sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''));
@@ -107,12 +113,12 @@ export default async (req, res) => {
row.org = orgByAsn[row.asn] || null;
}
} catch (error) {
logger.warn({ err: error, ip }, 'as-overview batch failed; returning ASN-only history');
logger.warn({ err: error, prefix }, 'as-overview batch failed; returning ASN-only history');
}
res.json({ ip, history });
res.json({ prefix, history });
} catch (error) {
logger.error({ err: error, ip }, 'asn-history handler failed');
logger.error({ err: error, prefix }, 'asn-history handler failed');
res.status(500).json({ error: error.message });
}
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { slowDown } from 'express-slow-down'
import rateLimit from 'express-rate-limit';
import pinoHttp from 'pino-http';
import logger from './common/logger.js';
import { requireReferer, requireValidIP } from './common/guards.js';
import { requireReferer, requireValidIP, requireValidPrefix } from './common/guards.js';
// Backend APIs
import mapHandler from './api/google-map.js';
@@ -195,7 +195,7 @@ app.get('/api/ipinfo', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipinfoHandle
app.get('/api/ipapicom', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapicomHandler);
app.get('/api/ipsb', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipsbHandler);
app.get('/api/cfradar', cacheable(ONE_DAY_CACHE), cfHander);
app.get('/api/asn-history', requireValidIP(), cacheable(ONE_DAY_CACHE), asnHistoryHandler);
app.get('/api/asn-history', requireValidPrefix(), cacheable(ONE_DAY_CACHE), asnHistoryHandler);
app.get('/api/whois', cacheable(ONE_HOUR_CACHE), getWhois);
app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapiisHandler);
app.get('/api/ip2location', requireValidIP(), cacheable(ONE_HOUR_CACHE), ip2locationHandler);
+75
View File
@@ -0,0 +1,75 @@
// BGP-prefix helpers. Routing on the public internet happens at prefix
// granularity, not per-IP — the default-free zone won't accept anything
// smaller than /24 (IPv4) or /48 (IPv6). Quantizing a query IP down to
// that floor before hitting the upstream lets Cloudflare's edge dedupe
// asn-history responses across every IP in the same prefix.
import { isValidIP } from './valid-ip.js';
const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/;
// Expand an IPv6 address (with optional `::` compression) into 8 hextet
// strings. Returns null on any structural failure.
function expandIPv6(ip) {
const halves = ip.split('::');
if (halves.length > 2) return null;
const hasShorthand = halves.length === 2;
const left = halves[0] ? halves[0].split(':') : [];
const right = hasShorthand && halves[1] ? halves[1].split(':') : [];
const explicit = left.length + right.length;
if (!hasShorthand && explicit !== 8) return null;
if (hasShorthand && explicit > 7) return null; // `::` must elide at least one group
const gap = hasShorthand ? 8 - explicit : 0;
const hextets = [...left, ...Array(gap).fill('0'), ...right];
if (hextets.length !== 8) return null;
return hextets.every(h => HEX_GROUP.test(h)) ? hextets : null;
}
/**
* Quantize a single IP to its BGP DFZ floor prefix.
* IPv4 → /24 (e.g. `8.8.8.8` → `8.8.8.0/24`)
* IPv6 → /48 (e.g. `2001:4860:4860::8888` → `2001:4860:4860::/48`)
* Returns null when input is not a recognizable IP.
*/
export function toBgpPrefix(ip) {
if (!isValidIP(ip)) return null;
if (ip.includes('.') && !ip.includes(':')) {
const [a, b, c] = ip.split('.');
return `${a}.${b}.${c}.0/24`;
}
const hextets = expandIPv6(ip);
if (!hextets) return null;
// Canonicalize each kept hextet to lowercase without leading zeros so
// semantically identical inputs map to the same cache key. `0x0001` and
// `1` would otherwise collide as different URLs at the CF edge.
const first3 = hextets.slice(0, 3).map(h => parseInt(h, 16).toString(16));
return `${first3[0]}:${first3[1]}:${first3[2]}::/48`;
}
/**
* Accept any well-formed CIDR string (IP + length within family bounds).
* Intentionally not strict about the specific length — the frontend decides
* the quantization policy; the guard just rejects junk.
*/
export function isValidBgpPrefix(prefix) {
if (typeof prefix !== 'string') return false;
const slash = prefix.indexOf('/');
if (slash <= 0 || slash === prefix.length - 1) return false;
const address = prefix.slice(0, slash);
const lengthStr = prefix.slice(slash + 1);
if (!/^\d+$/.test(lengthStr)) return false;
if (!isValidIP(address)) return false;
const length = Number(lengthStr);
const isV6 = address.includes(':');
const max = isV6 ? 128 : 32;
return length >= 0 && length <= max;
}
+15
View File
@@ -5,6 +5,7 @@
import { refererCheck } from './referer-check.js';
import { isValidIP } from './valid-ip.js';
import { isValidBgpPrefix } from './bgp-prefix.js';
// Reject requests without an allowed referer. The error message variant
// preserves the existing user-facing wording.
@@ -31,3 +32,17 @@ export const requireValidIP = (paramName = 'ip') => (req, res, next) => {
}
next();
};
// Reject requests without a valid CIDR prefix in the specified query param.
// Accepts any well-formed CIDR — the quantization policy (e.g. /24 for v4,
// /48 for v6) is the frontend's job, not the guard's.
export const requireValidPrefix = (paramName = 'prefix') => (req, res, next) => {
const prefix = req.query[paramName];
if (!prefix) {
return res.status(400).json({ error: 'No prefix provided' });
}
if (!isValidBgpPrefix(prefix)) {
return res.status(400).json({ error: 'Invalid prefix' });
}
next();
};
+2 -1
View File
@@ -110,7 +110,8 @@ const asnInfos = ref({
}
});
// ASN routing history (RIPEstat), keyed by IP. Session cache — wipes on reload.
// ASN routing history (RIPEstat), keyed by BGP-floor prefix (/24 v4, /48 v6).
// Session cache — wipes on reload.
const asnHistoryInfos = ref({});
// Other data
+5 -4
View File
@@ -67,13 +67,14 @@ const { t } = useI18n();
const placeholderSizes = [12, 8, 6, 8, 4];
const props = defineProps({
// The IP we're displaying history for; used as cache key.
ip: { type: String, required: true },
// Shared session cache, keyed by IP. Owned by IpInfos.vue (or local in QueryIP).
// BGP-floor prefix the parent quantized the IP to (/24 v4, /48 v6).
// Used both as cache key and as the URL param sent to the backend.
prefix: { type: String, required: true },
// Shared session cache, keyed by prefix. Owned by IpInfos.vue (or local in QueryIP).
asnHistoryInfos: { type: Object, required: true },
});
const entry = computed(() => props.asnHistoryInfos[props.ip]);
const entry = computed(() => props.asnHistoryInfos[props.prefix]);
const rows = computed(() => (entry.value && !entry.value.error) ? entry.value.history : null);
// Compact YYYY-MM-DD — RIPEstat returns "2013-12-05T00:00:00", so slice is enough.
+16 -10
View File
@@ -152,7 +152,7 @@
</Button>
</JnTooltip>
<!-- ASN History -->
<JnTooltip v-if="data.ip" :text="t('Tooltips.ShowASNHistory')" side="top">
<JnTooltip v-if="ipPrefix" :text="t('Tooltips.ShowASNHistory')" side="top">
<Button variant="ghost" size="icon"
:class="['size-7 cursor-pointer', activePanel === 'history' && 'bg-muted text-foreground hover:bg-muted']"
@click="togglePanel('history')" :aria-expanded="activePanel === 'history'"
@@ -166,7 +166,7 @@
<CollapsibleContent class="pt-3">
<ASNInfo v-if="activePanel === 'info'" :index="index" :isDarkMode="isDarkMode" :asn="data.asn"
:asnInfos="asnInfos" />
<ASNHistory v-else-if="activePanel === 'history'" :ip="data.ip"
<ASNHistory v-else-if="activePanel === 'history'" :prefix="ipPrefix"
:asnHistoryInfos="asnHistoryInfos" />
</CollapsibleContent>
</Collapsible>
@@ -211,6 +211,7 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/use-analytics';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { toBgpPrefix } from '@/utils/bgp-prefix.js';
import ASNInfo from './ASNInfo.vue';
import ASNHistory from './ASNHistory.vue';
import { JnTooltip } from '@/components/ui/tooltip';
@@ -316,6 +317,11 @@ const openMapDialog = () => {
trackEvent('IPCheck', 'ViewOnMapClick', props.data.source || 'unknown');
};
// BGP DFZ-floor prefix for the IP — /24 v4, /48 v6. Used both as the query
// param sent to /api/asn-history (so CF dedupes across every IP in the same
// prefix) and as the local session-cache key.
const ipPrefix = computed(() => toBgpPrefix(props.data.ip));
// Toggle the panel for `name`. Clicking the already-active button collapses
// the panel entirely; clicking the inactive one switches view and lazily
// triggers its data fetch. Session caches (asnInfos / asnHistoryInfos) make
@@ -328,8 +334,8 @@ const togglePanel = async (name) => {
activePanel.value = name;
if (name === 'info') {
await getASNInfo(props.data.asn);
} else if (name === 'history') {
await getASNHistory(props.data.ip);
} else if (name === 'history' && ipPrefix.value) {
await getASNHistory(ipPrefix.value);
}
};
@@ -352,23 +358,23 @@ const getASNInfo = async (asn) => {
}
};
const getASNHistory = async (ip) => {
const getASNHistory = async (prefix) => {
trackEvent('IPCheck', 'ASNHistoryClick', 'Show ASN History');
try {
if (props.asnHistoryInfos[ip]) return;
if (props.asnHistoryInfos[prefix]) return;
const response = await fetchWithTimeout(
`/api/asn-history?ip=${encodeURIComponent(ip)}`,
`/api/asn-history?prefix=${encodeURIComponent(prefix)}`,
{ timeoutMs: 10000 }
);
if (!response.ok) {
props.asnHistoryInfos[ip] = { error: true };
props.asnHistoryInfos[prefix] = { error: true };
return;
}
const data = await response.json();
props.asnHistoryInfos[ip] = data;
props.asnHistoryInfos[prefix] = data;
} catch (error) {
console.error('Error fetching ASN history:', error);
props.asnHistoryInfos[ip] = { error: true };
props.asnHistoryInfos[prefix] = { error: true };
}
};
</script>
+6
View File
@@ -0,0 +1,6 @@
// Thin re-export — implementation lives in common/bgp-prefix.js so the
// front-end and back-end share one source of truth (same pattern as
// valid-ip.js and fetch-with-timeout.js).
//
// import { toBgpPrefix } from '@/utils/bgp-prefix.js';
export { toBgpPrefix, isValidBgpPrefix } from '../../common/bgp-prefix.js';
+109
View File
@@ -0,0 +1,109 @@
// Coverage for common/bgp-prefix.js — both the IP→prefix quantizer used by
// the frontend before hitting /api/asn-history and the prefix validator used
// by the backend guard.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { toBgpPrefix, isValidBgpPrefix } from '../common/bgp-prefix.js';
describe('toBgpPrefix — IPv4 → /24', () => {
const cases = [
['8.8.8.8', '8.8.8.0/24'],
['1.1.1.1', '1.1.1.0/24'],
['18.141.59.1', '18.141.59.0/24'],
['255.255.255.255', '255.255.255.0/24'],
['0.0.0.0', '0.0.0.0/24'],
['10.20.30.40', '10.20.30.0/24'],
];
for (const [ip, expected] of cases) {
it(`${ip} → ${expected}`, () => {
assert.equal(toBgpPrefix(ip), expected);
});
}
});
describe('toBgpPrefix — IPv6 → /48', () => {
const cases = [
// Standard, no shorthand.
['2001:4860:4860:0:0:0:0:8888', '2001:4860:4860::/48'],
// Shorthand at end.
['2001:4860:4860::8888', '2001:4860:4860::/48'],
// Shorthand inside, after the 3rd hextet.
['2001:db8:1234::1', '2001:db8:1234::/48'],
// Shorthand splitting before the 3rd hextet — gap fills with zero.
['2001:db8::1', '2001:db8:0::/48'],
// Address starting with `::`.
['::1', '0:0:0::/48'],
// All zeros.
['::', '0:0:0::/48'],
// Mixed case + leading zeros — normalized to lowercase, no leading zeros.
['2001:0DB8:0001::1', '2001:db8:1::/48'],
// Ending with `::`.
['fe80::', 'fe80:0:0::/48'],
];
for (const [ip, expected] of cases) {
it(`${ip} → ${expected}`, () => {
assert.equal(toBgpPrefix(ip), expected);
});
}
});
describe('toBgpPrefix — rejects garbage', () => {
const cases = [
'',
'nope',
'8.8.8', // missing octet
'8.8.8.8.8', // too many octets
'256.1.1.1', // octet out of range
'2001:::8888', // multiple `::`
'2001:db8:1234:5:6:7:8:9:10', // too many hextets
'gggg::1', // non-hex
null,
undefined,
42,
];
for (const input of cases) {
it(`rejects ${JSON.stringify(input)}`, () => {
assert.equal(toBgpPrefix(input), null);
});
}
});
describe('isValidBgpPrefix', () => {
const valid = [
'8.8.8.0/24',
'0.0.0.0/0',
'255.255.255.255/32',
'2001:db8::/48',
'::/0',
'2001:4860:4860::/48',
'fe80::/64',
];
for (const p of valid) {
it(`accepts ${p}`, () => {
assert.equal(isValidBgpPrefix(p), true);
});
}
const invalid = [
'',
'nope',
'8.8.8.0', // no slash
'8.8.8.0/', // empty length
'/24', // no address
'8.8.8.0/33', // v4 length out of range
'2001:db8::/129', // v6 length out of range
'8.8.8.0/-1', // negative
'8.8.8.0/abc', // non-numeric length
'8.8.8.300/24', // address invalid
null,
undefined,
42,
];
for (const p of invalid) {
it(`rejects ${JSON.stringify(p)}`, () => {
assert.equal(isValidBgpPrefix(p), false);
});
}
});
+41 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { requireReferer, requireValidIP } from '../common/guards.js';
import { requireReferer, requireValidIP, requireValidPrefix } from '../common/guards.js';
// Minimal (req, res, next) stubs — just enough to observe what the
// middleware does.
@@ -89,3 +89,43 @@ describe('requireValidIP', () => {
assert.equal(nextCalled, true);
});
});
describe('requireValidPrefix', () => {
const guard = requireValidPrefix();
it('calls next() when prefix is a valid IPv4 CIDR', () => {
let nextCalled = false;
guard(makeReq({ query: { prefix: '8.8.8.0/24' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('calls next() when prefix is a valid IPv6 CIDR', () => {
let nextCalled = false;
guard(makeReq({ query: { prefix: '2001:4860:4860::/48' } }), makeRes(), () => { nextCalled = true; });
assert.equal(nextCalled, true);
});
it('returns 400 "No prefix provided" when prefix is missing', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: {} }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'No prefix provided');
assert.equal(nextCalled, false);
});
it('returns 400 "Invalid prefix" when prefix is malformed', () => {
const res = makeRes();
let nextCalled = false;
guard(makeReq({ query: { prefix: '8.8.8.0' } }), res, () => { nextCalled = true; });
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'Invalid prefix');
assert.equal(nextCalled, false);
});
it('returns 400 when prefix length is out of v4 range', () => {
const res = makeRes();
guard(makeReq({ query: { prefix: '8.8.8.0/33' } }), res, () => {});
assert.equal(res.statusCode, 400);
});
});