Add HTTP method matching to resource rules

Resolves #1408.

A rule with match "METHOD" carries a comma-separated list of HTTP
methods in its value, e.g. "POST,PUT", and applies when the request
method is in that list. This makes it possible to leave GET public
while sending POST and PUT to auth, which rules could not express
before because both share the same path.

No new columns: the methods live in the existing rule value, so this
needs no migration and every existing rule keeps working unchanged.

The UI offers the ten registered methods. Blueprints and the API
accept any method token, so extension methods such as the WebDAV verbs
can be targeted too, and the UI preserves them when a rule set that
way is edited later.
This commit is contained in:
Blacks-Army
2026-09-19 20:00:01 +02:00
parent 5ca08d71f0
commit 8e2f9ea5ef
11 changed files with 267 additions and 35 deletions
+5 -2
View File
@@ -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"
}
+2
View File
@@ -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()
+2
View File
@@ -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()
+15 -1
View File
@@ -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}`);
}
}
}
+16 -12
View File
@@ -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(
+24 -1
View File
@@ -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({
+41 -2
View File
@@ -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!");
}
+42 -1
View File
@@ -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) ||
+18 -2
View File
@@ -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(
@@ -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 (
<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({
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({
<Select
defaultValue={row.original.match}
disabled={readonly || isRuleLocked(row.original)}
onValueChange={(
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
) =>
onValueChange={(value: PolicyRuleMatchType) =>
updateRule(row.original.ruleId, {
match: value,
value:
@@ -458,7 +526,9 @@ export function PolicyAccessRulesTable({
? "AS15169"
: value === "REGION"
? "021"
: row.original.value
: value === "METHOD"
? "GET"
: row.original.value
})
}
>
@@ -473,6 +543,9 @@ export function PolicyAccessRulesTable({
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
<SelectItem value="METHOD">
{RuleMatch.METHOD}
</SelectItem>
{isMaxmindAvailable && (
<>
<SelectItem value="COUNTRY">
@@ -779,6 +852,15 @@ export function PolicyAccessRulesTable({
</Command>
</PopoverContent>
</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
defaultValue={row.original.value}
@@ -2,6 +2,7 @@ import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
@@ -19,7 +20,8 @@ export const POLICY_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
"REGION"
"REGION",
"METHOD"
] as const;
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),
{ message: t("rulesErrorInvalidCountryDescription") }
);
case "METHOD":
return required.refine(isValidHttpMethodList, {
message: t("rulesErrorInvalidMethodDescription")
});
case "ASN":
return required.refine(
(value) => {