rules server validation, enabled toggle, fix wildcard

This commit is contained in:
Milo Schwartz
2025-02-11 23:59:13 -05:00
parent f14ecf50e4
commit fdf1dfdeba
13 changed files with 467 additions and 196 deletions

View File

@@ -1,36 +1,38 @@
import HttpCode from "@server/types/HttpCode";
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 { generateSessionToken } from "@server/auth/sessions/app";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} 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 NodeCache from "node-cache";
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
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 { 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
const cache = new NodeCache({
@@ -169,18 +171,16 @@ export async function verifyResourceSession(
// 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
let validAccessToken: ResourceAccessToken | undefined;
if (token) {
const [accessTokenId, accessToken] = token.split(".");
const { valid, error, tokenItem } = await verifyResourceAccessToken(
{
resource,
accessTokenId,
accessToken
}
{ resource, accessTokenId, accessToken }
);
if (error) {
@@ -190,7 +190,9 @@ export async function verifyResourceSession(
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
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 (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
`Missing resource sessions. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res);
@@ -219,7 +223,9 @@ export async function verifyResourceSession(
const resourceSessionToken =
sessions[
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
`${config.getRawConfig().server.session_cookie_name}${
resource.ssl ? "_s" : ""
}`
];
if (resourceSessionToken) {
@@ -242,7 +248,9 @@ export async function verifyResourceSession(
);
if (config.getRawConfig().app.log_failed_attempts) {
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);
@@ -281,7 +289,9 @@ export async function verifyResourceSession(
}
if (resourceSession.userSessionId && sso) {
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
const userAccessCacheKey = `userAccess:${
resourceSession.userSessionId
}:${resource.resourceId}`;
let isAllowed: boolean | undefined =
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
// and create a new session.
// At this point we have checked all sessions, but since the access token is
// valid, we should allow access and create a new session.
if (validAccessToken) {
return await createAccessTokenSession(
res,
@@ -319,7 +329,9 @@ export async function verifyResourceSession(
if (config.getRawConfig().app.log_failed_attempts) {
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);
@@ -485,69 +497,123 @@ async function checkRules(
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) {
if (!rule.enabled) {
continue;
}
if (
(clientIp &&
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) &&
rule.action === "DROP")
clientIp &&
rule.match == "CIDR" &&
isIpInCidr(clientIp, rule.value)
) {
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)) ||
(clientIp &&
rule.match == "IP" &&
clientIp == rule.value) ||
(path &&
rule.match == "PATH" &&
urlGlobToRegex(rule.value).test(path))
) {
return "ACCEPT";
}
return rule.action as any;
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
return rule.action as any;
} else if (
path &&
rule.match == "PATH" &&
isPathAllowed(rule.value, path)
) {
return rule.action as any;
}
}
return;
}
function urlGlobToRegex(pattern: string): RegExp {
// Trim any leading or trailing slashes
pattern = pattern.replace(/^\/+|\/+$/g, "");
function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
// Escape special regex characters except *
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
// Normalize and split paths into segments
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
const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+");
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
// Create the final pattern that:
// 1. Optionally matches leading slash
// 2. Matches the pattern
// 3. Optionally matches trailing slash
return new RegExp(`^/?${regexPattern}/?$`);
}
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
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;
}

View File

@@ -17,7 +17,7 @@ import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { subdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
const createResourceParamsSchema = z

View File

@@ -8,12 +8,19 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
const createResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]),
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();
@@ -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(
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
const [newRule] = await db
.insert(resourceRules)
@@ -81,7 +123,9 @@ export async function createResourceRule(
resourceId,
action,
match,
value
value,
priority,
enabled
})
.returning();

View File

@@ -40,12 +40,14 @@ function queryResourceRules(resourceId: number) {
resourceId: resourceRules.resourceId,
action: resourceRules.action,
match: resourceRules.match,
value: resourceRules.value
value: resourceRules.value,
priority: resourceRules.priority,
enabled: resourceRules.enabled
})
.from(resourceRules)
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
.where(eq(resourceRules.resourceId, resourceId));
return baseQuery;
}
@@ -71,7 +73,9 @@ export async function listResourceRules(
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listResourceRulesParamsSchema.safeParse(req.params);
const parsedParams = listResourceRulesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -99,16 +103,19 @@ export async function listResourceRules(
}
const baseQuery = queryResourceRules(resourceId);
let countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourceRules)
.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 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, {
data: {
rules: rulesList,
@@ -129,4 +136,4 @@ export async function listResourceRules(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -8,8 +8,8 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import config from "@server/lib/config";
import { subdomainSchema } from "@server/lib/schemas";
const updateResourceParamsSchema = z
.object({

View File

@@ -8,14 +8,16 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
// Define Zod schema for request parameters validation
const updateResourceRuleParamsSchema = z
.object({
ruleId: z
.string()
.transform(Number)
.pipe(z.number().int().positive()),
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
resourceId: z
.string()
.transform(Number)
@@ -28,7 +30,9 @@ const updateResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]).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()
.refine((data) => Object.keys(data).length > 0, {
@@ -42,7 +46,9 @@ export async function updateResourceRule(
): Promise<any> {
try {
// Validate path parameters
const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params);
const parsedParams = updateResourceRuleParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
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
const [existingRule] = await db
.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
const [updatedRule] = await db
.update(resourceRules)
@@ -127,4 +176,4 @@ export async function updateResourceRule(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}