Merge pull request #3704 from Blacks-Army/feat/http-method-rules

Add HTTP method matching to resource rules
This commit is contained in:
Owen Schwartz
2026-09-21 11:44:54 -04:00
committed by GitHub
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.", "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"
} }
+2
View File
@@ -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()
+2
View File
@@ -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()
+15 -1
View File
@@ -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}`);
}
} }
} }
+16 -12
View File
@@ -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(
+24 -1
View File
@@ -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({
+41 -2
View File
@@ -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!");
} }
+42 -1
View File
@@ -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) ||
+18 -2
View File
@@ -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) => {