Add region-based resource rule

This commit is contained in:
Dennis
2025-12-22 17:44:56 +01:00
parent 9d9401d2ee
commit e051142334
9 changed files with 678 additions and 12 deletions

View File

@@ -1,4 +1,37 @@
import { assertEquals } from "@test/assert";
import { REGIONS } from "@server/db/regions";
function isIpInRegion(
ipCountryCode: string | undefined,
checkRegionCode: string
): boolean {
if (!ipCountryCode) {
return false;
}
const upperCode = ipCountryCode.toUpperCase();
for (const region of REGIONS) {
// Check if it's a top-level region (continent)
if (region.id === checkRegionCode) {
for (const subregion of region.includes) {
if (subregion.countries.includes(upperCode)) {
return true;
}
}
return false;
}
// Check subregions
for (const subregion of region.includes) {
if (subregion.id === checkRegionCode) {
return subregion.countries.includes(upperCode);
}
}
}
return false;
}
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
@@ -272,12 +305,71 @@ function runTests() {
"Root path should not match non-root path"
);
console.log("All tests passed!");
console.log("All path matching tests passed!");
}
function runRegionTests() {
console.log("\nRunning isIpInRegion tests...");
// Test undefined country code
assertEquals(
isIpInRegion(undefined, "150"),
false,
"Undefined country code should return false"
);
// Test subregion matching (Western Europe)
assertEquals(
isIpInRegion("DE", "155"),
true,
"Country should match its subregion"
);
assertEquals(
isIpInRegion("GB", "155"),
false,
"Country should NOT match wrong subregion"
);
// Test continent matching (Europe)
assertEquals(
isIpInRegion("DE", "150"),
true,
"Country should match its continent"
);
assertEquals(
isIpInRegion("GB", "150"),
true,
"Different European country should match Europe"
);
assertEquals(
isIpInRegion("US", "150"),
false,
"Non-European country should NOT match Europe"
);
// Test case insensitivity
assertEquals(
isIpInRegion("de", "155"),
true,
"Lowercase country code should work"
);
// Test invalid region code
assertEquals(
isIpInRegion("DE", "999"),
false,
"Invalid region code should return false"
);
console.log("All region tests passed!");
}
// Run all tests
try {
runTests();
runRegionTests();
console.log("\n✅ All tests passed!");
} catch (error) {
console.error("Test failed:", error);
console.error("Test failed:", error);
process.exit(1);
}

View File

@@ -39,6 +39,7 @@ import {
} from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit";
import cache from "@server/lib/cache";
import { REGIONS } from "@server/db/regions";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(),
@@ -967,6 +968,12 @@ async function checkRules(
(await isIpInAsn(ipAsn, rule.value))
) {
return rule.action as any;
} else if (
clientIp &&
rule.match == "REGION" &&
(await isIpInRegion(ipCC, rule.value))
) {
return rule.action as any;
}
}
@@ -1133,6 +1140,45 @@ async function isIpInAsn(
return match;
}
export async function isIpInRegion(
ipCountryCode: string | undefined,
checkRegionCode: string
): Promise<boolean> {
if (!ipCountryCode) {
return false;
}
const upperCode = ipCountryCode.toUpperCase();
for (const region of REGIONS) {
// Check if it's a top-level region (continent)
if (region.id === checkRegionCode) {
for (const subregion of region.includes) {
if (subregion.countries.includes(upperCode)) {
logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`);
return true;
}
}
logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`);
return false;
}
// Check subregions
for (const subregion of region.includes) {
if (subregion.id === checkRegionCode) {
if (subregion.countries.includes(upperCode)) {
logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`);
return true;
}
logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`);
return false;
}
}
}
return false;
}
async function getAsnFromIp(ip: string): Promise<number | undefined> {
const asnCacheKey = `asn:${ip}`;

View File

@@ -14,10 +14,11 @@ import {
isValidUrlGlobPattern
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidRegionId } from "@server/db/regions";
const createResourceRuleSchema = z.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]),
value: z.string().min(1),
priority: z.int(),
enabled: z.boolean().optional()
@@ -126,6 +127,15 @@ export async function createResourceRule(
)
);
}
} else if (match === "REGION") {
if (!isValidRegionId(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid region ID provided"
)
);
}
}
// Create the new resource rule

View File

@@ -14,6 +14,7 @@ import {
isValidUrlGlobPattern
} from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { isValidRegionId } from "@server/db/regions";
// Define Zod schema for request parameters validation
const updateResourceRuleParamsSchema = z.strictObject({
@@ -25,7 +26,7 @@ const updateResourceRuleParamsSchema = z.strictObject({
const updateResourceRuleSchema = z
.strictObject({
action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(),
match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(),
value: z.string().min(1).optional(),
priority: z.int(),
enabled: z.boolean().optional()
@@ -166,6 +167,15 @@ export async function updateResourceRule(
)
);
}
} else if (match === "REGION") {
if (!isValidRegionId(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid region ID provided"
)
);
}
}
}