From 8e2f9ea5ef991ca12784f7f1c9e26574895360a0 Mon Sep 17 00:00:00 2001 From: Blacks-Army <104644957+Blacks-Army@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:10:44 +0200 Subject: [PATCH] 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. --- messages/en-US.json | 7 +- server/db/pg/schema/schema.ts | 2 + server/db/sqlite/schema/schema.ts | 2 + server/lib/blueprints/publicResources.ts | 16 ++- server/lib/blueprints/resourcePolicies.ts | 28 +++-- server/lib/blueprints/types.ts | 25 +++- server/lib/validators.test.ts | 43 ++++++- server/lib/validators.ts | 43 ++++++- server/routers/badger/verifySession.ts | 20 +++- .../PolicyAccessRulesTable.tsx | 108 +++++++++++++++--- .../policy-access-rule-validation.ts | 8 +- 11 files changed, 267 insertions(+), 35 deletions(-) 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({ COUNTRIES.some((country) => country.code === value), { message: t("rulesErrorInvalidCountryDescription") } ); + case "METHOD": + return required.refine(isValidHttpMethodList, { + message: t("rulesErrorInvalidMethodDescription") + }); case "ASN": return required.refine( (value) => {