mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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:
1 parent
dbdd71d13f
commit
b08fb9189f
11 files changed
+291
-31
No files matched your search
+2
-1
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user