From befdc3a002a91cbea65915a0f4a98cca54588546 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Feb 2025 21:18:34 -0500 Subject: [PATCH 01/18] Add table --- server/db/schema.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/db/schema.ts b/server/db/schema.ts index f44873d1..0e2a2f70 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -371,6 +371,16 @@ export const versionMigrations = sqliteTable("versionMigrations", { executedAt: integer("executedAt").notNull() }); +export const badgerRules = sqliteTable("badgerRules", { + ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + action: text("action").notNull(), // ACCEPT, DROP + match: text("match").notNull(), // CIDR, PATH + value: text("value").notNull() +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; From b92639647ae545b507fb9f92588377a5cd27c3f9 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Feb 2025 21:19:55 -0500 Subject: [PATCH 02/18] Add applyRules to resources --- server/db/schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/db/schema.ts b/server/db/schema.ts index 0e2a2f70..2ae27497 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -54,7 +54,8 @@ export const resources = sqliteTable("resources", { emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() .default(false), - isBaseDomain: integer("isBaseDomain", { mode: "boolean" }) + isBaseDomain: integer("isBaseDomain", { mode: "boolean" }), + applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false) }); export const targets = sqliteTable("targets", { From 2f49be69fe137d7ca270f1e0e1aec95f4070b810 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 6 Feb 2025 21:42:18 -0500 Subject: [PATCH 03/18] Initial pass at rules --- server/db/schema.ts | 1 + server/routers/badger/verifySession.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/server/db/schema.ts b/server/db/schema.ts index 2ae27497..7c8c2739 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -414,3 +414,4 @@ export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; +export type BadgerRule = InferSelectModel; \ No newline at end of file diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 5830d805..a40cfd21 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -6,6 +6,8 @@ import { fromError } from "zod-validation-error"; import { response } from "@server/lib/response"; import db from "@server/db"; import { + BadgerRule, + badgerRules, ResourceAccessToken, ResourcePassword, resourcePassword, @@ -28,6 +30,7 @@ import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import NodeCache from "node-cache"; import { generateSessionToken } from "@server/auth/sessions/app"; +import { isIpInCidr } from "@server/lib/ip"; // We'll see if this speeds anything up const cache = new NodeCache({ @@ -79,6 +82,7 @@ export async function verifyResourceSession( host, originalRequestURL, requestIp, + path, accessToken: token } = parsedBody.data; @@ -146,6 +150,15 @@ export async function verifyResourceSession( return allowed(res); } + // check the rules + if ( + resource.applyRules && + (await checkRules(resource.resourceId, clientIp, path)) + ) { + logger.debug("Resource allowed because rules are satisfied"); + return allowed(res); + } + const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; // check for access token @@ -438,3 +451,41 @@ async function isUserAllowedToAccessResource( return false; } + +async function checkRules( + resourceId: number, + clientIp: string | undefined, + path: string | undefined +): Promise { + const ruleCacheKey = `rules:${resourceId}`; + + let rules: BadgerRule[] | undefined = cache.get(ruleCacheKey); + + if (!rules) { + rules = await db + .select() + .from(badgerRules) + .where(eq(badgerRules.resourceId, resourceId)); + + cache.set(ruleCacheKey, rules); + } + + if (rules.length === 0) { + logger.debug("No rules found for resource", resourceId); + return false; + } + + for (const rule of rules) { + if (clientIp && rule.match == "IP" && isIpInCidr(clientIp, rule.value)) { + return rule.action == "ACCEPT"; + } else if (path && rule.match == "PATH") { + // rule.value is a regex, match on the path and see if it matches + const re = new RegExp(rule.value); + if (re.test(path)) { + return rule.action == "ACCEPT"; + } + } + } + + return false; +} From da3c8823f8404d57e7c488931a8e44972d97b28b Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Feb 2025 17:02:22 -0500 Subject: [PATCH 04/18] rename to resource rules and add api endpoints --- server/auth/actions.ts | 17 ++- server/db/schema.ts | 4 +- server/routers/badger/verifySession.ts | 12 +- server/routers/external.ts | 21 +++ server/routers/resource/createResourceRule.ts | 79 +++++++++++ server/routers/resource/deleteResourceRule.ts | 67 +++++++++ server/routers/resource/index.ts | 3 + server/routers/resource/listResourceRules.ts | 133 ++++++++++++++++++ 8 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 server/routers/resource/createResourceRule.ts create mode 100644 server/routers/resource/deleteResourceRule.ts create mode 100644 server/routers/resource/listResourceRules.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 8bc7b823..7c0f5d28 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -51,13 +51,16 @@ export enum ActionsEnum { // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", - "setResourcePassword" = "setResourcePassword", - "setResourcePincode" = "setResourcePincode", - "setResourceWhitelist" = "setResourceWhitelist", - "getResourceWhitelist" = "getResourceWhitelist", - "generateAccessToken" = "generateAccessToken", - "deleteAcessToken" = "deleteAcessToken", - "listAccessTokens" = "listAccessTokens" + setResourcePassword = "setResourcePassword", + setResourcePincode = "setResourcePincode", + setResourceWhitelist = "setResourceWhitelist", + getResourceWhitelist = "getResourceWhitelist", + generateAccessToken = "generateAccessToken", + deleteAcessToken = "deleteAcessToken", + listAccessTokens = "listAccessTokens", + createResourceRule = "createResourceRule", + deleteResourceRule = "deleteResourceRule", + listResourceRules = "listResourceRules" } export async function checkUserActionPermission( diff --git a/server/db/schema.ts b/server/db/schema.ts index 7c8c2739..cf5ad531 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -372,7 +372,7 @@ export const versionMigrations = sqliteTable("versionMigrations", { executedAt: integer("executedAt").notNull() }); -export const badgerRules = sqliteTable("badgerRules", { +export const resourceRules = sqliteTable("resourceRules", { ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() @@ -414,4 +414,4 @@ export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; -export type BadgerRule = InferSelectModel; \ No newline at end of file +export type ResourceRule = InferSelectModel; \ No newline at end of file diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a40cfd21..12058825 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -6,8 +6,7 @@ import { fromError } from "zod-validation-error"; import { response } from "@server/lib/response"; import db from "@server/db"; import { - BadgerRule, - badgerRules, + resourceRules, ResourceAccessToken, ResourcePassword, resourcePassword, @@ -16,7 +15,8 @@ import { resources, sessions, userOrgs, - users + users, + ResourceRule } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import config from "@server/lib/config"; @@ -459,13 +459,13 @@ async function checkRules( ): Promise { const ruleCacheKey = `rules:${resourceId}`; - let rules: BadgerRule[] | undefined = cache.get(ruleCacheKey); + let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); if (!rules) { rules = await db .select() - .from(badgerRules) - .where(eq(badgerRules.resourceId, resourceId)); + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); cache.set(ruleCacheKey, rules); } diff --git a/server/routers/external.ts b/server/routers/external.ts index e74e0472..73e32911 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -186,6 +186,26 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listTargets), target.listTargets ); + +authenticated.put( + "/resource/:resourceId/:ruleId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.createResourceRule), + resource.createResourceRule +); +authenticated.get( + "/resource/:resourceId/rules", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceRules), + resource.listResourceRules +); +authenticated.delete( + "/resource/:resourceId/:ruleId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule +); + authenticated.get( "/target/:targetId", verifyTargetAccess, @@ -205,6 +225,7 @@ authenticated.delete( target.deleteTarget ); + authenticated.put( "/org/:orgId/role", verifyOrgAccess, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts new file mode 100644 index 00000000..68578da2 --- /dev/null +++ b/server/routers/resource/createResourceRule.ts @@ -0,0 +1,79 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const createResourceRuleSchema = z + .object({ + resourceId: z.number().int().positive(), + action: z.enum(["ACCEPT", "DROP"]), + match: z.enum(["CIDR", "PATH"]), + value: z.string().min(1) + }) + .strict(); + +export async function createResourceRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = createResourceRuleSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId, action, match, value } = parsedBody.data; + + // Verify that the referenced resource exists + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + // Create the new resource rule + const [newRule] = await db + .insert(resourceRules) + .values({ + resourceId, + action, + match, + value + }) + .returning(); + + return response(res, { + data: newRule, + success: true, + error: false, + message: "Resource rule created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts new file mode 100644 index 00000000..9c19ff04 --- /dev/null +++ b/server/routers/resource/deleteResourceRule.ts @@ -0,0 +1,67 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const deleteResourceRuleSchema = z + .object({ + ruleId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +export async function deleteResourceRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = deleteResourceRuleSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { ruleId } = parsedParams.data; + + // Delete the rule and return the deleted record + const [deletedRule] = await db + .delete(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .returning(); + + if (!deletedRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Resource rule deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 187d23fe..b3e8016f 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -18,3 +18,6 @@ export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; export * from "./transferResource"; export * from "./getExchangeToken"; +export * from "./createResourceRule"; +export * from "./deleteResourceRule"; +export * from "./listResourceRules"; \ No newline at end of file diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts new file mode 100644 index 00000000..02c774a6 --- /dev/null +++ b/server/routers/resource/listResourceRules.ts @@ -0,0 +1,133 @@ +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { eq, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const listResourceRulesParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +const listResourceRulesSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryResourceRules(resourceId: number) { + let baseQuery = db + .select({ + ruleId: resourceRules.ruleId, + resourceId: resourceRules.resourceId, + action: resourceRules.action, + match: resourceRules.match, + value: resourceRules.value, + resourceName: resources.name, + }) + .from(resourceRules) + .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) + .where(eq(resourceRules.resourceId, resourceId)); + + return baseQuery; +} + +export type ListResourceRulesResponse = { + rules: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listResourceRules( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listResourceRulesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listResourceRulesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { resourceId } = parsedParams.data; + + // Verify the resource exists + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const baseQuery = queryResourceRules(resourceId); + + let countQuery = db + .select({ count: sql`cast(count(*) as integer)` }) + .from(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)); + + const rulesList = await baseQuery.limit(limit).offset(offset); + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + return response(res, { + data: { + rules: rulesList, + pagination: { + total: totalCount, + limit, + offset + } + }, + success: true, + error: false, + message: "Resource rules retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file From 8f96d0795c4edb0302bcb66da4e3dcc4616e17ad Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Feb 2025 17:10:37 -0500 Subject: [PATCH 05/18] Add update --- server/auth/actions.ts | 3 +- server/routers/external.ts | 7 + server/routers/resource/index.ts | 3 +- server/routers/resource/updateResourceRule.ts | 130 ++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 server/routers/resource/updateResourceRule.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 7c0f5d28..001b9a6c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -60,7 +60,8 @@ export enum ActionsEnum { listAccessTokens = "listAccessTokens", createResourceRule = "createResourceRule", deleteResourceRule = "deleteResourceRule", - listResourceRules = "listResourceRules" + listResourceRules = "listResourceRules", + updateResourceRule = "updateResourceRule", } export async function checkUserActionPermission( diff --git a/server/routers/external.ts b/server/routers/external.ts index 73e32911..d7934f9e 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -199,6 +199,13 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listResourceRules), resource.listResourceRules ); +authenticated.post( + "/resource/:resourceId/:ruleId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResourceRule), + resource.updateResourceRule +); + authenticated.delete( "/resource/:resourceId/:ruleId", verifyResourceAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index b3e8016f..03c9ffbe 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -20,4 +20,5 @@ export * from "./transferResource"; export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; -export * from "./listResourceRules"; \ No newline at end of file +export * from "./listResourceRules"; +export * from "./updateResourceRule"; \ No newline at end of file diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts new file mode 100644 index 00000000..f96ea0fa --- /dev/null +++ b/server/routers/resource/updateResourceRule.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourceRules, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request parameters validation +const updateResourceRuleParamsSchema = z + .object({ + ruleId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()), + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +// Define Zod schema for request body validation +const updateResourceRuleSchema = z + .object({ + action: z.enum(["ACCEPT", "DROP"]).optional(), + match: z.enum(["CIDR", "PATH"]).optional(), + value: z.string().min(1).optional() + }) + .strict() + .refine((data) => Object.keys(data).length > 0, { + message: "At least one field must be provided for update" + }); + +export async function updateResourceRule( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate path parameters + const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + // Validate request body + const parsedBody = updateResourceRuleSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { ruleId, resourceId } = parsedParams.data; + const updateData = parsedBody.data; + + // Verify that the resource exists + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + // Verify that the rule exists and belongs to the specified resource + const [existingRule] = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .limit(1); + + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + // Update the rule + const [updatedRule] = await db + .update(resourceRules) + .set(updateData) + .where(eq(resourceRules.ruleId, ruleId)) + .returning(); + + return response(res, { + data: updatedRule, + success: true, + error: false, + message: "Resource rule updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file From 4a6da91fafd10c9d02934b473ae3cd8cbf73b112 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Feb 2025 17:38:30 -0500 Subject: [PATCH 06/18] API and rule screen working --- server/routers/external.ts | 7 +- server/routers/resource/createResourceRule.ts | 28 +- server/routers/resource/deleteResourceRule.ts | 4 + server/routers/resource/listResourceRules.ts | 3 +- .../resources/[resourceId]/layout.tsx | 5 + .../resources/[resourceId]/rules/page.tsx | 452 ++++++++++++++++++ 6 files changed, 490 insertions(+), 9 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx diff --git a/server/routers/external.ts b/server/routers/external.ts index d7934f9e..19c57008 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -188,7 +188,7 @@ authenticated.get( ); authenticated.put( - "/resource/:resourceId/:ruleId", + "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), resource.createResourceRule @@ -200,14 +200,13 @@ authenticated.get( resource.listResourceRules ); authenticated.post( - "/resource/:resourceId/:ruleId", + "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), resource.updateResourceRule ); - authenticated.delete( - "/resource/:resourceId/:ruleId", + "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), resource.deleteResourceRule diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 68578da2..f01ed115 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -11,13 +11,21 @@ import { fromError } from "zod-validation-error"; const createResourceRuleSchema = z .object({ - resourceId: z.number().int().positive(), action: z.enum(["ACCEPT", "DROP"]), match: z.enum(["CIDR", "PATH"]), value: z.string().min(1) }) .strict(); +const createResourceRuleParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + export async function createResourceRule( req: Request, res: Response, @@ -34,7 +42,21 @@ export async function createResourceRule( ); } - const { resourceId, action, match, value } = parsedBody.data; + const { action, match, value } = parsedBody.data; + + const parsedParams = createResourceRuleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; // Verify that the referenced resource exists const [resource] = await db @@ -76,4 +98,4 @@ export async function createResourceRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index 9c19ff04..b562fc11 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -12,6 +12,10 @@ import { fromError } from "zod-validation-error"; const deleteResourceRuleSchema = z .object({ ruleId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()), + resourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 02c774a6..3364aa4b 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -40,8 +40,7 @@ function queryResourceRules(resourceId: number) { resourceId: resourceRules.resourceId, action: resourceRules.action, match: resourceRules.match, - value: resourceRules.value, - resourceName: resources.name, + value: resourceRules.value }) .from(resourceRules) .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index 5506866e..51b147fe 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -99,6 +99,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/{resourceId}/authentication` // icon: , }); + sidebarNavItems.push({ + title: "Rules", + href: `/{orgId}/settings/resources/{resourceId}/rules` + // icon: , + }); } return ( diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx new file mode 100644 index 00000000..2398bf32 --- /dev/null +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -0,0 +1,452 @@ +"use client"; +import { useEffect, useState, use } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { AxiosResponse } from "axios"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + ColumnDef, + getFilteredRowModel, + getSortedRowModel, + getPaginationRowModel, + getCoreRowModel, + useReactTable, + flexRender +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { useToast } from "@app/hooks/useToast"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { ArrayElement } from "@server/types/ArrayElement"; +import { formatAxiosError } from "@app/lib/api/formatAxiosError"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionFooter +} from "@app/components/Settings"; +import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; + +// Schema for rule validation +const addRuleSchema = z.object({ + action: z.string(), + match: z.string(), + value: z.string() +}); + +type LocalRule = ArrayElement & { + new?: boolean; + updated?: boolean; +}; + +export default function ResourceRules(props: { + params: Promise<{ resourceId: number }>; +}) { + const params = use(props.params); + const { toast } = useToast(); + const { resource } = useResourceContext(); + const api = createApiClient(useEnvContext()); + const [rules, setRules] = useState([]); + const [rulesToRemove, setRulesToRemove] = useState([]); + const [loading, setLoading] = useState(false); + const [pageLoading, setPageLoading] = useState(true); + + const addRuleForm = useForm({ + resolver: zodResolver(addRuleSchema), + defaultValues: { + action: "ACCEPT", + match: "CIDR", + value: "" + } + }); + + useEffect(() => { + const fetchRules = async () => { + try { + const res = await api.get>( + `/resource/${params.resourceId}/rules` + ); + if (res.status === 200) { + setRules(res.data.data.rules); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to fetch rules", + description: formatAxiosError( + err, + "An error occurred while fetching rules" + ) + }); + } finally { + setPageLoading(false); + } + }; + fetchRules(); + }, []); + + async function addRule(data: z.infer) { + const isDuplicate = rules.some( + (rule) => + rule.action === data.action && + rule.match === data.match && + rule.value === data.value + ); + + if (isDuplicate) { + toast({ + variant: "destructive", + title: "Duplicate rule", + description: "A rule with these settings already exists" + }); + return; + } + + const newRule: LocalRule = { + ...data, + ruleId: new Date().getTime(), + new: true, + resourceId: resource.resourceId + }; + + setRules([...rules, newRule]); + addRuleForm.reset(); + } + + const removeRule = (ruleId: number) => { + setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]); + if (!rules.find((rule) => rule.ruleId === ruleId)?.new) { + setRulesToRemove([...rulesToRemove, ruleId]); + } + }; + + async function updateRule(ruleId: number, data: Partial) { + setRules( + rules.map((rule) => + rule.ruleId === ruleId + ? { ...rule, ...data, updated: true } + : rule + ) + ); + } + + async function saveRules() { + try { + setLoading(true); + for (let rule of rules) { + const data = { + action: rule.action, + match: rule.match, + value: rule.value + }; + + if (rule.new) { + await api.put( + `/resource/${params.resourceId}/rule`, + data + ); + } else if (rule.updated) { + await api.post( + `/resource/${params.resourceId}/rule/${rule.ruleId}`, + data + ); + } + } + + for (const ruleId of rulesToRemove) { + await api.delete( + `/resource/${params.resourceId}/rule/${ruleId}` + ); + } + + setRules(rules.map(rule => ({ ...rule, new: false, updated: false }))); + setRulesToRemove([]); + + toast({ + title: "Rules updated", + description: "Rules updated successfully" + }); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Operation failed", + description: formatAxiosError( + err, + "An error occurred during the save operation" + ) + }); + } + setLoading(false); + } + + const columns: ColumnDef[] = [ + { + accessorKey: "action", + header: "Action", + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "match", + header: "Match Type", + cell: ({ row }) => ( + + ) + }, + { + accessorKey: "value", + header: "Value", + cell: ({ row }) => ( + + updateRule(row.original.ruleId, { + value: e.target.value + }) + } + /> + ) + }, + { + id: "actions", + cell: ({ row }) => ( +
+ +
+ ) + } + ]; + + const table = useReactTable({ + data: rules, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel() + }); + + if (pageLoading) { + return <>; + } + + return ( + + + + + Resource Rules Configuration + + + Configure rules to control access to your resource + + + +
+ +
+ ( + + Action + + + + + + )} + /> + ( + + Match Type + + + + + + )} + /> + ( + + Value + + + + + + Enter CIDR or path value based on match type + + + )} + /> +
+ +
+ + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No rules. Add a rule using the form. + + + )} + +
+
+
+ + + +
+
+ ); +} \ No newline at end of file From 42434ca8323a63c9ad80ab9432aa000bcba1eb22 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Feb 2025 17:54:01 -0500 Subject: [PATCH 07/18] Add validation --- server/routers/badger/verifySession.ts | 18 ++- .../resources/[resourceId]/rules/page.tsx | 142 +++++++++++++++--- 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 12058825..39415023 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -480,7 +480,7 @@ async function checkRules( return rule.action == "ACCEPT"; } else if (path && rule.match == "PATH") { // rule.value is a regex, match on the path and see if it matches - const re = new RegExp(rule.value); + const re = urlGlobToRegex(rule.value); if (re.test(path)) { return rule.action == "ACCEPT"; } @@ -489,3 +489,19 @@ async function checkRules( return false; } + +function urlGlobToRegex(pattern: string): RegExp { + // Remove leading slash if present (we'll add it to the regex pattern) + pattern = pattern.startsWith('/') ? pattern.slice(1) : pattern; + + // Escape special regex characters except * + const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + + // Replace * with regex pattern for any valid URL segment characters + const regexPattern = escapedPattern.replace(/\*/g, '[a-zA-Z0-9_-]+'); + + // Create the final pattern that: + // 1. Optionally matches leading slash + // 2. Matches the entire string + return new RegExp(`^/?${regexPattern}$`); + } \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 2398bf32..d74ba6e9 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -93,9 +93,9 @@ export default function ResourceRules(props: { useEffect(() => { const fetchRules = async () => { try { - const res = await api.get>( - `/resource/${params.resourceId}/rules` - ); + const res = await api.get< + AxiosResponse + >(`/resource/${params.resourceId}/rules`); if (res.status === 200) { setRules(res.data.data.rules); } @@ -133,6 +133,25 @@ export default function ResourceRules(props: { return; } + if (data.match === "CIDR" && !isValidCIDR(data.value)) { + toast({ + variant: "destructive", + title: "Invalid CIDR", + description: "Please enter a valid CIDR value" + }); + setLoading(false); + return; + } + if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) { + toast({ + variant: "destructive", + title: "Invalid URL path", + description: "Please enter a valid URL path value" + }); + setLoading(false); + return; + } + const newRule: LocalRule = { ...data, ruleId: new Date().getTime(), @@ -171,11 +190,27 @@ export default function ResourceRules(props: { value: rule.value }; + if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + toast({ + variant: "destructive", + title: "Invalid CIDR", + description: "Please enter a valid CIDR value" + }); + setLoading(false); + return; + } + if (rule.match === "PATH" && !isValidUrlGlobPattern(rule.value)) { + toast({ + variant: "destructive", + title: "Invalid URL path", + description: "Please enter a valid URL path value" + }); + setLoading(false); + return; + } + if (rule.new) { - await api.put( - `/resource/${params.resourceId}/rule`, - data - ); + await api.put(`/resource/${params.resourceId}/rule`, data); } else if (rule.updated) { await api.post( `/resource/${params.resourceId}/rule/${rule.ruleId}`, @@ -190,7 +225,9 @@ export default function ResourceRules(props: { ); } - setRules(rules.map(rule => ({ ...rule, new: false, updated: false }))); + setRules( + rules.map((rule) => ({ ...rule, new: false, updated: false })) + ); setRulesToRemove([]); toast({ @@ -322,7 +359,9 @@ export default function ResourceRules(props: { @@ -380,7 +421,8 @@ export default function ResourceRules(props: { - Enter CIDR or path value based on match type + Enter CIDR or path value based + on match type )} @@ -401,7 +443,8 @@ export default function ResourceRules(props: { {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, + header.column + .columnDef.header, header.getContext() )} @@ -413,14 +456,17 @@ export default function ResourceRules(props: { {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef.cell, + cell.getContext() + )} + + ))} )) ) : ( @@ -449,4 +495,58 @@ export default function ResourceRules(props: { ); -} \ No newline at end of file +} + +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 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; + } \ No newline at end of file From 874c67345ee353bf5357b67a9ec26a1c7ed084a5 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 10:50:43 -0500 Subject: [PATCH 08/18] Adjust rule processing --- server/routers/badger/verifySession.ts | 54 ++++++++++++++++---------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 39415023..8e120dbf 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -151,12 +151,22 @@ export async function verifyResourceSession( } // check the rules - if ( - resource.applyRules && - (await checkRules(resource.resourceId, clientIp, path)) - ) { - logger.debug("Resource allowed because rules are satisfied"); - return allowed(res); + if (resource.applyRules) { + const action = await checkRules( + resource.resourceId, + clientIp, + path + ); + + if (action == "ACCEPT") { + logger.debug("Resource allowed by rule"); + return allowed(res); + } else if (action == "DROP") { + logger.debug("Resource denied by rule"); + return notAllowed(res); + } + + // otherwise its undefined and we pass } const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; @@ -456,7 +466,7 @@ async function checkRules( resourceId: number, clientIp: string | undefined, path: string | undefined -): Promise { +): Promise<"ACCEPT" | "DROP" | undefined> { const ruleCacheKey = `rules:${resourceId}`; let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); @@ -472,36 +482,40 @@ async function checkRules( if (rules.length === 0) { logger.debug("No rules found for resource", resourceId); - return false; + return; } for (const rule of rules) { - if (clientIp && rule.match == "IP" && isIpInCidr(clientIp, rule.value)) { - return rule.action == "ACCEPT"; + if ( + clientIp && + rule.match == "IP" && + isIpInCidr(clientIp, rule.value) + ) { + return rule.action as "ACCEPT" | "DROP"; } else if (path && rule.match == "PATH") { // rule.value is a regex, match on the path and see if it matches const re = urlGlobToRegex(rule.value); if (re.test(path)) { - return rule.action == "ACCEPT"; + return rule.action as "ACCEPT" | "DROP"; } } } - return false; + return; } -function urlGlobToRegex(pattern: string): RegExp { +function urlGlobToRegex(pattern: string): RegExp { // Remove leading slash if present (we'll add it to the regex pattern) - pattern = pattern.startsWith('/') ? pattern.slice(1) : pattern; - + pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; + // Escape special regex characters except * - const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); - + const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + // Replace * with regex pattern for any valid URL segment characters - const regexPattern = escapedPattern.replace(/\*/g, '[a-zA-Z0-9_-]+'); - + const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+"); + // Create the final pattern that: // 1. Optionally matches leading slash // 2. Matches the entire string return new RegExp(`^/?${regexPattern}$`); - } \ No newline at end of file +} From 9694261f3e2cc285bd2418ac193713f9a84f57b7 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 11:02:40 -0500 Subject: [PATCH 09/18] Add enable rules toggle --- server/routers/resource/updateResource.ts | 3 +- .../[resourceId]/connectivity/page.tsx | 37 ++++-- .../resources/[resourceId]/rules/page.tsx | 125 +++++++++++++----- 3 files changed, 121 insertions(+), 44 deletions(-) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index f7dcf6ea..cc48894b 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -29,7 +29,8 @@ const updateResourceBodySchema = z blockAccess: z.boolean().optional(), proxyPort: z.number().int().min(1).max(65535).optional(), emailWhitelistEnabled: z.boolean().optional(), - isBaseDomain: z.boolean().optional() + isBaseDomain: z.boolean().optional(), + applyRules: z.boolean().optional(), }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 7cc21914..0e853194 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -131,7 +131,7 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(addTargetSchema), defaultValues: { ip: "", - method: resource.http ? "http" : null, + method: resource.http ? "http" : null // protocol: "TCP", } as z.infer }); @@ -316,17 +316,31 @@ export default function ReverseProxyTargets(props: { } async function saveSsl(val: boolean) { - const res = await api.post(`/resource/${params.resourceId}`, { - ssl: val - }); + const res = await api + .post(`/resource/${params.resourceId}`, { + ssl: val + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update SSL configuration", + description: formatAxiosError( + err, + "An error occurred while updating the SSL configuration" + ) + }); + }); - setSslEnabled(val); - updateResource({ ssl: val }); + if (res && res.status === 200) { + setSslEnabled(val); + updateResource({ ssl: val }); - toast({ - title: "SSL Configuration", - description: "SSL configuration updated successfully" - }); + toast({ + title: "SSL Configuration", + description: "SSL configuration updated successfully" + }); + } } const columns: ColumnDef[] = [ @@ -652,7 +666,8 @@ export default function ReverseProxyTargets(props: {

- Adding more than one target above will enable load balancing. + Adding more than one target above will enable load + balancing.

diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index d74ba6e9..ac863c5a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -56,6 +56,7 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; +import { SwitchInput } from "@app/components/SwitchInput"; // Schema for rule validation const addRuleSchema = z.object({ @@ -74,12 +75,13 @@ export default function ResourceRules(props: { }) { const params = use(props.params); const { toast } = useToast(); - const { resource } = useResourceContext(); + const { resource, updateResource } = useResourceContext(); const api = createApiClient(useEnvContext()); const [rules, setRules] = useState([]); const [rulesToRemove, setRulesToRemove] = useState([]); const [loading, setLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); + const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules); const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), @@ -180,6 +182,34 @@ export default function ResourceRules(props: { ); } + async function saveApplyRules(val: boolean) { + const res = await api + .post(`/resource/${params.resourceId}`, { + applyRules: val + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update rules", + description: formatAxiosError( + err, + "An error occurred while updating rules" + ) + }); + }); + + if (res && res.status === 200) { + setRulesEnabled(val); + updateResource({ applyRules: val }); + + toast({ + title: "Enable Rules", + description: "Rule evaluation has been updated" + }); + } + } + async function saveRules() { try { setLoading(true); @@ -199,7 +229,10 @@ export default function ResourceRules(props: { setLoading(false); return; } - if (rule.match === "PATH" && !isValidUrlGlobPattern(rule.value)) { + if ( + rule.match === "PATH" && + !isValidUrlGlobPattern(rule.value) + ) { toast({ variant: "destructive", title: "Invalid URL path", @@ -334,6 +367,25 @@ export default function ResourceRules(props: { return ( + + + Enable Rules + + Enable or disable rule evaluation for this resource + + + + { + await saveApplyRules(val); + }} + /> + + + @@ -400,9 +452,11 @@ export default function ResourceRules(props: { CIDR - - PATH - + {resource.http && ( + + PATH + + )} @@ -421,14 +475,21 @@ export default function ResourceRules(props: { - Enter CIDR or path value based - on match type + Enter CIDR{" "} + {resource.http + ? "or path value" + : ""}{" "} + based on match type )} /> - @@ -518,35 +579,35 @@ function isValidCIDR(cidr: string): boolean { function isValidUrlGlobPattern(pattern: string): boolean { // Remove leading slash if present - pattern = pattern.startsWith('/') ? pattern.slice(1) : pattern; - + pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; + // Empty string is not valid if (!pattern) { - return false; + return false; } - + // Split path into segments - const segments = pattern.split('/'); - + 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; - } + 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; - } \ No newline at end of file +} From 73798f9e61d0b3948465a74d047377dde23230a9 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 11:05:42 -0500 Subject: [PATCH 10/18] Add ecr login --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index da0ce742..02d97f3c 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,9 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . +auth-ecr: + aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/g5j5p7x0 + build-x86-ecr: docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push . From c415ceef8d188c30792075d2d360cb04d53d8c26 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 11:10:19 -0500 Subject: [PATCH 11/18] Add migrations --- server/setup/migrations.ts | 4 +++- server/setup/scripts/1.0.0-beta13.ts | 29 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 server/setup/scripts/1.0.0-beta13.ts diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 5581fc24..52a82ad4 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -14,6 +14,7 @@ import m5 from "./scripts/1.0.0-beta6"; import m6 from "./scripts/1.0.0-beta9"; import m7 from "./scripts/1.0.0-beta10"; import m8 from "./scripts/1.0.0-beta12"; +import m13 from "./scripts/1.0.0-beta13"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -27,7 +28,8 @@ const migrations = [ { version: "1.0.0-beta.6", run: m5 }, { version: "1.0.0-beta.9", run: m6 }, { version: "1.0.0-beta.10", run: m7 }, - { version: "1.0.0-beta.12", run: m8 } + { version: "1.0.0-beta.12", run: m8 }, + { version: "1.0.0-beta.13", run: m13 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.0.0-beta13.ts b/server/setup/scripts/1.0.0-beta13.ts new file mode 100644 index 00000000..26dac8f9 --- /dev/null +++ b/server/setup/scripts/1.0.0-beta13.ts @@ -0,0 +1,29 @@ +import db from "@server/db"; +import { sql } from "drizzle-orm"; + +export default async function migration() { + console.log("Running setup script 1.0.0-beta.13..."); + + try { + db.transaction((trx) => { + trx.run(sql`CREATE TABLE resourceRules ( + ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL, + resourceId integer NOT NULL, + action text NOT NULL, + match text NOT NULL, + value text NOT NULL, + FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade + );`); + trx.run( + sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;` + ); + }); + + console.log(`Added new table and column: resourceRules, applyRules`); + } catch (e) { + console.log("Unable to add new table and column: resourceRules, applyRules"); + throw e; + } + + console.log("Done."); +} From 34e3fe690d516dff20049723d9c6c090413e9ef2 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 11:33:40 -0500 Subject: [PATCH 12/18] Fix check on string --- server/routers/badger/verifySession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 8e120dbf..4515e981 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -488,7 +488,7 @@ async function checkRules( for (const rule of rules) { if ( clientIp && - rule.match == "IP" && + rule.match == "CIDR" && isIpInCidr(clientIp, rule.value) ) { return rule.action as "ACCEPT" | "DROP"; From 2428738fa6ea36823fcdd8afe17b29f4c0029117 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 21:47:59 -0500 Subject: [PATCH 13/18] Fix missing ruleId issue --- .../[resourceId]/connectivity/page.tsx | 4 ++-- .../resources/[resourceId]/rules/page.tsx | 24 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx index 0e853194..b3b26a5a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx @@ -269,7 +269,7 @@ export default function ReverseProxyTargets(props: { >(`/resource/${params.resourceId}/target`, data); target.targetId = res.data.data.targetId; } else if (target.updated) { - const res = await api.post( + await api.post( `/target/${target.targetId}`, data ); @@ -290,7 +290,7 @@ export default function ReverseProxyTargets(props: { for (const targetId of targetsToRemove) { await api.delete(`/target/${targetId}`); setTargets( - targets.filter((target) => target.targetId !== targetId) + targets.filter((t) => t.targetId !== targetId) ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index ac863c5a..b5038b36 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -243,30 +243,42 @@ export default function ResourceRules(props: { } if (rule.new) { - await api.put(`/resource/${params.resourceId}/rule`, data); + const res = await api.put(`/resource/${params.resourceId}/rule`, data); + rule.ruleId = res.data.data.ruleId; } else if (rule.updated) { await api.post( `/resource/${params.resourceId}/rule/${rule.ruleId}`, data ); } + + setRules([ + ...rules.map((r) => { + let res = { + ...r, + new: false, + updated: false + }; + return res; + }) + ]); } for (const ruleId of rulesToRemove) { await api.delete( `/resource/${params.resourceId}/rule/${ruleId}` ); + setRules( + rules.filter((r) => r.ruleId !== ruleId) + ); } - setRules( - rules.map((rule) => ({ ...rule, new: false, updated: false })) - ); - setRulesToRemove([]); - toast({ title: "Rules updated", description: "Rules updated successfully" }); + + setRulesToRemove([]); } catch (err) { console.error(err); toast({ From 5e92aebd20ff3bed818a8c9f95493e4c4c8221e1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 21:56:39 -0500 Subject: [PATCH 14/18] Drop first --- server/routers/badger/verifySession.ts | 40 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 4515e981..ce8d3210 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -485,18 +485,42 @@ async function checkRules( return; } + let hasAcceptRule = false; + + // First pass: look for DROP rules for (const rule of rules) { if ( - clientIp && + (clientIp && rule.match == "CIDR" && - isIpInCidr(clientIp, rule.value) + isIpInCidr(clientIp, rule.value) && + rule.action === "DROP") || + (path && + rule.match == "PATH" && + urlGlobToRegex(rule.value).test(path) && + rule.action === "DROP") ) { - return rule.action as "ACCEPT" | "DROP"; - } else if (path && rule.match == "PATH") { - // rule.value is a regex, match on the path and see if it matches - const re = urlGlobToRegex(rule.value); - if (re.test(path)) { - return rule.action as "ACCEPT" | "DROP"; + return "DROP"; + } + // Track if we see any ACCEPT rules for the second pass + if (rule.action === "ACCEPT") { + hasAcceptRule = true; + } + } + + // Second pass: only check ACCEPT rules if we found one and didn't find a DROP + if (hasAcceptRule) { + for (const rule of rules) { + if (rule.action !== "ACCEPT") continue; + + if ( + (clientIp && + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value)) || + (path && + rule.match == "PATH" && + urlGlobToRegex(rule.value).test(path)) + ) { + return "ACCEPT"; } } } From bbc1a9eac40a1d5d4755d9cbd4d8adc594007ab5 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 22:00:02 -0500 Subject: [PATCH 15/18] Format --- server/routers/badger/verifySession.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ce8d3210..a3c6abbc 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -491,13 +491,13 @@ async function checkRules( for (const rule of rules) { if ( (clientIp && - rule.match == "CIDR" && - isIpInCidr(clientIp, rule.value) && - rule.action === "DROP") || + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value) && + rule.action === "DROP") || (path && - rule.match == "PATH" && - urlGlobToRegex(rule.value).test(path) && - rule.action === "DROP") + rule.match == "PATH" && + urlGlobToRegex(rule.value).test(path) && + rule.action === "DROP") ) { return "DROP"; } @@ -511,14 +511,14 @@ async function checkRules( if (hasAcceptRule) { for (const rule of rules) { if (rule.action !== "ACCEPT") continue; - + if ( (clientIp && - rule.match == "CIDR" && - isIpInCidr(clientIp, rule.value)) || + rule.match == "CIDR" && + isIpInCidr(clientIp, rule.value)) || (path && - rule.match == "PATH" && - urlGlobToRegex(rule.value).test(path)) + rule.match == "PATH" && + urlGlobToRegex(rule.value).test(path)) ) { return "ACCEPT"; } From 4bce210ff560f08e9358603520471b3658676fa0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 9 Feb 2025 22:03:18 -0500 Subject: [PATCH 16/18] Be more lenient with leading and trailing slashes --- server/routers/badger/verifySession.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a3c6abbc..a65b24a0 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -529,8 +529,8 @@ async function checkRules( } function urlGlobToRegex(pattern: string): RegExp { - // Remove leading slash if present (we'll add it to the regex pattern) - pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; + // Trim any leading or trailing slashes + pattern = pattern.replace(/^\/+|\/+$/g, ""); // Escape special regex characters except * const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); @@ -540,6 +540,7 @@ function urlGlobToRegex(pattern: string): RegExp { // Create the final pattern that: // 1. Optionally matches leading slash - // 2. Matches the entire string - return new RegExp(`^/?${regexPattern}$`); -} + // 2. Matches the pattern + // 3. Optionally matches trailing slash + return new RegExp(`^/?${regexPattern}/?$`); +} \ No newline at end of file From 6e6992e19f8d6993b8f98afed9355582b4b6e530 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Sun, 9 Feb 2025 23:23:55 -0500 Subject: [PATCH 17/18] add rules info card --- server/lib/consts.ts | 2 +- .../resources/[resourceId]/rules/page.tsx | 92 ++++++++++++++++--- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 2f505ae1..20376f8e 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.0.0-beta.12"; +export const APP_VERSION = "1.0.0-beta.13"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index ac863c5a..287e767a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -57,6 +57,15 @@ import { } from "@app/components/Settings"; import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules"; import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Check, Info, InfoIcon, X } from "lucide-react"; +import { + InfoSection, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Separator } from "@app/components/ui/separator"; +import { InfoPopup } from "@app/components/ui/info-popup"; // Schema for rule validation const addRuleSchema = z.object({ @@ -70,6 +79,11 @@ type LocalRule = ArrayElement & { updated?: boolean; }; +enum RuleAction { + ACCEPT = "Always Allow", + DROP = "Always Deny" +} + export default function ResourceRules(props: { params: Promise<{ resourceId: number }>; }) { @@ -296,8 +310,10 @@ export default function ResourceRules(props: { {row.original.action} - ACCEPT - DROP + + {RuleAction.ACCEPT} + + {RuleAction.DROP} ) @@ -367,6 +383,56 @@ export default function ResourceRules(props: { return ( + + + About Rules + +

+ Rules allow you to control access to your resource based + on a set of criteria. You can create rules to allow or + deny access based on IP address or URL path. Deny rules + take precedence over allow rules. If a request matches + both an allow and a deny rule, the deny rule will be + applied. +

+ + + Actions +
    +
  • + + Always Allow: Bypass all authentication + methods +
  • +
  • + + Always Deny: Block all requests; no + authentication can be attempted +
  • +
+
+ + + + Matching Criteria + +
    +
  • + Match a specific IP address +
  • +
  • + Match a range of IP addresses in CIDR + notation +
  • +
  • + Match a URL path or pattern +
  • +
+
+
+
+
+ Enable Rules @@ -420,10 +486,10 @@ export default function ResourceRules(props: { - ACCEPT + {RuleAction.ACCEPT} - DROP + {RuleAction.DROP} @@ -469,18 +535,20 @@ export default function ResourceRules(props: { name="value" render={({ field }) => ( - Value + - - Enter CIDR{" "} - {resource.http - ? "or path value" - : ""}{" "} - based on match type - )} /> From 3c99fbb1efc49c869d39ac39a7dff9f0e28d62df Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 10 Feb 2025 21:06:37 -0500 Subject: [PATCH 18/18] Seperate ip and cidr --- Makefile | 6 --- server/db/schema.ts | 2 +- server/routers/badger/verifySession.ts | 7 +++ server/routers/resource/createResourceRule.ts | 2 +- server/routers/resource/updateResourceRule.ts | 2 +- .../resources/[resourceId]/rules/page.tsx | 45 +++++++++++++++++-- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index 02d97f3c..793a3481 100644 --- a/Makefile +++ b/Makefile @@ -12,12 +12,6 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . -auth-ecr: - aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/g5j5p7x0 - -build-x86-ecr: - docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push . - build: docker build -t fosrl/pangolin:latest . diff --git a/server/db/schema.ts b/server/db/schema.ts index cf5ad531..16d8ada2 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -378,7 +378,7 @@ export const resourceRules = sqliteTable("resourceRules", { .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), action: text("action").notNull(), // ACCEPT, DROP - match: text("match").notNull(), // CIDR, PATH + match: text("match").notNull(), // CIDR, PATH, IP value: text("value").notNull() }); diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index a65b24a0..a4a2944a 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -494,6 +494,10 @@ async function checkRules( rule.match == "CIDR" && 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) && @@ -516,6 +520,9 @@ async function checkRules( (clientIp && rule.match == "CIDR" && isIpInCidr(clientIp, rule.value)) || + (clientIp && + rule.match == "IP" && + clientIp == rule.value) || (path && rule.match == "PATH" && urlGlobToRegex(rule.value).test(path)) diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index f01ed115..24b08fc9 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -12,7 +12,7 @@ import { fromError } from "zod-validation-error"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP"]), - match: z.enum(["CIDR", "PATH"]), + match: z.enum(["CIDR", "IP", "PATH"]), value: z.string().min(1) }) .strict(); diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index f96ea0fa..0eaacc03 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -27,7 +27,7 @@ const updateResourceRuleParamsSchema = z const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP"]).optional(), - match: z.enum(["CIDR", "PATH"]).optional(), + match: z.enum(["CIDR", "IP", "PATH"]).optional(), value: z.string().min(1).optional() }) .strict() diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index bffcc9ae..6a961a6a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -101,7 +101,7 @@ export default function ResourceRules(props: { resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT", - match: "CIDR", + match: "IP", value: "" } }); @@ -167,6 +167,15 @@ export default function ResourceRules(props: { setLoading(false); return; } + if (data.match === "IP" && !isValidIP(data.value)) { + toast({ + variant: "destructive", + title: "Invalid IP", + description: "Please enter a valid IP address" + }); + setLoading(false); + return; + } const newRule: LocalRule = { ...data, @@ -255,6 +264,15 @@ export default function ResourceRules(props: { setLoading(false); return; } + if (rule.match === "IP" && !isValidIP(rule.value)) { + toast({ + variant: "destructive", + title: "Invalid IP", + description: "Please enter a valid IP address" + }); + setLoading(false); + return; + } if (rule.new) { const res = await api.put(`/resource/${params.resourceId}/rule`, data); @@ -336,7 +354,7 @@ export default function ResourceRules(props: { cell: ({ row }) => ( @@ -527,8 +546,11 @@ export default function ResourceRules(props: { + + IP + - CIDR + IP Range {resource.http && ( @@ -657,6 +679,21 @@ function isValidCIDR(cidr: string): boolean { }); } +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;