Files
MyIP/server/guards.js
T
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
common/ had become the backend's library: 30 of its 41 modules were
imported by nothing in the frontend, so the name no longer said what
belonged there. It now holds only what the frontend or the build imports
too; backend-only code moved to server/, flat except for datasets/ (the
dataset engine and its readers). The *-db/ dataset directories stay in
common/: self-hosting docs and Docker volumes mount those paths.

frontend/utils/ keeps its app-wide modules at the root and gains ip/
(IP / ASN shaping and validation), report/ (the report pipeline) and
features/ (the pure layer of a single feature). Paths only — no logic
changes. The Dockerfile copies server/, and the AGENTS.md files say where
a new module goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:02 +08:00

165 lines
6.8 KiB
JavaScript

// Express middleware that factors out the boilerplate every api/ handler
// used to repeat at the top of its function body: referer check, param
// presence check, param validity check. Mount once in backend-server.js;
// handlers stop carrying the defensive checks and can't accidentally forget
// them.
import { refererCheck } from './referer-check.js';
import { isValidIP, isValidDomain, isUsablePublicIP } from '../common/valid-ip.js';
import { isValidBgpPrefix } from '../common/bgp-prefix.js';
import { STATUS_PROVIDER_IDS } from './service-status-providers.js';
import { DNS_RECORD_TYPE_SET } from '../common/dns-record-types.js';
import { parseAsnInput } from '../common/asn-input.js';
// Reject requests without an allowed referer. The error message variant
// preserves the existing user-facing wording.
export const requireReferer = (req, res, next) => {
const referer = req.headers.referer;
if (!refererCheck(referer)) {
return res.status(403).json({
error: referer ? 'Access denied' : 'What are you doing?',
});
}
next();
};
// Reject requests whose IP query param isn't a publicly routable address.
// Every `?ip=` route asks an external service about that address — a
// registry, a geolocation source — and reserved space has no answer there,
// so it never reaches an upstream call. Malformed input and reserved space
// get distinct messages; the two failures need different fixes.
// Factory so a route using another param name (none today, but leaving the
// door open) can say `requirePublicIP('target')`.
export const requirePublicIP = (paramName = 'ip') => (req, res, next) => {
const ip = req.query[paramName];
if (!ip) {
return res.status(400).json({ error: 'No IP address provided' });
}
if (!isValidIP(ip)) {
return res.status(400).json({ error: 'Invalid IP address' });
}
if (!isUsablePublicIP(ip)) {
return res.status(400).json({ error: 'Not a public IP address' });
}
next();
};
// Reject requests without a syntactically valid domain in the specified
// query param. Lowercases the value in place so handlers and the edge cache
// see one canonical form (domains are case-insensitive; a mixed-case query
// must not become a distinct CF cache entry).
export const requireValidDomain = (paramName = 'domain') => (req, res, next) => {
const raw = req.query[paramName];
if (!raw) {
return res.status(400).json({ error: 'No domain provided' });
}
const domain = String(raw).toLowerCase();
if (!isValidDomain(domain)) {
return res.status(400).json({ error: 'Invalid domain' });
}
req.query[paramName] = domain;
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();
};
// Reject requests without a valid ASN (AS1 … AS4294967295, up to ten
// digits, optional 'AS' prefix — parseAsnInput in common/asn-input.js) and
// rewrite it in place to the canonical number string (`AS013335` → `13335`),
// so the edge cache sees one key. Used by /api/asn-connectivity,
// /api/asn-profile and, per-view, the /api/cfradar dispatcher.
export const requireValidASN = (paramName = 'asn') => (req, res, next) => {
const raw = req.query[paramName];
if (!raw) {
return res.status(400).json({ error: 'No ASN provided' });
}
const asn = parseAsnInput(raw);
if (asn === null) {
return res.status(400).json({ error: 'Invalid ASN' });
}
req.query[paramName] = String(asn);
next();
};
// Canonicalize an ASN-shaped value (`AS13335`, `as13335` — the `AS` prefix
// is required) to `AS<n>` in place, rejecting AS0 and anything past 32 bits.
// Any other value, a bare number included, passes through untouched —
// /api/whois also takes IPs and domains, which its handler validates.
export const normalizeAsnQuery = (paramName = 'q') => (req, res, next) => {
const raw = req.query[paramName];
if (typeof raw !== 'string' || !/^AS\d+$/i.test(raw.trim())) return next();
const asn = parseAsnInput(raw, { requirePrefix: true });
if (asn === null) {
return res.status(400).json({ error: 'Invalid ASN' });
}
req.query[paramName] = `AS${asn}`;
next();
};
// Reject requests without a two-letter country code; uppercases in place so
// the edge cache sees one canonical key. Syntactic only — an unassigned code
// just yields an empty upstream series.
export const requireValidCountry = (paramName = 'country') => (req, res, next) => {
const raw = req.query[paramName];
if (!raw) {
return res.status(400).json({ error: 'No country provided' });
}
if (!/^[A-Za-z]{2}$/.test(raw)) {
return res.status(400).json({ error: 'Invalid country' });
}
req.query[paramName] = String(raw).toUpperCase();
next();
};
// Reject requests whose report id doesn't look like one we issued: 16 random
// bytes as base64url, always exactly 22 chars. Reads the ROUTE param (not the
// query) — the share-report read endpoint is /api/report/:id.
export const requireValidReportId = (paramName = 'id') => (req, res, next) => {
const id = req.params?.[paramName];
if (!id || !/^[A-Za-z0-9_-]{22}$/.test(id)) {
return res.status(400).json({ error: 'Invalid report id' });
}
next();
};
// Whitelist ?type= against the record types the resolver actually handles.
// Without it the DoH branch forwards any string verbatim to four third-party
// endpoints, which makes this route a query proxy for types we never support.
export const requireValidRecordType = (paramName = 'type') => (req, res, next) => {
const type = req.query[paramName];
if (!type) {
return res.status(400).json({ error: 'No record type provided' });
}
if (!DNS_RECORD_TYPE_SET.has(String(type).toUpperCase())) {
return res.status(400).json({ error: 'Invalid record type' });
}
req.query[paramName] = String(type).toUpperCase();
next();
};
// Reject requests whose `id` isn't a known service-status provider slug.
// Used by the per-provider components / incidents endpoints, which select a
// row from the in-memory snapshot by id.
export const requireValidProviderId = (paramName = 'id') => (req, res, next) => {
const id = req.query[paramName];
if (!id) {
return res.status(400).json({ error: 'No provider id provided' });
}
if (!STATUS_PROVIDER_IDS.has(id)) {
return res.status(400).json({ error: 'Invalid provider id' });
}
next();
};