Add ASN-based resource rule matching

- Add MaxMind ASN database integration
- Implement ASN lookup and matching in resource rule verification
- Add curated list of 100+ major ASNs (cloud, ISP, CDN, mobile carriers)
- Add ASN dropdown selector in resource rules UI with search functionality
- Support custom ASN input for unlisted ASNs
- Add 'ALL ASNs' special case handling (AS0)
- Cache ASN lookups with 5-minute TTL for performance
- Update validation schemas to support ASN match type

This allows administrators to create resource access rules based on
Autonomous System Numbers, similar to existing country-based rules.
Useful for restricting access by ISP, cloud provider, or mobile carrier.
This commit is contained in:
Thomas Wilde
2025-12-16 11:18:54 -07:00
committed by Owen Schwartz
parent 1f4ebf1907
commit 8d2955475b
11 changed files with 678 additions and 9 deletions

View File

@@ -29,6 +29,7 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { getCountryCodeForIp } from "@server/lib/geoip";
import { getAsnForIp } from "@server/lib/asn";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { verifyPassword } from "@server/auth/password";
@@ -128,6 +129,10 @@ export async function verifyResourceSession(
? await getCountryCodeFromIp(clientIp)
: undefined;
const ipAsn = clientIp
? await getAsnFromIp(clientIp)
: undefined;
let cleanHost = host;
// if the host ends with :port, strip it
if (cleanHost.match(/:[0-9]{1,5}$/)) {
@@ -216,7 +221,8 @@ export async function verifyResourceSession(
resource.resourceId,
clientIp,
path,
ipCC
ipCC,
ipAsn
);
if (action == "ACCEPT") {
@@ -910,7 +916,8 @@ async function checkRules(
resourceId: number,
clientIp: string | undefined,
path: string | undefined,
ipCC?: string
ipCC?: string,
ipAsn?: number
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
@@ -954,6 +961,12 @@ async function checkRules(
(await isIpInGeoIP(ipCC, rule.value))
) {
return rule.action as any;
} else if (
clientIp &&
rule.match == "ASN" &&
(await isIpInAsn(ipAsn, rule.value))
) {
return rule.action as any;
}
}
@@ -1090,6 +1103,52 @@ async function isIpInGeoIP(
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
}
async function isIpInAsn(
ipAsn: number | undefined,
checkAsn: string
): Promise<boolean> {
// Handle "ALL" special case
if (checkAsn === "ALL" || checkAsn === "AS0") {
return true;
}
if (!ipAsn) {
return false;
}
// Normalize the check ASN - remove "AS" prefix if present and convert to number
const normalizedCheckAsn = checkAsn.toUpperCase().replace(/^AS/, "");
const checkAsnNumber = parseInt(normalizedCheckAsn, 10);
if (isNaN(checkAsnNumber)) {
logger.warn(`Invalid ASN format in rule: ${checkAsn}`);
return false;
}
const match = ipAsn === checkAsnNumber;
logger.debug(
`ASN check: IP ASN ${ipAsn} ${match ? "matches" : "does not match"} rule ASN ${checkAsnNumber}`
);
return match;
}
async function getAsnFromIp(ip: string): Promise<number | undefined> {
const asnCacheKey = `asn:${ip}`;
let cachedAsn: number | undefined = cache.get(asnCacheKey);
if (!cachedAsn) {
cachedAsn = await getAsnForIp(ip); // do it locally
// Cache for longer since IP ASN doesn't change frequently
if (cachedAsn) {
cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes
}
}
return cachedAsn;
}
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
const geoIpCacheKey = `geoip:${ip}`;

View File

@@ -17,7 +17,7 @@ import { OpenAPITags, registry } from "@server/openApi";
const createResourceRuleSchema = z.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]),
value: z.string().min(1),
priority: z.int(),
enabled: z.boolean().optional()

View File

@@ -25,7 +25,7 @@ const updateResourceRuleParamsSchema = z.strictObject({
const updateResourceRuleSchema = z
.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(),
value: z.string().min(1).optional(),
priority: z.int(),
enabled: z.boolean().optional()