diff --git a/messages/en-US.json b/messages/en-US.json
index 664098b8b..ed0ca7dba 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -834,7 +834,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules",
"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.",
"rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -4400,5 +4400,8 @@
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals",
- "check": "Check"
+ "check": "Check",
+ "rulesErrorInvalidMethod": "Invalid HTTP method",
+ "rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
+ "rulesSelectMethods": "Select methods"
}
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 744e88b98..6569d48b9 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -1137,6 +1137,7 @@ export const resourceRules = pgTable("resourceRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
+ | "METHOD"
>()
.notNull(), // CIDR, PATH, IP
value: varchar("value").notNull()
@@ -1161,6 +1162,7 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
+ | "METHOD"
>()
.notNull(),
value: varchar("value").notNull()
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index e30bc678e..6201e695d 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -1409,6 +1409,7 @@ export const resourceRules = sqliteTable("resourceRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
+ | "METHOD"
>()
.notNull(), // CIDR, PATH, IP
value: text("value").notNull()
@@ -1465,6 +1466,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
+ | "METHOD"
>()
.notNull(),
value: text("value").notNull()
diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts
index 4bd42ed0b..55e0013ae 100644
--- a/server/lib/blueprints/publicResources.ts
+++ b/server/lib/blueprints/publicResources.ts
@@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
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 { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -1453,6 +1459,10 @@ function getRuleValue(match: string, value: string) {
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase();
}
+ // normalize the method list so it is stored as "POST,PUT"
+ if (match === "METHOD") {
+ return parseHttpMethodList(value).join(",");
+ }
return value;
}
@@ -1473,6 +1483,10 @@ function validateRule(rule: any) {
if (!isValidRegionId(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}`);
+ }
}
}
diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts
index d8c744cdb..babb1c10a 100644
--- a/server/lib/blueprints/resourcePolicies.ts
+++ b/server/lib/blueprints/resourcePolicies.ts
@@ -19,7 +19,13 @@ import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
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 { tierMatrix } from "../billing/tierMatrix";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -66,6 +72,13 @@ export async function updateResourcePolicies(
throw new Error(
`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";
}
-function getRuleMatch(
- input: string
-): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
- return input.toUpperCase() as
- | "CIDR"
- | "IP"
- | "PATH"
- | "COUNTRY"
- | "COUNTRY_IS_NOT"
- | "ASN"
- | "REGION";
+function getRuleMatch(input: string): ResourceRuleMatchType {
+ return input.toUpperCase() as ResourceRuleMatchType;
}
async function syncRolePolicies(
diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts
index 238ef49c4..80c4aeb87 100644
--- a/server/lib/blueprints/types.ts
+++ b/server/lib/blueprints/types.ts
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
+import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
@@ -127,7 +128,16 @@ export const AuthSchema = z.object({
export const RuleSchema = z
.object({
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(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
@@ -207,6 +217,19 @@ export const RuleSchema = z
message:
"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({
diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts
index 5ce95f45c..c17181c6a 100644
--- a/server/lib/validators.test.ts
+++ b/server/lib/validators.test.ts
@@ -1,9 +1,10 @@
import {
getResourceRuleValueValidationError,
isValidDomain,
- isValidUrlGlobPattern
+ isValidUrlGlobPattern,
+ parseHttpMethodList
} from "./validators";
-import { assertEquals } from "@test/assert";
+import { assertEquals, assertEqualsObj } from "@test/assert";
function runTests() {
console.log("Running domain validation tests...");
@@ -295,6 +296,44 @@ function runTests() {
"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!");
}
diff --git a/server/lib/validators.ts b/server/lib/validators.ts
index 872ced221..d5bf5d2cc 100644
--- a/server/lib/validators.ts
+++ b/server/lib/validators.ts
@@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
- "REGION"
+ "REGION",
+ "METHOD"
] 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 function getResourceRuleValueValidationError(
@@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError(
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
+ case "METHOD":
+ return isValidHttpMethodList(value)
+ ? null
+ : "Invalid HTTP method provided";
case "ASN":
const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) ||
diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts
index 486feb2b7..b8ecf6b9e 100644
--- a/server/routers/badger/verifySession.ts
+++ b/server/routers/badger/verifySession.ts
@@ -40,6 +40,7 @@ import {
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch";
+import { parseHttpMethodList } from "@server/lib/validators";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -163,6 +164,7 @@ export async function verifyResourceSession(
path,
headers,
query,
+ method,
badgerVersion
} = parsedBody.data;
@@ -293,7 +295,8 @@ export async function verifyResourceSession(
clientIp,
path,
ipCC,
- ipAsn
+ ipAsn,
+ method
);
if (action == "ACCEPT") {
@@ -1429,7 +1432,8 @@ async function checkRules(
clientIp: string | undefined,
path: string | undefined,
ipCC?: string,
- ipAsn?: number
+ ipAsn?: number,
+ method?: string
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
@@ -1504,12 +1508,24 @@ async function checkRules(
(await isIpInRegion(ipCC, rule.value))
) {
return rule.action as any;
+ } else if (
+ method &&
+ rule.match == "METHOD" &&
+ isMethodAllowed(rule.value, method)
+ ) {
+ return rule.action as any;
}
}
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 };
async function isIpInGeoIP(
diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx
index 2ff2e1915..dba282342 100644
--- a/src/components/resource-policy/PolicyAccessRulesTable.tsx
+++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx
@@ -37,6 +37,7 @@ import { cn } from "@app/lib/cn";
import { MAJOR_ASNS } from "@server/db/asns";
import { COUNTRIES } from "@server/db/countries";
import { REGIONS, getRegionNameById } from "@server/db/regions";
+import { HTTP_METHODS, parseHttpMethodList } from "@server/lib/validators";
import {
ColumnDef,
flexRender,
@@ -63,7 +64,8 @@ import {
} from "react";
import {
validatePolicyRulePriority,
- validatePolicyRuleValue
+ validatePolicyRuleValue,
+ type PolicyRuleMatchType
} from "./policy-access-rule-validation";
import {
buildDisplayPrioritiesForResourceOverlay,
@@ -112,6 +114,80 @@ function getColumnClassName(columnId: string) {
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 (
+
+
+
+
+
+
+
+
+ {options.map((method) => (
+ toggle(method)}
+ >
+
+ {method}
+
+ ))}
+
+
+
+
+
+ );
+}
+
export function PolicyAccessRulesTable({
rules,
onRulesChange,
@@ -233,7 +309,8 @@ export function PolicyAccessRulesTable({
COUNTRY: t("country"),
COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN",
- REGION: t("region")
+ REGION: t("region"),
+ METHOD: t("method")
}),
[t]
);
@@ -438,16 +515,7 @@ export function PolicyAccessRulesTable({