166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
|
|
/**
|
||
|
|
* Path Validator
|
||
|
|
* 路径验证器
|
||
|
|
*
|
||
|
|
* Validates and sanitizes asset paths for security
|
||
|
|
* 验证并清理资产路径以确保安全
|
||
|
|
*/
|
||
|
|
|
||
|
|
export class PathValidator {
|
||
|
|
// Dangerous path patterns
|
||
|
|
private static readonly DANGEROUS_PATTERNS = [
|
||
|
|
/\.\.[/\\]/g, // Path traversal attempts (..)
|
||
|
|
/^[/\\]/, // Absolute paths on Unix
|
||
|
|
/^[a-zA-Z]:[/\\]/, // Absolute paths on Windows
|
||
|
|
/[<>:"|?*]/, // Invalid characters for Windows paths
|
||
|
|
/\0/, // Null bytes
|
||
|
|
/%00/, // URL encoded null bytes
|
||
|
|
/\.\.%2[fF]/ // URL encoded path traversal
|
||
|
|
];
|
||
|
|
|
||
|
|
// Valid path characters (alphanumeric, dash, underscore, dot, slash)
|
||
|
|
private static readonly VALID_PATH_REGEX = /^[a-zA-Z0-9\-_./\\@]+$/;
|
||
|
|
|
||
|
|
// Maximum path length
|
||
|
|
private static readonly MAX_PATH_LENGTH = 260;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Validate if a path is safe
|
||
|
|
* 验证路径是否安全
|
||
|
|
*/
|
||
|
|
static validate(path: string): { valid: boolean; reason?: string } {
|
||
|
|
// Check for null/undefined/empty
|
||
|
|
if (!path || typeof path !== 'string') {
|
||
|
|
return { valid: false, reason: 'Path is empty or invalid' };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check length
|
||
|
|
if (path.length > this.MAX_PATH_LENGTH) {
|
||
|
|
return { valid: false, reason: `Path exceeds maximum length of ${this.MAX_PATH_LENGTH} characters` };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for dangerous patterns
|
||
|
|
for (const pattern of this.DANGEROUS_PATTERNS) {
|
||
|
|
if (pattern.test(path)) {
|
||
|
|
return { valid: false, reason: 'Path contains dangerous pattern' };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for valid characters
|
||
|
|
if (!this.VALID_PATH_REGEX.test(path)) {
|
||
|
|
return { valid: false, reason: 'Path contains invalid characters' };
|
||
|
|
}
|
||
|
|
|
||
|
|
return { valid: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sanitize a path
|
||
|
|
* 清理路径
|
||
|
|
*/
|
||
|
|
static sanitize(path: string): string {
|
||
|
|
if (!path || typeof path !== 'string') {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove dangerous patterns
|
||
|
|
let sanitized = path;
|
||
|
|
|
||
|
|
// Remove path traversal (apply repeatedly until fully removed)
|
||
|
|
let prev;
|
||
|
|
do {
|
||
|
|
prev = sanitized;
|
||
|
|
sanitized = sanitized.replace(/\.\.[/\\]/g, '');
|
||
|
|
} while (sanitized !== prev);
|
||
|
|
|
||
|
|
// Remove leading slashes
|
||
|
|
sanitized = sanitized.replace(/^[/\\]+/, '');
|
||
|
|
|
||
|
|
// Remove null bytes
|
||
|
|
sanitized = sanitized.replace(/\0/g, '');
|
||
|
|
sanitized = sanitized.replace(/%00/g, '');
|
||
|
|
|
||
|
|
// Remove invalid Windows characters
|
||
|
|
sanitized = sanitized.replace(/[<>:"|?*]/g, '_');
|
||
|
|
|
||
|
|
// Normalize slashes
|
||
|
|
sanitized = sanitized.replace(/\\/g, '/');
|
||
|
|
|
||
|
|
// Remove double slashes
|
||
|
|
sanitized = sanitized.replace(/\/+/g, '/');
|
||
|
|
|
||
|
|
// Trim whitespace
|
||
|
|
sanitized = sanitized.trim();
|
||
|
|
|
||
|
|
// Truncate if too long
|
||
|
|
if (sanitized.length > this.MAX_PATH_LENGTH) {
|
||
|
|
sanitized = sanitized.substring(0, this.MAX_PATH_LENGTH);
|
||
|
|
}
|
||
|
|
|
||
|
|
return sanitized;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if path is trying to escape the base directory
|
||
|
|
* 检查路径是否试图逃离基础目录
|
||
|
|
*/
|
||
|
|
static isPathTraversal(path: string): boolean {
|
||
|
|
const normalized = path.replace(/\\/g, '/');
|
||
|
|
return normalized.includes('../') || normalized.includes('..\\');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Normalize a path for consistent handling
|
||
|
|
* 规范化路径以便一致处理
|
||
|
|
*/
|
||
|
|
static normalize(path: string): string {
|
||
|
|
if (!path) return '';
|
||
|
|
|
||
|
|
// Sanitize first
|
||
|
|
let normalized = this.sanitize(path);
|
||
|
|
|
||
|
|
// Convert backslashes to forward slashes
|
||
|
|
normalized = normalized.replace(/\\/g, '/');
|
||
|
|
|
||
|
|
// Remove trailing slash (except for root)
|
||
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
||
|
|
normalized = normalized.slice(0, -1);
|
||
|
|
}
|
||
|
|
|
||
|
|
return normalized;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Join path segments safely
|
||
|
|
* 安全地连接路径段
|
||
|
|
*/
|
||
|
|
static join(...segments: string[]): string {
|
||
|
|
const validSegments = segments
|
||
|
|
.filter((s) => s && typeof s === 'string')
|
||
|
|
.map((s) => this.sanitize(s))
|
||
|
|
.filter((s) => s.length > 0);
|
||
|
|
|
||
|
|
if (validSegments.length === 0) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
|
||
|
|
return this.normalize(validSegments.join('/'));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get file extension safely
|
||
|
|
* 安全地获取文件扩展名
|
||
|
|
*/
|
||
|
|
static getExtension(path: string): string {
|
||
|
|
const sanitized = this.sanitize(path);
|
||
|
|
const lastDot = sanitized.lastIndexOf('.');
|
||
|
|
const lastSlash = sanitized.lastIndexOf('/');
|
||
|
|
|
||
|
|
if (lastDot > lastSlash && lastDot > 0) {
|
||
|
|
return sanitized.substring(lastDot + 1).toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
}
|