Skip to content
Merged
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Logs
logs
!apps/api/src/domains/logs
!apps/web/src/components/pages/Logs.tsx
*.log
npm-debug.log*
yarn-debug.log*
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/domains/access-lists/dto/access-lists.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { body, param, query } from 'express-validator';
import { isValidIpOrCidr } from '../../acl/utils/validators';

/**
* Validation rules for creating an access list
Expand Down Expand Up @@ -38,8 +39,13 @@ export const createAccessListValidation = [
body('allowedIps.*')
.optional()
.trim()
.matches(/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/)
.withMessage('Each IP must be a valid IPv4 address or CIDR notation'),
.custom((value) => {
if (!value) return true;
if (!isValidIpOrCidr(value)) {
throw new Error('Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24');
}
return true;
}),

body('authUsers')
.optional()
Expand Down Expand Up @@ -125,8 +131,13 @@ export const updateAccessListValidation = [
body('allowedIps.*')
.optional()
.trim()
.matches(/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/)
.withMessage('Each IP must be a valid IPv4 address or CIDR notation'),
.custom((value) => {
if (!value) return true;
if (!isValidIpOrCidr(value)) {
throw new Error('Invalid IP address or CIDR notation. Examples: 192.168.1.1 or 192.168.1.0/24');
}
return true;
}),

body('authUsers')
.optional()
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/domains/acl/acl.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,31 @@ export class AclController {
}
}

/**
* Preview ACL configuration without applying
* @route GET /api/acl/preview
*/
async previewAclConfig(req: Request, res: Response): Promise<void> {
try {
const config = await aclService.previewNginxConfig();

res.json({
success: true,
data: {
config,
rulesCount: await aclService.getEnabledRulesCount()
}
});
} catch (error: any) {
logger.error('Failed to preview ACL config:', error);
res.status(500).json({
success: false,
message: 'Failed to preview ACL configuration',
error: error.message
});
}
}

/**
* Apply ACL rules to Nginx
* @route POST /api/acl/apply
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/domains/acl/acl.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ router.get('/:id', (req, res) => aclController.getAclRule(req, res));
*/
router.post('/', authorize('admin', 'moderator'), (req, res) => aclController.createAclRule(req, res));

/**
* @route GET /api/acl/preview
* @desc Preview ACL configuration without applying
* @access Private (all roles)
*/
router.get('/preview', (req, res) => aclController.previewAclConfig(req, res));

/**
* @route POST /api/acl/apply
* @desc Apply ACL rules to Nginx
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/domains/acl/acl.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ export class AclService {
return rule;
}

/**
* Preview Nginx configuration without applying
*/
async previewNginxConfig(): Promise<string> {
return aclNginxService.generateAclConfig();
}

/**
* Get count of enabled rules
*/
async getEnabledRulesCount(): Promise<number> {
const rules = await aclRepository.findEnabled();
return rules.length;
}

/**
* Apply ACL rules to Nginx
*/
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/domains/acl/dto/create-acl-rule.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { validateAclValue, sanitizeValue } from '../utils/validators';

/**
* DTO for creating ACL rule
*/
Expand All @@ -17,34 +19,76 @@ export interface CreateAclRuleDto {
export function validateCreateAclRuleDto(data: any): { isValid: boolean; errors: string[] } {
const errors: string[] = [];

// Validate name
if (!data.name || typeof data.name !== 'string' || !data.name.trim()) {
errors.push('Name is required and must be a non-empty string');
} else if (data.name.length > 100) {
errors.push('Name must not exceed 100 characters');
}

// Validate type
const validTypes = ['whitelist', 'blacklist'];
if (!data.type || typeof data.type !== 'string') {
errors.push('Type is required and must be a string');
} else if (!validTypes.includes(data.type)) {
errors.push(`Type must be one of: ${validTypes.join(', ')}`);
}

// Validate condition field
const validFields = ['ip', 'geoip', 'user_agent', 'url', 'method', 'header'];
if (!data.conditionField || typeof data.conditionField !== 'string') {
errors.push('Condition field is required and must be a string');
} else if (!validFields.includes(data.conditionField)) {
errors.push(`Condition field must be one of: ${validFields.join(', ')}`);
}

// Validate condition operator
const validOperators = ['equals', 'contains', 'regex'];
if (!data.conditionOperator || typeof data.conditionOperator !== 'string') {
errors.push('Condition operator is required and must be a string');
} else if (!validOperators.includes(data.conditionOperator)) {
errors.push(`Condition operator must be one of: ${validOperators.join(', ')}`);
}

// Validate condition value
if (!data.conditionValue || typeof data.conditionValue !== 'string') {
errors.push('Condition value is required and must be a string');
} else if (data.conditionValue.trim().length === 0) {
errors.push('Condition value cannot be empty');
} else {
// Perform field-specific validation
const valueValidation = validateAclValue(
data.conditionField,
data.conditionOperator,
data.conditionValue
);

if (!valueValidation.valid) {
errors.push(valueValidation.error || 'Invalid condition value');
}
}

// Validate action
const validActions = ['allow', 'deny', 'challenge'];
if (!data.action || typeof data.action !== 'string') {
errors.push('Action is required and must be a string');
} else if (!validActions.includes(data.action)) {
errors.push(`Action must be one of: ${validActions.join(', ')}`);
}

// Validate enabled
if (data.enabled !== undefined && typeof data.enabled !== 'boolean') {
errors.push('Enabled must be a boolean');
}

// Validate type-action combinations
if (data.type === 'whitelist' && data.action === 'deny') {
errors.push('Whitelist rules should use "allow" action, not "deny"');
}
if (data.type === 'blacklist' && data.action === 'allow') {
errors.push('Blacklist rules should use "deny" action, not "allow"');
}

return {
isValid: errors.length === 0,
errors
Expand Down
50 changes: 48 additions & 2 deletions apps/api/src/domains/acl/dto/update-acl-rule.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { validateAclValue } from '../utils/validators';

/**
* DTO for updating ACL rule
*/
Expand All @@ -17,34 +19,78 @@ export interface UpdateAclRuleDto {
export function validateUpdateAclRuleDto(data: any): { isValid: boolean; errors: string[] } {
const errors: string[] = [];

// Validate name
if (data.name !== undefined && (typeof data.name !== 'string' || !data.name.trim())) {
errors.push('Name must be a non-empty string');
} else if (data.name && data.name.length > 100) {
errors.push('Name must not exceed 100 characters');
}

// Validate type
const validTypes = ['whitelist', 'blacklist'];
if (data.type !== undefined && typeof data.type !== 'string') {
errors.push('Type must be a string');
} else if (data.type && !validTypes.includes(data.type)) {
errors.push(`Type must be one of: ${validTypes.join(', ')}`);
}

// Validate condition field
const validFields = ['ip', 'geoip', 'user_agent', 'url', 'method', 'header'];
if (data.conditionField !== undefined && typeof data.conditionField !== 'string') {
errors.push('Condition field must be a string');
} else if (data.conditionField && !validFields.includes(data.conditionField)) {
errors.push(`Condition field must be one of: ${validFields.join(', ')}`);
}

// Validate condition operator
const validOperators = ['equals', 'contains', 'regex'];
if (data.conditionOperator !== undefined && typeof data.conditionOperator !== 'string') {
errors.push('Condition operator must be a string');
} else if (data.conditionOperator && !validOperators.includes(data.conditionOperator)) {
errors.push(`Condition operator must be one of: ${validOperators.join(', ')}`);
}

if (data.conditionValue !== undefined && typeof data.conditionValue !== 'string') {
errors.push('Condition value must be a string');
// Validate condition value with field-specific validation
if (data.conditionValue !== undefined) {
if (typeof data.conditionValue !== 'string') {
errors.push('Condition value must be a string');
} else if (data.conditionValue.trim().length === 0) {
errors.push('Condition value cannot be empty');
} else if (data.conditionField && data.conditionOperator) {
// Perform field-specific validation if we have all required fields
const valueValidation = validateAclValue(
data.conditionField,
data.conditionOperator,
data.conditionValue
);

if (!valueValidation.valid) {
errors.push(valueValidation.error || 'Invalid condition value');
}
}
}

// Validate action
const validActions = ['allow', 'deny', 'challenge'];
if (data.action !== undefined && typeof data.action !== 'string') {
errors.push('Action must be a string');
} else if (data.action && !validActions.includes(data.action)) {
errors.push(`Action must be one of: ${validActions.join(', ')}`);
}

// Validate enabled
if (data.enabled !== undefined && typeof data.enabled !== 'boolean') {
errors.push('Enabled must be a boolean');
}

// Validate type-action combinations
if (data.type === 'whitelist' && data.action === 'deny') {
errors.push('Whitelist rules should use "allow" action, not "deny"');
}
if (data.type === 'blacklist' && data.action === 'allow') {
errors.push('Blacklist rules should use "deny" action, not "allow"');
}

return {
isValid: errors.length === 0,
errors
Expand Down
Loading