feat: implement comprehensive resource name and path validation to prevent conflicts and ensure safe routing

This commit is contained in:
Sam Chau
2025-01-10 03:34:44 +10:30
parent b031207239
commit fd8780ae27

View File

@@ -2,66 +2,225 @@ import axios from 'axios';
const BASE_URL = '/api/data';
// Define all special endpoints that could conflict with resource names
const SPECIAL_ENDPOINTS = [
'test',
'validate',
'search',
'batch',
'export',
'import',
'stats',
'metrics',
'health',
'status',
'config',
'settings',
'logs',
'audit',
'backup',
'restore',
'sync',
'preview',
'publish',
'deploy',
'run',
'execute',
'process',
'analyze',
'verify',
'check'
];
// Define characters and patterns that could cause routing issues
const UNSAFE_PATTERNS = [
/[\/\\]/, // No slashes (forward or backward)
/[\s]/, // No whitespace
/[<>:"|?*]/, // No special characters that might be interpreted by the system
/^\.+/, // No dots at start (prevent relative paths)
/\.+$/, // No dots at end
/^-/, // No dash at start
/-$/, // No dash at end
/--|__|\.\./ // No double separators
];
// Comprehensive name validation
const validateResourceName = (category, name) => {
// Basic checks
if (!name || typeof name !== 'string') {
throw new Error('Resource name must be a non-empty string');
}
// Length check
if (name.length < 1 || name.length > 64) {
throw new Error('Resource name must be between 1 and 64 characters');
}
// Check for special endpoints
if (SPECIAL_ENDPOINTS.includes(name.toLowerCase())) {
throw new Error(
`'${name}' is a reserved word and cannot be used as a resource name`
);
}
// Check for unsafe patterns
for (const pattern of UNSAFE_PATTERNS) {
if (pattern.test(name)) {
throw new Error(
'Resource name contains invalid characters or patterns'
);
}
}
// Only allow alphanumeric characters, single hyphens, and underscores
const validNamePattern = /^[a-zA-Z0-9]+(?:[-_][a-zA-Z0-9]+)*$/;
if (!validNamePattern.test(name)) {
throw new Error(
'Resource name must contain only letters, numbers, hyphens, and underscores, and cannot have consecutive separators'
);
}
return true;
};
// Validate entire path to protect against path traversal
const validatePath = parts => {
if (!Array.isArray(parts)) {
throw new Error('Path must be an array of segments');
}
const joinedPath = parts.join('/');
// Prevent any path traversal attempts
if (
joinedPath.includes('..') ||
joinedPath.includes('./') ||
joinedPath.includes('/.')
) {
throw new Error('Invalid path detected');
}
// Ensure path doesn't start or end with separators
if (joinedPath.startsWith('/') || joinedPath.endsWith('/')) {
throw new Error('Path cannot start or end with separators');
}
return true;
};
const handleError = (error, operation) => {
console.error(`Error ${operation}:`, error);
const errorMessage =
error.response?.data?.error || `Failed to ${operation}`;
throw new Error(errorMessage); // Throw instead of returning an error object
// If it's already an Error object with a message, throw it directly
if (error instanceof Error) {
throw error;
}
// If it's an axios error with a response
if (error.response?.data) {
const errorData = error.response.data;
// Handle various error response formats
const message =
errorData.error ||
errorData.message ||
errorData.detail ||
(typeof errorData === 'string' ? errorData : null);
if (message) {
throw new Error(message);
}
}
// Fallback generic error
throw new Error(`Failed to ${operation}`);
};
// Get all items for a category
export const getAllItems = async category => {
try {
validateResourceName('category', category);
validatePath([category]);
const response = await axios.get(`${BASE_URL}/${category}`);
return response.data;
} catch (error) {
return handleError(error, `fetch ${category} items`);
throw handleError(error, `fetch ${category} items`);
}
};
// Get single item
export const getItem = async (category, name) => {
try {
validateResourceName('category', category);
validateResourceName(category, name);
validatePath([category, name]);
const response = await axios.get(`${BASE_URL}/${category}/${name}`);
return response.data;
} catch (error) {
return handleError(error, `fetch ${category} item ${name}`);
throw handleError(error, `fetch ${category} item ${name}`);
}
};
// Create new item
export const createItem = async (category, data) => {
try {
validateResourceName('category', category);
validateResourceName(category, data.name);
validatePath([category, data.name]);
const response = await axios.post(
`${BASE_URL}/${category}/${data.name}`,
data
);
return response.data;
} catch (error) {
return handleError(error, `create ${category} item`);
throw handleError(error, `create ${category} item`);
}
};
// Update existing item
export const updateItem = async (category, name, data, newName) => {
try {
validateResourceName('category', category);
validateResourceName(category, name);
if (newName) {
validateResourceName(category, newName);
validatePath([category, newName]);
}
validatePath([category, name]);
const response = await axios.put(`${BASE_URL}/${category}/${name}`, {
...data,
...(newName && {rename: newName}) // Only add rename field if newName exists
...(newName && {rename: newName})
});
return response.data;
} catch (error) {
return handleError(error, `update ${category} item ${name}`);
throw handleError(error, `update ${category} item ${name}`);
}
};
// Delete item
export const deleteItem = async (category, name) => {
try {
validateResourceName('category', category);
validateResourceName(category, name);
validatePath([category, name]);
const response = await axios.delete(`${BASE_URL}/${category}/${name}`);
return response.data;
} catch (error) {
return handleError(error, `delete ${category} item ${name}`);
throw handleError(error, `delete ${category} item ${name}`);
}
};
// Special endpoint wrapper
const createSpecialEndpoint = (category, endpoint) => async data => {
try {
validateResourceName('category', category);
validatePath([category, endpoint]);
const response = await axios.post(
`${BASE_URL}/${category}/${endpoint}`,
data
);
return response.data;
} catch (error) {
throw handleError(error, `execute ${category} ${endpoint}`);
}
};
@@ -80,20 +239,7 @@ export const CustomFormats = {
update: (name, data, newName) =>
updateItem('custom_format', name, data, newName),
delete: name => deleteItem('custom_format', name),
runTests: async ({conditions, tests}) => {
try {
const response = await axios.post(
`${BASE_URL}/custom_format/test`,
{
conditions,
tests
}
);
return response.data;
} catch (error) {
return handleError(error, 'run tests');
}
}
runTests: createSpecialEndpoint('custom_format', 'test')
};
export const RegexPatterns = {
@@ -103,18 +249,5 @@ export const RegexPatterns = {
update: (name, data, newName) =>
updateItem('regex_pattern', name, data, newName),
delete: name => deleteItem('regex_pattern', name),
runTests: async (pattern, tests) => {
try {
const response = await axios.post(
`${BASE_URL}/regex_pattern/test`,
{
pattern,
tests
}
);
return response.data;
} catch (error) {
return handleError(error, 'run tests');
}
}
runTests: createSpecialEndpoint('regex_pattern', 'test')
};