mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-21 18:18:27 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d10acad9 | |||
| 887c6e4e8b | |||
| f71b9e7c54 | |||
| 5dd4c54ad0 | |||
| 6f3e0cf5a6 | |||
| d4488ec125 | |||
| b8572524a4 | |||
| b054f90149 | |||
| c290239894 | |||
| 8e2f9ea5ef | |||
| 032eeb2656 | |||
| 5ca08d71f0 | |||
| 1f453dc04f | |||
| a7d4745f93 |
@@ -3,18 +3,25 @@ name: ESLint
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Disabled from running on PRs: typescript-eslint does not yet support
|
||||
# TypeScript 7 (which this repo is on), so eslint currently crashes on
|
||||
# every run. Kept as workflow_dispatch so it can still be triggered
|
||||
# manually, and re-enabled on pull_request once upstream support lands.
|
||||
# https://github.com/typescript-eslint/typescript-eslint/issues/10940
|
||||
# on:
|
||||
# pull_request:
|
||||
# paths:
|
||||
# - '**/*.js'
|
||||
# - '**/*.jsx'
|
||||
# - '**/*.ts'
|
||||
# - '**/*.tsx'
|
||||
# - '.eslintrc*'
|
||||
# - 'package.json'
|
||||
# - 'yarn.lock'
|
||||
# - 'pnpm-lock.yaml'
|
||||
# - 'package-lock.json'
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
- '.eslintrc*'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'package-lock.json'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
Linter:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: docker.io/fosrl/pangolin:ee-latest
|
||||
image: docker.io/fosrl/pangolin:ee-postgresql-latest
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: pangolin
|
||||
services:
|
||||
pangolin:
|
||||
image: docker.io/fosrl/pangolin:ee-latest
|
||||
image: docker.io/fosrl/pangolin:ee-postgresql-latest
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
+6
-3
@@ -132,7 +132,7 @@
|
||||
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
|
||||
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
|
||||
"siteRestarted": "Site restarted",
|
||||
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
|
||||
"siteRestartedDescription": "The site has been restarted.",
|
||||
"siteErrorRestart": "Failed to restart site",
|
||||
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
||||
"siteSettingDescription": "Configure the settings on the site",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+76
-47
@@ -4,42 +4,18 @@
|
||||
"iPad2,2": "iPad 2",
|
||||
"iPad2,3": "iPad 2",
|
||||
"iPad2,4": "iPad 2",
|
||||
"iPad3,1": "iPad 3rd Gen",
|
||||
"iPad3,3": "iPad 3rd Gen",
|
||||
"iPad3,2": "iPad 3rd Gen",
|
||||
"iPad3,4": "iPad 4th Gen",
|
||||
"iPad3,5": "iPad 4th Gen",
|
||||
"iPad3,6": "iPad 4th Gen",
|
||||
"iPad6,11": "iPad 9.7 5th Gen",
|
||||
"iPad6,12": "iPad 9.7 5th Gen",
|
||||
"iPad7,5": "iPad 9.7 6th Gen",
|
||||
"iPad7,6": "iPad 9.7 6th Gen",
|
||||
"iPad7,11": "iPad 10.2 7th Gen",
|
||||
"iPad7,12": "iPad 10.2 7th Gen",
|
||||
"iPad11,6": "iPad 10.2 8th Gen",
|
||||
"iPad11,7": "iPad 10.2 8th Gen",
|
||||
"iPad12,1": "iPad 10.2 9th Gen",
|
||||
"iPad12,2": "iPad 10.2 9th Gen",
|
||||
"iPad13,18": "iPad 10.9 10th Gen",
|
||||
"iPad13,19": "iPad 10.9 10th Gen",
|
||||
"iPad4,1": "iPad Air",
|
||||
"iPad4,2": "iPad Air",
|
||||
"iPad4,3": "iPad Air",
|
||||
"iPad5,3": "iPad Air 2",
|
||||
"iPad5,4": "iPad Air 2",
|
||||
"iPad11,3": "iPad Air 3rd Gen",
|
||||
"iPad11,4": "iPad Air 3rd Gen",
|
||||
"iPad13,1": "iPad Air 4th Gen",
|
||||
"iPad13,2": "iPad Air 4th Gen",
|
||||
"iPad13,16": "iPad Air 5th Gen",
|
||||
"iPad13,17": "iPad Air 5th Gen",
|
||||
"iPad14,8": "iPad Air M2 11",
|
||||
"iPad14,9": "iPad Air M2 11",
|
||||
"iPad14,10": "iPad Air M2 13",
|
||||
"iPad14,11": "iPad Air M2 13",
|
||||
"iPad2,5": "iPad mini",
|
||||
"iPad2,6": "iPad mini",
|
||||
"iPad2,7": "iPad mini",
|
||||
"iPad3,1": "iPad 3rd Gen",
|
||||
"iPad3,2": "iPad 3rd Gen",
|
||||
"iPad3,3": "iPad 3rd Gen",
|
||||
"iPad3,4": "iPad 4th Gen",
|
||||
"iPad3,5": "iPad 4th Gen",
|
||||
"iPad3,6": "iPad 4th Gen",
|
||||
"iPad4,1": "iPad Air",
|
||||
"iPad4,2": "iPad Air",
|
||||
"iPad4,3": "iPad Air",
|
||||
"iPad4,4": "iPad mini 2",
|
||||
"iPad4,5": "iPad mini 2",
|
||||
"iPad4,6": "iPad mini 2",
|
||||
@@ -48,18 +24,22 @@
|
||||
"iPad4,9": "iPad mini 3",
|
||||
"iPad5,1": "iPad mini 4",
|
||||
"iPad5,2": "iPad mini 4",
|
||||
"iPad11,1": "iPad mini 5th Gen",
|
||||
"iPad11,2": "iPad mini 5th Gen",
|
||||
"iPad14,1": "iPad mini 6th Gen",
|
||||
"iPad14,2": "iPad mini 6th Gen",
|
||||
"iPad6,7": "iPad Pro 12.9",
|
||||
"iPad6,8": "iPad Pro 12.9",
|
||||
"iPad5,3": "iPad Air 2",
|
||||
"iPad5,4": "iPad Air 2",
|
||||
"iPad6,3": "iPad Pro 9.7",
|
||||
"iPad6,4": "iPad Pro 9.7",
|
||||
"iPad7,3": "iPad Pro 10.5",
|
||||
"iPad7,4": "iPad Pro 10.5",
|
||||
"iPad6,7": "iPad Pro 12.9",
|
||||
"iPad6,8": "iPad Pro 12.9",
|
||||
"iPad6,11": "iPad 9.7 5th Gen",
|
||||
"iPad6,12": "iPad 9.7 5th Gen",
|
||||
"iPad7,1": "iPad Pro 12.9",
|
||||
"iPad7,2": "iPad Pro 12.9",
|
||||
"iPad7,3": "iPad Pro 10.5",
|
||||
"iPad7,4": "iPad Pro 10.5",
|
||||
"iPad7,5": "iPad 9.7 6th Gen",
|
||||
"iPad7,6": "iPad 9.7 6th Gen",
|
||||
"iPad7,11": "iPad 10.2 7th Gen",
|
||||
"iPad7,12": "iPad 10.2 7th Gen",
|
||||
"iPad8,1": "iPad Pro 11",
|
||||
"iPad8,2": "iPad Pro 11",
|
||||
"iPad8,3": "iPad Pro 11",
|
||||
@@ -72,6 +52,16 @@
|
||||
"iPad8,10": "iPad Pro 11",
|
||||
"iPad8,11": "iPad Pro 12.9",
|
||||
"iPad8,12": "iPad Pro 12.9",
|
||||
"iPad11,1": "iPad mini 5th Gen",
|
||||
"iPad11,2": "iPad mini 5th Gen",
|
||||
"iPad11,3": "iPad Air 3rd Gen",
|
||||
"iPad11,4": "iPad Air 3rd Gen",
|
||||
"iPad11,6": "iPad 10.2 8th Gen",
|
||||
"iPad11,7": "iPad 10.2 8th Gen",
|
||||
"iPad12,1": "iPad 10.2 9th Gen",
|
||||
"iPad12,2": "iPad 10.2 9th Gen",
|
||||
"iPad13,1": "iPad Air 4th Gen",
|
||||
"iPad13,2": "iPad Air 4th Gen",
|
||||
"iPad13,4": "iPad Pro 11",
|
||||
"iPad13,5": "iPad Pro 11",
|
||||
"iPad13,6": "iPad Pro 11",
|
||||
@@ -80,14 +70,40 @@
|
||||
"iPad13,9": "iPad Pro 12.9",
|
||||
"iPad13,10": "iPad Pro 12.9",
|
||||
"iPad13,11": "iPad Pro 12.9",
|
||||
"iPad13,16": "iPad Air M1 5th Gen",
|
||||
"iPad13,17": "iPad Air M1 5th Gen",
|
||||
"iPad13,18": "iPad 10.9 10th Gen",
|
||||
"iPad13,19": "iPad 10.9 10th Gen",
|
||||
"iPad14,1": "iPad mini 6th Gen",
|
||||
"iPad14,2": "iPad mini 6th Gen",
|
||||
"iPad14,3": "iPad Pro 11",
|
||||
"iPad14,4": "iPad Pro 11",
|
||||
"iPad14,5": "iPad Pro 12.9",
|
||||
"iPad14,6": "iPad Pro 12.9",
|
||||
"iPad14,8": "iPad Air M2 11",
|
||||
"iPad14,9": "iPad Air M2 11",
|
||||
"iPad14,10": "iPad Air M2 13",
|
||||
"iPad14,11": "iPad Air M2 13",
|
||||
"iPad15,3": "iPad Air M3 11",
|
||||
"iPad15,4": "iPad Air M3 11",
|
||||
"iPad15,5": "iPad Air M3 13",
|
||||
"iPad15,6": "iPad Air M3 13",
|
||||
"iPad15,7": "iPad A16 - 11th Gen",
|
||||
"iPad15,8": "iPad A16 - 11th Gen",
|
||||
"iPad16,1": "iPad mini A17 Pro - 7th Gen",
|
||||
"iPad16,2": "iPad mini A17 Pro - 7th Gen",
|
||||
"iPad16,3": "iPad Pro M4 11",
|
||||
"iPad16,4": "iPad Pro M4 11",
|
||||
"iPad16,5": "iPad Pro M4 13",
|
||||
"iPad16,6": "iPad Pro M4 13",
|
||||
"iPad16,8": "iPad Air M4 11",
|
||||
"iPad16,9": "iPad Air M4 11",
|
||||
"iPad16,10": "iPad Air M4 13",
|
||||
"iPad16,11": "iPad Air M4 13",
|
||||
"iPad17,1": "iPad Pro M5 11",
|
||||
"iPad17,2": "iPad Pro M5 11",
|
||||
"iPad17,3": "iPad Pro M5 13",
|
||||
"iPad17,4": "iPad Pro M5 13",
|
||||
"iPhone1,1": "iPhone",
|
||||
"iPhone1,2": "iPhone 3G",
|
||||
"iPhone2,1": "iPhone 3GS",
|
||||
@@ -101,20 +117,20 @@
|
||||
"iPhone5,4": "iPhone 5c",
|
||||
"iPhone6,1": "iPhone 5s",
|
||||
"iPhone6,2": "iPhone 5s",
|
||||
"iPhone7,2": "iPhone 6",
|
||||
"iPhone7,1": "iPhone 6 Plus",
|
||||
"iPhone7,2": "iPhone 6",
|
||||
"iPhone8,1": "iPhone 6s",
|
||||
"iPhone8,2": "iPhone 6s Plus",
|
||||
"iPhone8,4": "iPhone SE",
|
||||
"iPhone9,1": "iPhone 7",
|
||||
"iPhone9,3": "iPhone 7",
|
||||
"iPhone9,2": "iPhone 7 Plus",
|
||||
"iPhone9,3": "iPhone 7",
|
||||
"iPhone9,4": "iPhone 7 Plus",
|
||||
"iPhone10,1": "iPhone 8",
|
||||
"iPhone10,4": "iPhone 8",
|
||||
"iPhone10,2": "iPhone 8 Plus",
|
||||
"iPhone10,5": "iPhone 8 Plus",
|
||||
"iPhone10,3": "iPhone X",
|
||||
"iPhone10,4": "iPhone 8",
|
||||
"iPhone10,5": "iPhone 8 Plus",
|
||||
"iPhone10,6": "iPhone X",
|
||||
"iPhone11,2": "iPhone Xs",
|
||||
"iPhone11,6": "iPhone Xs Max",
|
||||
@@ -127,10 +143,10 @@
|
||||
"iPhone13,2": "iPhone 12",
|
||||
"iPhone13,3": "iPhone 12 Pro",
|
||||
"iPhone13,4": "iPhone 12 Pro Max",
|
||||
"iPhone14,4": "iPhone 13 mini",
|
||||
"iPhone14,5": "iPhone 13",
|
||||
"iPhone14,2": "iPhone 13 Pro",
|
||||
"iPhone14,3": "iPhone 13 Pro Max",
|
||||
"iPhone14,4": "iPhone 13 mini",
|
||||
"iPhone14,5": "iPhone 13",
|
||||
"iPhone14,6": "iPhone SE",
|
||||
"iPhone14,7": "iPhone 14",
|
||||
"iPhone14,8": "iPhone 14 Plus",
|
||||
@@ -140,6 +156,19 @@
|
||||
"iPhone15,5": "iPhone 15 Plus",
|
||||
"iPhone16,1": "iPhone 15 Pro",
|
||||
"iPhone16,2": "iPhone 15 Pro Max",
|
||||
"iPhone17,1": "iPhone 16 Pro",
|
||||
"iPhone17,2": "iPhone 16 Pro Max",
|
||||
"iPhone17,3": "iPhone 16",
|
||||
"iPhone17,4": "iPhone 16 Plus",
|
||||
"iPhone17,5": "iPhone 16e",
|
||||
"iPhone18,1": "iPhone 17 Pro",
|
||||
"iPhone18,2": "iPhone 17 Pro Max",
|
||||
"iPhone18,3": "iPhone 17",
|
||||
"iPhone18,4": "iPhone Air",
|
||||
"iPhone18,5": "iPhone 17e",
|
||||
"iPhone19,2": "iPhone 18 Pro",
|
||||
"iPhone19,3": "iPhone 18 Pro Max",
|
||||
"iPhone19,7": "iPhone 18 Pro Max",
|
||||
"iPod1,1": "iPod touch Original",
|
||||
"iPod2,1": "iPod touch 2nd",
|
||||
"iPod3,1": "iPod touch 3rd Gen",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -235,7 +235,7 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{ label: "Time", value: new Date().toUTCString() },
|
||||
{ label: "Time (UTC)", value: new Date().toUTCString() },
|
||||
...dataItems
|
||||
];
|
||||
|
||||
@@ -265,8 +265,8 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
||||
</EmailText>
|
||||
{isTestAlert && (
|
||||
<EmailText>
|
||||
This is a test alert. No action is required,
|
||||
and no real event has occurred.
|
||||
This is a test alert. No action is required, and
|
||||
no real event has occurred.
|
||||
</EmailText>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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!");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
|
||||
// Error codes for registration failures
|
||||
export const NewtErrorCodes = {
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
// Helper function to send registration error
|
||||
export async function sendNewtError(
|
||||
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
|
||||
newtId: string
|
||||
) {
|
||||
sendToClient(newtId, {
|
||||
type: "newt/error",
|
||||
data: {
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { fetchContainers } from "./dockerSocket";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { NewtErrorCodes, sendNewtError } from "./error";
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.error(
|
||||
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
||||
);
|
||||
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
|
||||
HOLEPUNCH_MISSING: {
|
||||
code: "HOLEPUNCH_MISSING",
|
||||
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
|
||||
},
|
||||
NO_AVAILABLE_SUBNET: {
|
||||
code: "NO_AVAILABLE_SUBNET",
|
||||
message:
|
||||
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||
{ orgId: client.orgId, clientId: client.clientId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,11 +122,20 @@ export default async function ResourceAuthPage(props: {
|
||||
|
||||
if (searchParams.redirect) {
|
||||
try {
|
||||
const redirectTarget = new URL(searchParams.redirect);
|
||||
const serverResourceHost = new URL(authInfo.url).host;
|
||||
const redirectHost = new URL(searchParams.redirect).host;
|
||||
const redirectPort = new URL(searchParams.redirect).port;
|
||||
const redirectHost = redirectTarget.host;
|
||||
const redirectPort = redirectTarget.port;
|
||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||
|
||||
// URL parses a host out of any scheme that uses "//", so a target
|
||||
// like javascript://resource-host/... matches the comparisons
|
||||
// below. The target is later assigned to window.location, so only
|
||||
// http(s) is accepted here.
|
||||
const isHttpTarget =
|
||||
redirectTarget.protocol === "http:" ||
|
||||
redirectTarget.protocol === "https:";
|
||||
|
||||
const wildcardMatchesRedirect = (
|
||||
wildcardDomain: string,
|
||||
host: string
|
||||
@@ -136,14 +145,16 @@ export default async function ResourceAuthPage(props: {
|
||||
return host.endsWith(suffix) && host.length > suffix.length;
|
||||
};
|
||||
|
||||
if (serverResourceHost === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
} else if (serverResourceHostWithPort === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
} else if (
|
||||
authInfo.wildcard &&
|
||||
authInfo.fullDomain &&
|
||||
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
|
||||
if (
|
||||
isHttpTarget &&
|
||||
(serverResourceHost === redirectHost ||
|
||||
serverResourceHostWithPort === redirectHost ||
|
||||
(authInfo.wildcard &&
|
||||
authInfo.fullDomain &&
|
||||
wildcardMatchesRedirect(
|
||||
authInfo.fullDomain,
|
||||
redirectHost
|
||||
)))
|
||||
) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
}
|
||||
@@ -283,7 +294,7 @@ export default async function ResourceAuthPage(props: {
|
||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
variant: idp.type
|
||||
variant: idp.variant ?? idp.type
|
||||
})) as LoginFormIDP[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user