From adeb393fb03246b42f7551b1ed291acfae0a5f83 Mon Sep 17 00:00:00 2001 From: Blacks-Army <104644957+Blacks-Army@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:10:21 +0200 Subject: [PATCH 1/4] Add HTTP method matching to resource rules 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. --- server/routers/badger/verifySession.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index eb7a296..3b8a13a 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -113,6 +113,7 @@ export async function verifyResourceSession( path, headers, query, + method, badgerVersion } = parsedBody.data; @@ -236,7 +237,8 @@ export async function verifyResourceSession( clientIp, path, ipCC, - ipAsn + ipAsn, + method ); if (action === "ACCEPT") { @@ -1206,7 +1208,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}`; @@ -1278,12 +1281,26 @@ 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 ruleValue + .split(",") + .some((ruleMethod) => ruleMethod.trim().toUpperCase() === requestMethod); +} + // Decodes percent-encoding (so an encoded slash like `%2F` is treated as a // real path separator, matching what most backends will do) and then // resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` From 91b25c2e4542c00c28234d80c052dd55f3a42766 Mon Sep 17 00:00:00 2001 From: Blacks-Army <104644957+Blacks-Army@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:11:20 +0200 Subject: [PATCH 2/4] Trigger CI From 87700fe1fd1b8510b022a9a7f38ab1322382bc3a Mon Sep 17 00:00:00 2001 From: Blacks-Army <104644957+Blacks-Army@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:18:32 +0200 Subject: [PATCH 3/4] Add conditional (AND) resource rules A rule with match "AND" carries a JSON array of conditions in its value and applies only when every one of them matches, so a rule can finally combine a path with a method, a country with a CIDR, and so on. The per-condition matching that the rule loop used to do inline moves into matchesCondition, which both a plain rule and an AND rule now go through. Behaviour for existing rules is unchanged. --- server/routers/badger/verifySession.ts | 177 +++++++++++++++++-------- 1 file changed, 122 insertions(+), 55 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 3b8a13a..85f0d59 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1228,77 +1228,144 @@ async function checkRules( // sort rules by priority in ascending order rules = rules.sort((a, b) => a.priority - b.priority); + const context: RuleContext = { clientIp, path, ipCC, ipAsn, method }; + for (const rule of rules) { if (!rule.enabled) { continue; } - if ( - clientIp && - rule.match === "CIDR" && - isIpInCidr(clientIp, rule.value) - ) { - return rule.action as any; - } else if (clientIp && rule.match === "IP" && clientIp === rule.value) { - return rule.action as any; - } else if ( - path && - rule.match === "PATH" && - isPathAllowed(rule.value, path) - ) { - return rule.action as any; - } else if (clientIp && (rule.match === "COUNTRY" || rule.match === "COUNTRY_IS_NOT")) { - // COUNTRY=ALL should not affect local/private/CGNAT addresses. - if ( - rule.value.toUpperCase() === "ALL" && - isLocalOrCarrierGradeNatIp(clientIp) - ) { - continue; + if (rule.match === "AND") { + if (await matchesAllConditions(rule, context)) { + return rule.action as any; } + } else if (await matchesCondition(rule.match, rule.value, context)) { + return rule.action as any; + } + } - const inCountry = await isIpInGeoIP(ipCC, rule.value); - const matched = rule.match === "COUNTRY" ? inCountry : !inCountry; + return; +} - if (matched) { - return rule.action as any; - } - } else if (clientIp && rule.match === "ASN") { - // ASN=ALL/AS0 should not affect local/private/CGNAT addresses. - if ( - (rule.value.toUpperCase() === "ALL" || - rule.value.toUpperCase() === "AS0") && - isLocalOrCarrierGradeNatIp(clientIp) - ) { - continue; - } +type RuleContext = { + clientIp: string | undefined; + path: string | undefined; + ipCC?: string; + ipAsn?: number; + method?: string; +}; - if (await isIpInAsn(ipAsn, rule.value)) { - return rule.action as any; - } - } else if ( - clientIp && - rule.match === "REGION" && - (await isIpInRegion(ipCC, rule.value)) +async function matchesCondition( + match: string, + value: string, + { clientIp, path, ipCC, ipAsn, method }: RuleContext +): Promise { + if (clientIp && match === "CIDR") { + return isIpInCidr(clientIp, value); + } else if (clientIp && match === "IP") { + return clientIp === value; + } else if (path && match === "PATH") { + return isPathAllowed(value, path); + } else if (clientIp && (match === "COUNTRY" || match === "COUNTRY_IS_NOT")) { + // COUNTRY=ALL should not affect local/private/CGNAT addresses. + if ( + value.toUpperCase() === "ALL" && + isLocalOrCarrierGradeNatIp(clientIp) ) { - return rule.action as any; - } else if ( - method && - rule.match === "METHOD" && - isMethodAllowed(rule.value, method) + return false; + } + + const inCountry = await isIpInGeoIP(ipCC, value); + return match === "COUNTRY" ? inCountry : !inCountry; + } else if (clientIp && match === "ASN") { + // ASN=ALL/AS0 should not affect local/private/CGNAT addresses. + if ( + (value.toUpperCase() === "ALL" || value.toUpperCase() === "AS0") && + isLocalOrCarrierGradeNatIp(clientIp) ) { - return rule.action as any; + return false; } + + return await isIpInAsn(ipAsn, value); + } else if (clientIp && match === "REGION") { + return await isIpInRegion(ipCC, value); + } else if (method && match === "METHOD") { + // value holds a comma-separated list of methods, e.g. "POST,PUT". + const requestMethod = method.toUpperCase(); + return value + .split(",") + .some( + (ruleMethod) => ruleMethod.trim().toUpperCase() === requestMethod + ); } - return; + return false; +} + +// An AND rule keeps its conditions as a JSON array in rule.value and applies +// only when every one of them matches. +async function matchesAllConditions( + rule: ResourceRule, + context: RuleContext +): Promise { + const conditions = parseRuleConditions(rule.value); + + // an empty list would match every request, so treat it as a broken rule + if (!conditions || conditions.length === 0) { + logger.warn( + `Skipping rule ${rule.ruleId}: value is not a valid condition list` + ); + return false; + } + + for (const { match, value } of conditions) { + if (!(await matchesCondition(match, value, context))) { + return false; + } + } + + return true; } -// 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 ruleValue - .split(",") - .some((ruleMethod) => ruleMethod.trim().toUpperCase() === requestMethod); +// An AND rule stores its conditions as a JSON array in the rule value, e.g. +// [{"match":"PATH","value":"/api/*"},{"match":"METHOD","value":"POST,PUT"}]. +// Returns null when the value is not a well-formed condition list. +function parseRuleConditions( + value: string +): Array<{ match: string; value: string }> | null { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return null; + } + + if (!Array.isArray(parsed)) { + return null; + } + + const conditions: Array<{ match: string; value: string }> = []; + for (const entry of parsed) { + if (typeof entry !== "object" || entry === null) { + return null; + } + + const { match, value: conditionValue } = entry as Record< + string, + unknown + >; + if ( + typeof match !== "string" || + match === "AND" || + typeof conditionValue !== "string" + ) { + return null; + } + + conditions.push({ match, value: conditionValue }); + } + + return conditions; } // Decodes percent-encoding (so an encoded slash like `%2F` is treated as a From 51c0c4c35ec87bedb599930d32eb3deaa2b22665 Mon Sep 17 00:00:00 2001 From: Blacks-Army <104644957+Blacks-Army@users.noreply.github.com> Date: Sat, 19 Sep 2026 20:11:26 +0200 Subject: [PATCH 4/4] Trigger CI