mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-21 18:18:27 +02:00
Merge pull request #3704 from Blacks-Army/feat/http-method-rules
Add HTTP method matching to resource rules
This commit is contained in:
+5
-2
@@ -834,7 +834,7 @@
|
|||||||
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||||
"rulesErrorValidation": "Invalid rules",
|
"rulesErrorValidation": "Invalid rules",
|
||||||
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||||
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
|
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, ASN, or method).",
|
||||||
"rulesErrorValueRequired": "Enter a value for this rule.",
|
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||||
"rulesErrorInvalidCountry": "Invalid country",
|
"rulesErrorInvalidCountry": "Invalid country",
|
||||||
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||||
@@ -4400,5 +4400,8 @@
|
|||||||
"sessionToolbarShow": "Show toolbar",
|
"sessionToolbarShow": "Show toolbar",
|
||||||
"sessionToolbarHide": "Hide toolbar",
|
"sessionToolbarHide": "Hide toolbar",
|
||||||
"actionUpdateSiteApprovals": "Update Site Approvals",
|
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||||
"check": "Check"
|
"check": "Check",
|
||||||
|
"rulesErrorInvalidMethod": "Invalid HTTP method",
|
||||||
|
"rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
|
||||||
|
"rulesSelectMethods": "Select methods"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1137,6 +1137,7 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(), // CIDR, PATH, IP
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
@@ -1161,6 +1162,7 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
|
|||||||
@@ -1409,6 +1409,7 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(), // CIDR, PATH, IP
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
@@ -1465,6 +1466,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
|||||||
import { pickPort } from "@server/routers/target/helpers";
|
import { pickPort } from "@server/routers/target/helpers";
|
||||||
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
|
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidHttpMethodList,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern,
|
||||||
|
parseHttpMethodList
|
||||||
|
} from "../validators";
|
||||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||||
@@ -1453,6 +1459,10 @@ function getRuleValue(match: string, value: string) {
|
|||||||
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
||||||
return value.toUpperCase();
|
return value.toUpperCase();
|
||||||
}
|
}
|
||||||
|
// normalize the method list so it is stored as "POST,PUT"
|
||||||
|
if (match === "METHOD") {
|
||||||
|
return parseHttpMethodList(value).join(",");
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,6 +1483,10 @@ function validateRule(rule: any) {
|
|||||||
if (!isValidRegionId(rule.value)) {
|
if (!isValidRegionId(rule.value)) {
|
||||||
throw new Error(`Invalid region ID provided: ${rule.value}`);
|
throw new Error(`Invalid region ID provided: ${rule.value}`);
|
||||||
}
|
}
|
||||||
|
} else if (rule.match === "method") {
|
||||||
|
if (!isValidHttpMethodList(rule.value)) {
|
||||||
|
throw new Error(`Invalid HTTP method provided: ${rule.value}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import logger from "@server/logger";
|
|||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidHttpMethodList,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern,
|
||||||
|
ResourceRuleMatchType
|
||||||
|
} from "../validators";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||||
@@ -66,6 +72,13 @@ export async function updateResourcePolicies(
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
rule.match === "method" &&
|
||||||
|
!isValidHttpMethodList(rule.value)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid HTTP method provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,17 +352,8 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
|||||||
return "PASS";
|
return "PASS";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuleMatch(
|
function getRuleMatch(input: string): ResourceRuleMatchType {
|
||||||
input: string
|
return input.toUpperCase() as ResourceRuleMatchType;
|
||||||
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
|
|
||||||
return input.toUpperCase() as
|
|
||||||
| "CIDR"
|
|
||||||
| "IP"
|
|
||||||
| "PATH"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncRolePolicies(
|
async function syncRolePolicies(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { portRangeStringSchema } from "@server/lib/ip";
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
import { isValidHttpMethodList } from "@server/lib/validators";
|
||||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import {
|
||||||
@@ -127,7 +128,16 @@ export const AuthSchema = z.object({
|
|||||||
export const RuleSchema = z
|
export const RuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
|
match: z.enum([
|
||||||
|
"cidr",
|
||||||
|
"path",
|
||||||
|
"ip",
|
||||||
|
"country",
|
||||||
|
"country_is_not",
|
||||||
|
"asn",
|
||||||
|
"region",
|
||||||
|
"method"
|
||||||
|
]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional(),
|
priority: z.int().optional(),
|
||||||
enabled: z.boolean().optional().default(true)
|
enabled: z.boolean().optional().default(true)
|
||||||
@@ -207,6 +217,19 @@ export const RuleSchema = z
|
|||||||
message:
|
message:
|
||||||
"Value must be a valid UN M.49 region or subregion ID when match is 'region'"
|
"Value must be a valid UN M.49 region or subregion ID when match is 'region'"
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(rule) => {
|
||||||
|
if (rule.match === "method") {
|
||||||
|
return isValidHttpMethodList(rule.value);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["value"],
|
||||||
|
message:
|
||||||
|
"Value must be a comma-separated list of HTTP methods when match is 'method', e.g. 'POST,PUT'"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const HeaderSchema = z.object({
|
export const HeaderSchema = z.object({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
getResourceRuleValueValidationError,
|
getResourceRuleValueValidationError,
|
||||||
isValidDomain,
|
isValidDomain,
|
||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern,
|
||||||
|
parseHttpMethodList
|
||||||
} from "./validators";
|
} from "./validators";
|
||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals, assertEqualsObj } from "@test/assert";
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
console.log("Running domain validation tests...");
|
console.log("Running domain validation tests...");
|
||||||
@@ -295,6 +296,44 @@ function runTests() {
|
|||||||
"Invalid ASN should return an error"
|
"Invalid ASN should return an error"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// HTTP method validation tests
|
||||||
|
assertEquals(
|
||||||
|
getResourceRuleValueValidationError("METHOD", "POST"),
|
||||||
|
null,
|
||||||
|
"Single HTTP method should be valid"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
getResourceRuleValueValidationError("METHOD", " post , Put "),
|
||||||
|
null,
|
||||||
|
"Method list should be valid with mixed case and whitespace"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
getResourceRuleValueValidationError("METHOD", "PROPFIND"),
|
||||||
|
null,
|
||||||
|
"Extension methods such as the WebDAV verbs should be valid"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
getResourceRuleValueValidationError("METHOD", ""),
|
||||||
|
"Invalid HTTP method provided",
|
||||||
|
"Empty method list should return an error"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
getResourceRuleValueValidationError("METHOD", ",,"),
|
||||||
|
"Invalid HTTP method provided",
|
||||||
|
"Method list of only separators should return an error"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
getResourceRuleValueValidationError("METHOD", "GET POST"),
|
||||||
|
"Invalid HTTP method provided",
|
||||||
|
"Space separated methods should return an error"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEqualsObj(
|
||||||
|
parseHttpMethodList(" get ,post, "),
|
||||||
|
["GET", "POST"],
|
||||||
|
"Method list should be normalized to uppercase without empty entries"
|
||||||
|
);
|
||||||
|
|
||||||
console.log("All tests passed!");
|
console.log("All tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [
|
|||||||
"COUNTRY",
|
"COUNTRY",
|
||||||
"COUNTRY_IS_NOT",
|
"COUNTRY_IS_NOT",
|
||||||
"ASN",
|
"ASN",
|
||||||
"REGION"
|
"REGION",
|
||||||
|
"METHOD"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// The methods offered in the UI: the eight from RFC 9110 plus PATCH (RFC 5789)
|
||||||
|
// and QUERY (RFC 10008). A METHOD rule is not limited to these, since
|
||||||
|
// isValidHttpMethodList accepts any method token, so blueprints and the API can
|
||||||
|
// also target extension methods such as the WebDAV verbs.
|
||||||
|
export const HTTP_METHODS = [
|
||||||
|
"GET",
|
||||||
|
"HEAD",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"DELETE",
|
||||||
|
"OPTIONS",
|
||||||
|
"TRACE",
|
||||||
|
"CONNECT",
|
||||||
|
"QUERY"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// RFC 9110 token, minus the characters that would collide with the
|
||||||
|
// comma-separated list encoding.
|
||||||
|
const HTTP_METHOD_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||||
|
|
||||||
|
export function parseHttpMethodList(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((method) => method.trim().toUpperCase())
|
||||||
|
.filter((method) => method.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidHttpMethodList(value: string): boolean {
|
||||||
|
const methods = parseHttpMethodList(value);
|
||||||
|
return (
|
||||||
|
methods.length > 0 &&
|
||||||
|
methods.every((method) => HTTP_METHOD_REGEX.test(method))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
||||||
|
|
||||||
export function getResourceRuleValueValidationError(
|
export function getResourceRuleValueValidationError(
|
||||||
@@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError(
|
|||||||
return COUNTRIES.some((country) => country.code === value)
|
return COUNTRIES.some((country) => country.code === value)
|
||||||
? null
|
? null
|
||||||
: "Invalid country code provided";
|
: "Invalid country code provided";
|
||||||
|
case "METHOD":
|
||||||
|
return isValidHttpMethodList(value)
|
||||||
|
? null
|
||||||
|
: "Invalid HTTP method provided";
|
||||||
case "ASN":
|
case "ASN":
|
||||||
const normalizedValue = value.trim().toUpperCase();
|
const normalizedValue = value.trim().toUpperCase();
|
||||||
return /^AS\d+$/.test(normalizedValue) ||
|
return /^AS\d+$/.test(normalizedValue) ||
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
import { isPathAllowed } from "@server/lib/pathMatch";
|
||||||
|
import { parseHttpMethodList } from "@server/lib/validators";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -163,6 +164,7 @@ export async function verifyResourceSession(
|
|||||||
path,
|
path,
|
||||||
headers,
|
headers,
|
||||||
query,
|
query,
|
||||||
|
method,
|
||||||
badgerVersion
|
badgerVersion
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
@@ -293,7 +295,8 @@ export async function verifyResourceSession(
|
|||||||
clientIp,
|
clientIp,
|
||||||
path,
|
path,
|
||||||
ipCC,
|
ipCC,
|
||||||
ipAsn
|
ipAsn,
|
||||||
|
method
|
||||||
);
|
);
|
||||||
|
|
||||||
if (action == "ACCEPT") {
|
if (action == "ACCEPT") {
|
||||||
@@ -1429,7 +1432,8 @@ async function checkRules(
|
|||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
path: string | undefined,
|
path: string | undefined,
|
||||||
ipCC?: string,
|
ipCC?: string,
|
||||||
ipAsn?: number
|
ipAsn?: number,
|
||||||
|
method?: string
|
||||||
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
||||||
const ruleCacheKey = `rules:${resourceId}`;
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
@@ -1504,12 +1508,24 @@ async function checkRules(
|
|||||||
(await isIpInRegion(ipCC, rule.value))
|
(await isIpInRegion(ipCC, rule.value))
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
method &&
|
||||||
|
rule.match == "METHOD" &&
|
||||||
|
isMethodAllowed(rule.value, method)
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rule.value holds a comma-separated list of HTTP methods, e.g. "POST,PUT".
|
||||||
|
function isMethodAllowed(ruleValue: string, method: string): boolean {
|
||||||
|
const requestMethod = method.toUpperCase();
|
||||||
|
return parseHttpMethodList(ruleValue).includes(requestMethod);
|
||||||
|
}
|
||||||
|
|
||||||
export { isPathAllowed };
|
export { isPathAllowed };
|
||||||
|
|
||||||
async function isIpInGeoIP(
|
async function isIpInGeoIP(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
import { REGIONS, getRegionNameById } from "@server/db/regions";
|
import { REGIONS, getRegionNameById } from "@server/db/regions";
|
||||||
|
import { HTTP_METHODS, parseHttpMethodList } from "@server/lib/validators";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -63,7 +64,8 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
validatePolicyRulePriority,
|
validatePolicyRulePriority,
|
||||||
validatePolicyRuleValue
|
validatePolicyRuleValue,
|
||||||
|
type PolicyRuleMatchType
|
||||||
} from "./policy-access-rule-validation";
|
} from "./policy-access-rule-validation";
|
||||||
import {
|
import {
|
||||||
buildDisplayPrioritiesForResourceOverlay,
|
buildDisplayPrioritiesForResourceOverlay,
|
||||||
@@ -112,6 +114,80 @@ function getColumnClassName(columnId: string) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A METHOD rule stores its methods as a comma-separated list in rule.value,
|
||||||
|
// e.g. "POST,PUT". Only the common methods are offered here; a value set
|
||||||
|
// through a blueprint or the API may contain other methods (the WebDAV verbs,
|
||||||
|
// for instance), so those are kept and shown rather than dropped on edit.
|
||||||
|
function RuleMethodSelect({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
disabled: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const selected = parseHttpMethodList(value);
|
||||||
|
const knownMethods: readonly string[] = HTTP_METHODS;
|
||||||
|
const options = [
|
||||||
|
...knownMethods,
|
||||||
|
...selected.filter((method) => !knownMethods.includes(method))
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggle(method: string) {
|
||||||
|
const next = selected.includes(method)
|
||||||
|
? selected.filter((m) => m !== method)
|
||||||
|
: [...selected, method];
|
||||||
|
|
||||||
|
// keep a stable order so the stored value does not churn on every edit
|
||||||
|
onChange(options.filter((m) => next.includes(m)).join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full min-w-0 justify-between"
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{selected.length > 0 ? selected.join(", ") : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="min-w-50 p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((method) => (
|
||||||
|
<CommandItem
|
||||||
|
key={method}
|
||||||
|
value={method}
|
||||||
|
onSelect={() => toggle(method)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={`mr-2 h-4 w-4 ${
|
||||||
|
selected.includes(method)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{method}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PolicyAccessRulesTable({
|
export function PolicyAccessRulesTable({
|
||||||
rules,
|
rules,
|
||||||
onRulesChange,
|
onRulesChange,
|
||||||
@@ -233,7 +309,8 @@ export function PolicyAccessRulesTable({
|
|||||||
COUNTRY: t("country"),
|
COUNTRY: t("country"),
|
||||||
COUNTRY_IS_NOT: t("countryIsNot"),
|
COUNTRY_IS_NOT: t("countryIsNot"),
|
||||||
ASN: "ASN",
|
ASN: "ASN",
|
||||||
REGION: t("region")
|
REGION: t("region"),
|
||||||
|
METHOD: t("method")
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
@@ -438,16 +515,7 @@ export function PolicyAccessRulesTable({
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
disabled={readonly || isRuleLocked(row.original)}
|
disabled={readonly || isRuleLocked(row.original)}
|
||||||
onValueChange={(
|
onValueChange={(value: PolicyRuleMatchType) =>
|
||||||
value:
|
|
||||||
| "CIDR"
|
|
||||||
| "IP"
|
|
||||||
| "PATH"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
) =>
|
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
value:
|
value:
|
||||||
@@ -458,7 +526,9 @@ export function PolicyAccessRulesTable({
|
|||||||
? "AS15169"
|
? "AS15169"
|
||||||
: value === "REGION"
|
: value === "REGION"
|
||||||
? "021"
|
? "021"
|
||||||
: row.original.value
|
: value === "METHOD"
|
||||||
|
? "GET"
|
||||||
|
: row.original.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -473,6 +543,9 @@ export function PolicyAccessRulesTable({
|
|||||||
<SelectItem value="CIDR">
|
<SelectItem value="CIDR">
|
||||||
{RuleMatch.CIDR}
|
{RuleMatch.CIDR}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="METHOD">
|
||||||
|
{RuleMatch.METHOD}
|
||||||
|
</SelectItem>
|
||||||
{isMaxmindAvailable && (
|
{isMaxmindAvailable && (
|
||||||
<>
|
<>
|
||||||
<SelectItem value="COUNTRY">
|
<SelectItem value="COUNTRY">
|
||||||
@@ -779,6 +852,15 @@ export function PolicyAccessRulesTable({
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
) : row.original.match === "METHOD" ? (
|
||||||
|
<RuleMethodSelect
|
||||||
|
value={row.original.value}
|
||||||
|
disabled={readonly || isRuleLocked(row.original)}
|
||||||
|
placeholder={t("rulesSelectMethods")}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateRule(row.original.ruleId, { value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.value}
|
defaultValue={row.original.value}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { COUNTRIES } from "@server/db/countries";
|
|||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
isValidCIDR,
|
||||||
|
isValidHttpMethodList,
|
||||||
isValidIP,
|
isValidIP,
|
||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
@@ -19,7 +20,8 @@ export const POLICY_RULE_MATCH_TYPES = [
|
|||||||
"COUNTRY",
|
"COUNTRY",
|
||||||
"COUNTRY_IS_NOT",
|
"COUNTRY_IS_NOT",
|
||||||
"ASN",
|
"ASN",
|
||||||
"REGION"
|
"REGION",
|
||||||
|
"METHOD"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
||||||
@@ -84,6 +86,10 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
|||||||
(value) => COUNTRIES.some((country) => country.code === value),
|
(value) => COUNTRIES.some((country) => country.code === value),
|
||||||
{ message: t("rulesErrorInvalidCountryDescription") }
|
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||||
);
|
);
|
||||||
|
case "METHOD":
|
||||||
|
return required.refine(isValidHttpMethodList, {
|
||||||
|
message: t("rulesErrorInvalidMethodDescription")
|
||||||
|
});
|
||||||
case "ASN":
|
case "ASN":
|
||||||
return required.refine(
|
return required.refine(
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user