mirror of
https://github.com/fosrl/pangolin.git
synced 2025-06-24 14:18:55 +02:00
rules server validation, enabled toggle, fix wildcard
This commit is contained in:
parent
f14ecf50e4
commit
fdf1dfdeba
13 changed files with 467 additions and 196 deletions
|
@ -377,6 +377,8 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP
|
action: text("action").notNull(), // ACCEPT, DROP
|
||||||
match: text("match").notNull(), // CIDR, PATH, IP
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
|
@ -414,4 +416,4 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
|
|
|
@ -8,3 +8,4 @@ export const subdomainSchema = z
|
||||||
)
|
)
|
||||||
.min(1, "Subdomain must be at least 1 character long")
|
.min(1, "Subdomain must be at least 1 character long")
|
||||||
.transform((val) => val.toLowerCase());
|
.transform((val) => val.toLowerCase());
|
||||||
|
|
68
server/lib/validators.ts
Normal file
68
server/lib/validators.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
export function isValidCIDR(cidr: string): boolean {
|
||||||
|
// Match CIDR pattern (e.g., "192.168.0.0/24")
|
||||||
|
const cidrPattern =
|
||||||
|
/^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
||||||
|
|
||||||
|
if (!cidrPattern.test(cidr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IP address part
|
||||||
|
const ipPart = cidr.split("/")[0];
|
||||||
|
const octets = ipPart.split(".");
|
||||||
|
|
||||||
|
return octets.every((octet) => {
|
||||||
|
const num = parseInt(octet, 10);
|
||||||
|
return num >= 0 && num <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIP(ip: string): boolean {
|
||||||
|
const ipPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
||||||
|
|
||||||
|
if (!ipPattern.test(ip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const octets = ip.split(".");
|
||||||
|
|
||||||
|
return octets.every((octet) => {
|
||||||
|
const num = parseInt(octet, 10);
|
||||||
|
return num >= 0 && num <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
|
// Remove leading slash if present
|
||||||
|
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||||
|
|
||||||
|
// Empty string is not valid
|
||||||
|
if (!pattern) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split path into segments
|
||||||
|
const segments = pattern.split("/");
|
||||||
|
|
||||||
|
// Check each segment
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
|
||||||
|
// Empty segments are not allowed (double slashes)
|
||||||
|
if (!segment && i !== segments.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If segment contains *, it must be exactly *
|
||||||
|
if (segment.includes("*") && segment !== "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters
|
||||||
|
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
|
@ -1,36 +1,38 @@
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import db from "@server/db";
|
|
||||||
import {
|
|
||||||
resourceRules,
|
|
||||||
ResourceAccessToken,
|
|
||||||
ResourcePassword,
|
|
||||||
resourcePassword,
|
|
||||||
ResourcePincode,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
sessions,
|
|
||||||
userOrgs,
|
|
||||||
users,
|
|
||||||
ResourceRule
|
|
||||||
} from "@server/db/schema";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import {
|
import {
|
||||||
createResourceSession,
|
createResourceSession,
|
||||||
serializeResourceSessionCookie,
|
serializeResourceSessionCookie,
|
||||||
validateResourceSessionToken
|
validateResourceSessionToken
|
||||||
} from "@server/auth/sessions/resource";
|
} from "@server/auth/sessions/resource";
|
||||||
import { Resource, roleResources, userResources } from "@server/db/schema";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import NodeCache from "node-cache";
|
import db from "@server/db";
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import {
|
||||||
|
Resource,
|
||||||
|
ResourceAccessToken,
|
||||||
|
ResourcePassword,
|
||||||
|
resourcePassword,
|
||||||
|
ResourcePincode,
|
||||||
|
resourcePincode,
|
||||||
|
ResourceRule,
|
||||||
|
resourceRules,
|
||||||
|
resources,
|
||||||
|
roleResources,
|
||||||
|
sessions,
|
||||||
|
userOrgs,
|
||||||
|
userResources,
|
||||||
|
users
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr } from "@server/lib/ip";
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
// We'll see if this speeds anything up
|
// We'll see if this speeds anything up
|
||||||
const cache = new NodeCache({
|
const cache = new NodeCache({
|
||||||
|
@ -169,18 +171,16 @@ export async function verifyResourceSession(
|
||||||
// otherwise its undefined and we pass
|
// otherwise its undefined and we pass
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||||
|
resource.resourceId
|
||||||
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
|
||||||
// check for access token
|
// check for access token
|
||||||
let validAccessToken: ResourceAccessToken | undefined;
|
let validAccessToken: ResourceAccessToken | undefined;
|
||||||
if (token) {
|
if (token) {
|
||||||
const [accessTokenId, accessToken] = token.split(".");
|
const [accessTokenId, accessToken] = token.split(".");
|
||||||
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
||||||
{
|
{ resource, accessTokenId, accessToken }
|
||||||
resource,
|
|
||||||
accessTokenId,
|
|
||||||
accessToken
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -190,7 +190,9 @@ export async function verifyResourceSession(
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Resource access token is invalid. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -211,7 +213,9 @@ export async function verifyResourceSession(
|
||||||
if (!sessions) {
|
if (!sessions) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Missing resource sessions. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
|
@ -219,7 +223,9 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
const resourceSessionToken =
|
const resourceSessionToken =
|
||||||
sessions[
|
sessions[
|
||||||
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
|
`${config.getRawConfig().server.session_cookie_name}${
|
||||||
|
resource.ssl ? "_s" : ""
|
||||||
|
}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resourceSessionToken) {
|
if (resourceSessionToken) {
|
||||||
|
@ -242,7 +248,9 @@ export async function verifyResourceSession(
|
||||||
);
|
);
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Resource session is an exchange token. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
|
@ -281,7 +289,9 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceSession.userSessionId && sso) {
|
if (resourceSession.userSessionId && sso) {
|
||||||
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
|
const userAccessCacheKey = `userAccess:${
|
||||||
|
resourceSession.userSessionId
|
||||||
|
}:${resource.resourceId}`;
|
||||||
|
|
||||||
let isAllowed: boolean | undefined =
|
let isAllowed: boolean | undefined =
|
||||||
cache.get(userAccessCacheKey);
|
cache.get(userAccessCacheKey);
|
||||||
|
@ -305,8 +315,8 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point we have checked all sessions, but since the access token is valid, we should allow access
|
// At this point we have checked all sessions, but since the access token is
|
||||||
// and create a new session.
|
// valid, we should allow access and create a new session.
|
||||||
if (validAccessToken) {
|
if (validAccessToken) {
|
||||||
return await createAccessTokenSession(
|
return await createAccessTokenSession(
|
||||||
res,
|
res,
|
||||||
|
@ -319,7 +329,9 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Resource access not allowed. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return notAllowed(res, redirectUrl);
|
return notAllowed(res, redirectUrl);
|
||||||
|
@ -485,69 +497,123 @@ async function checkRules(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasAcceptRule = false;
|
// sort rules by priority in ascending order
|
||||||
|
rules = rules.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
// First pass: look for DROP rules
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
|
if (!rule.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(clientIp &&
|
clientIp &&
|
||||||
rule.match == "CIDR" &&
|
rule.match == "CIDR" &&
|
||||||
isIpInCidr(clientIp, rule.value) &&
|
isIpInCidr(clientIp, rule.value)
|
||||||
rule.action === "DROP") ||
|
|
||||||
(clientIp &&
|
|
||||||
rule.match == "IP" &&
|
|
||||||
clientIp == rule.value &&
|
|
||||||
rule.action === "DROP") ||
|
|
||||||
(path &&
|
|
||||||
rule.match == "PATH" &&
|
|
||||||
urlGlobToRegex(rule.value).test(path) &&
|
|
||||||
rule.action === "DROP")
|
|
||||||
) {
|
) {
|
||||||
return "DROP";
|
return rule.action as any;
|
||||||
}
|
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||||
// Track if we see any ACCEPT rules for the second pass
|
return rule.action as any;
|
||||||
if (rule.action === "ACCEPT") {
|
} else if (
|
||||||
hasAcceptRule = true;
|
path &&
|
||||||
}
|
rule.match == "PATH" &&
|
||||||
}
|
isPathAllowed(rule.value, path)
|
||||||
|
) {
|
||||||
// Second pass: only check ACCEPT rules if we found one and didn't find a DROP
|
return rule.action as any;
|
||||||
if (hasAcceptRule) {
|
|
||||||
for (const rule of rules) {
|
|
||||||
if (rule.action !== "ACCEPT") continue;
|
|
||||||
|
|
||||||
if (
|
|
||||||
(clientIp &&
|
|
||||||
rule.match == "CIDR" &&
|
|
||||||
isIpInCidr(clientIp, rule.value)) ||
|
|
||||||
(clientIp &&
|
|
||||||
rule.match == "IP" &&
|
|
||||||
clientIp == rule.value) ||
|
|
||||||
(path &&
|
|
||||||
rule.match == "PATH" &&
|
|
||||||
urlGlobToRegex(rule.value).test(path))
|
|
||||||
) {
|
|
||||||
return "ACCEPT";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlGlobToRegex(pattern: string): RegExp {
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
// Trim any leading or trailing slashes
|
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||||
pattern = pattern.replace(/^\/+|\/+$/g, "");
|
|
||||||
|
|
||||||
// Escape special regex characters except *
|
// Normalize and split paths into segments
|
||||||
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
// Replace * with regex pattern for any valid URL segment characters
|
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||||
const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+");
|
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
// Create the final pattern that:
|
// Recursive function to try different wildcard matches
|
||||||
// 1. Optionally matches leading slash
|
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||||
// 2. Matches the pattern
|
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||||
// 3. Optionally matches trailing slash
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
return new RegExp(`^/?${regexPattern}/?$`);
|
const currentPathPart = pathParts[pathIndex];
|
||||||
}
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||||
|
if (patternIndex >= patternParts.length) {
|
||||||
|
const result = pathIndex >= pathParts.length;
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've consumed all path parts but still have pattern parts
|
||||||
|
if (pathIndex >= pathParts.length) {
|
||||||
|
// The only way this can match is if all remaining pattern parts are wildcards
|
||||||
|
const remainingPattern = patternParts.slice(patternIndex);
|
||||||
|
const result = remainingPattern.every((p) => p === "*");
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For wildcards, try consuming different numbers of path segments
|
||||||
|
if (currentPatternPart === "*") {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try consuming 0 segments (skip the wildcard)
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to skip wildcard (consume 0 segments)`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by skipping wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try consuming current segment and recursively try rest
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by consuming segment for wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`${indent}Failed to match wildcard`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular segments, they must match exactly
|
||||||
|
if (currentPatternPart !== currentPathPart) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
// Move to next segments in both pattern and path
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matchSegments(0, 0);
|
||||||
|
logger.debug(`Final result: ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { eq, and } from "drizzle-orm";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const createResourceParamsSchema = z
|
const createResourceParamsSchema = z
|
||||||
|
|
|
@ -8,12 +8,19 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
const createResourceRuleSchema = z
|
const createResourceRuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["ACCEPT", "DROP"]),
|
action: z.enum(["ACCEPT", "DROP"]),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]),
|
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||||
value: z.string().min(1)
|
value: z.string().min(1),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -42,7 +49,7 @@ export async function createResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action, match, value } = parsedBody.data;
|
const { action, match, value, priority, enabled } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
|
@ -74,6 +81,41 @@ export async function createResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resource.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot create rule for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match === "CIDR") {
|
||||||
|
if (!isValidCIDR(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid CIDR provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "IP") {
|
||||||
|
if (!isValidIP(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "PATH") {
|
||||||
|
if (!isValidUrlGlobPattern(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid URL glob pattern provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the new resource rule
|
// Create the new resource rule
|
||||||
const [newRule] = await db
|
const [newRule] = await db
|
||||||
.insert(resourceRules)
|
.insert(resourceRules)
|
||||||
|
@ -81,7 +123,9 @@ export async function createResourceRule(
|
||||||
resourceId,
|
resourceId,
|
||||||
action,
|
action,
|
||||||
match,
|
match,
|
||||||
value
|
value,
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
@ -40,12 +40,14 @@ function queryResourceRules(resourceId: number) {
|
||||||
resourceId: resourceRules.resourceId,
|
resourceId: resourceRules.resourceId,
|
||||||
action: resourceRules.action,
|
action: resourceRules.action,
|
||||||
match: resourceRules.match,
|
match: resourceRules.match,
|
||||||
value: resourceRules.value
|
value: resourceRules.value,
|
||||||
|
priority: resourceRules.priority,
|
||||||
|
enabled: resourceRules.enabled
|
||||||
})
|
})
|
||||||
.from(resourceRules)
|
.from(resourceRules)
|
||||||
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
return baseQuery;
|
return baseQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +73,9 @@ export async function listResourceRules(
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourceRulesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourceRulesParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -99,16 +103,19 @@ export async function listResourceRules(
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourceRules(resourceId);
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
|
||||||
let countQuery = db
|
let countQuery = db
|
||||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
.from(resourceRules)
|
.from(resourceRules)
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
const rulesList = await baseQuery.limit(limit).offset(offset);
|
let rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
const totalCountResult = await countQuery;
|
const totalCountResult = await countQuery;
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
// sort rules list by the priority in ascending order
|
||||||
|
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
return response<ListResourceRulesResponse>(res, {
|
return response<ListResourceRulesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
rules: rulesList,
|
rules: rulesList,
|
||||||
|
@ -129,4 +136,4 @@ export async function listResourceRules(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,8 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
|
|
||||||
const updateResourceParamsSchema = z
|
const updateResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
@ -8,14 +8,16 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
// Define Zod schema for request parameters validation
|
// Define Zod schema for request parameters validation
|
||||||
const updateResourceRuleParamsSchema = z
|
const updateResourceRuleParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
ruleId: z
|
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
.string()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive()),
|
|
||||||
resourceId: z
|
resourceId: z
|
||||||
.string()
|
.string()
|
||||||
.transform(Number)
|
.transform(Number)
|
||||||
|
@ -28,7 +30,9 @@ const updateResourceRuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||||
value: z.string().min(1).optional()
|
value: z.string().min(1).optional(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -42,7 +46,9 @@ export async function updateResourceRule(
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Validate path parameters
|
// Validate path parameters
|
||||||
const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params);
|
const parsedParams = updateResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -82,6 +88,15 @@ export async function updateResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resource.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot create rule for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Verify that the rule exists and belongs to the specified resource
|
// Verify that the rule exists and belongs to the specified resource
|
||||||
const [existingRule] = await db
|
const [existingRule] = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -107,6 +122,40 @@ export async function updateResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const match = updateData.match || existingRule.match;
|
||||||
|
const { value } = updateData;
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (match === "CIDR") {
|
||||||
|
if (!isValidCIDR(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid CIDR provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "IP") {
|
||||||
|
if (!isValidIP(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid IP provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "PATH") {
|
||||||
|
if (!isValidUrlGlobPattern(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid URL glob pattern provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the rule
|
// Update the rule
|
||||||
const [updatedRule] = await db
|
const [updatedRule] = await db
|
||||||
.update(resourceRules)
|
.update(resourceRules)
|
||||||
|
@ -127,4 +176,4 @@ export async function updateResourceRule(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,8 @@ export default async function migration() {
|
||||||
trx.run(sql`CREATE TABLE resourceRules (
|
trx.run(sql`CREATE TABLE resourceRules (
|
||||||
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
resourceId integer NOT NULL,
|
resourceId integer NOT NULL,
|
||||||
|
priority integer NOT NULL,
|
||||||
|
enabled integer DEFAULT true NOT NULL,
|
||||||
action text NOT NULL,
|
action text NOT NULL,
|
||||||
match text NOT NULL,
|
match text NOT NULL,
|
||||||
value text NOT NULL,
|
value text NOT NULL,
|
||||||
|
|
|
@ -59,7 +59,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SquareArrowOutUpRight } from "lucide-react";
|
import { SquareArrowOutUpRight } from "lucide-react";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
|
|
@ -49,9 +49,8 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import CustomDomainInput from "../CustomDomainInput";
|
import CustomDomainInput from "../CustomDomainInput";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
|
|
||||||
|
|
|
@ -57,7 +57,7 @@ import {
|
||||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { Check, InfoIcon, X } from "lucide-react";
|
import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSections,
|
InfoSections,
|
||||||
|
@ -65,12 +65,19 @@ import {
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
|
||||||
// Schema for rule validation
|
// Schema for rule validation
|
||||||
const addRuleSchema = z.object({
|
const addRuleSchema = z.object({
|
||||||
action: z.string(),
|
action: z.string(),
|
||||||
match: z.string(),
|
match: z.string(),
|
||||||
value: z.string()
|
value: z.string(),
|
||||||
|
priority: z.coerce.number().int().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||||
|
@ -181,11 +188,23 @@ export default function ResourceRules(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// find the highest priority and add one
|
||||||
|
let priority = data.priority;
|
||||||
|
if (priority === undefined) {
|
||||||
|
priority = rules.reduce(
|
||||||
|
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
priority++;
|
||||||
|
}
|
||||||
|
|
||||||
const newRule: LocalRule = {
|
const newRule: LocalRule = {
|
||||||
...data,
|
...data,
|
||||||
ruleId: new Date().getTime(),
|
ruleId: new Date().getTime(),
|
||||||
new: true,
|
new: true,
|
||||||
resourceId: resource.resourceId
|
resourceId: resource.resourceId,
|
||||||
|
priority,
|
||||||
|
enabled: true
|
||||||
};
|
};
|
||||||
|
|
||||||
setRules([...rules, newRule]);
|
setRules([...rules, newRule]);
|
||||||
|
@ -255,7 +274,9 @@ export default function ResourceRules(props: {
|
||||||
const data = {
|
const data = {
|
||||||
action: rule.action,
|
action: rule.action,
|
||||||
match: rule.match,
|
match: rule.match,
|
||||||
value: rule.value
|
value: rule.value,
|
||||||
|
priority: rule.priority,
|
||||||
|
enabled: rule.enabled
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||||
|
@ -289,6 +310,28 @@ export default function ResourceRules(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rule.priority === undefined) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid Priority",
|
||||||
|
description: "Please enter a valid priority"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure no duplicate priorities
|
||||||
|
const priorities = rules.map((r) => r.priority);
|
||||||
|
if (priorities.length !== new Set(priorities).size) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Duplicate Priorities",
|
||||||
|
description: "Please enter unique priorities"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (rule.new) {
|
if (rule.new) {
|
||||||
const res = await api.put(
|
const res = await api.put(
|
||||||
`/resource/${params.resourceId}/rule`,
|
`/resource/${params.resourceId}/rule`,
|
||||||
|
@ -342,6 +385,50 @@ export default function ResourceRules(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalRule>[] = [
|
const columns: ColumnDef<LocalRule>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Priority
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Input
|
||||||
|
defaultValue={row.original.priority}
|
||||||
|
className="w-[75px]"
|
||||||
|
type="number"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const parsed = z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.safeParse(e.target.value);
|
||||||
|
|
||||||
|
if (!parsed.data) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid IP",
|
||||||
|
description: "Please enter a valid priority"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRule(row.original.ruleId, {
|
||||||
|
priority: parsed.data
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: "Action",
|
header: "Action",
|
||||||
|
@ -400,6 +487,18 @@ export default function ResourceRules(props: {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: "Enabled",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.original.enabled}
|
||||||
|
onCheckedChange={(val) =>
|
||||||
|
updateRule(row.original.ruleId, { enabled: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
@ -434,14 +533,14 @@ export default function ResourceRules(props: {
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<p className="mb-4">
|
<div className="space-y-1 mb-4">
|
||||||
Rules allow you to control access to your resource based
|
<p>
|
||||||
on a set of criteria. You can create rules to allow or
|
Rules allow you to control access to your resource
|
||||||
deny access based on IP address or URL path. Deny rules
|
based on a set of criteria. You can create rules to
|
||||||
take precedence over allow rules. If a request matches
|
allow or deny access based on IP address or URL
|
||||||
both an allow and a deny rule, the deny rule will be
|
path.
|
||||||
applied.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<InfoSections>
|
<InfoSections>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Actions</InfoSectionTitle>
|
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||||
|
@ -661,6 +760,9 @@ export default function ResourceRules(props: {
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Rules are evaluated by priority in ascending order.
|
||||||
|
</p>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
@ -675,72 +777,3 @@ export default function ResourceRules(props: {
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidCIDR(cidr: string): boolean {
|
|
||||||
// Match CIDR pattern (e.g., "192.168.0.0/24")
|
|
||||||
const cidrPattern =
|
|
||||||
/^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
|
||||||
|
|
||||||
if (!cidrPattern.test(cidr)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate IP address part
|
|
||||||
const ipPart = cidr.split("/")[0];
|
|
||||||
const octets = ipPart.split(".");
|
|
||||||
|
|
||||||
return octets.every((octet) => {
|
|
||||||
const num = parseInt(octet, 10);
|
|
||||||
return num >= 0 && num <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidIP(ip: string): boolean {
|
|
||||||
const ipPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
|
||||||
|
|
||||||
if (!ipPattern.test(ip)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const octets = ip.split(".");
|
|
||||||
|
|
||||||
return octets.every((octet) => {
|
|
||||||
const num = parseInt(octet, 10);
|
|
||||||
return num >= 0 && num <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidUrlGlobPattern(pattern: string): boolean {
|
|
||||||
// Remove leading slash if present
|
|
||||||
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
|
||||||
|
|
||||||
// Empty string is not valid
|
|
||||||
if (!pattern) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split path into segments
|
|
||||||
const segments = pattern.split("/");
|
|
||||||
|
|
||||||
// Check each segment
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const segment = segments[i];
|
|
||||||
|
|
||||||
// Empty segments are not allowed (double slashes)
|
|
||||||
if (!segment && i !== segments.length - 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If segment contains *, it must be exactly *
|
|
||||||
if (segment.includes("*") && segment !== "*") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid characters
|
|
||||||
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue