/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DEFAULT_SECURITY_CONFIG, SecurityConfig } from './config.js';
/**
* Validation result interface
*/
export interface ValidationResult {
isValid: boolean;
sanitizedValue?: string | number | string[] | Record<string, unknown>;
errors: string[];
}
/**
* Security validation error class
*/
export class SecurityValidationError extends Error {
public readonly code: string;
public readonly field: string;
constructor(message: string, code: string, field: string) {
super(message);
this.name = 'SecurityValidationError';
this.code = code;
this.field = field;
}
}
/**
* OWASP-compliant input validators
* Following allowlist validation approach with syntactic and semantic validation
*/
export class InputValidators {
private config: SecurityConfig;
constructor(config: SecurityConfig = DEFAULT_SECURITY_CONFIG) {
this.config = config;
}
/**
* Validate search query parameter
* Implements OWASP allowlist validation with length and character restrictions
*/
validateQuery(query: unknown): ValidationResult {
const errors: string[] = [];
// Type validation
if (typeof query !== 'string') {
return {
isValid: false,
errors: ['Query must be a string'],
};
}
// Length validation (syntactic)
if (query.length < this.config.input.query.minLength) {
errors.push(`Query too short (minimum ${this.config.input.query.minLength} characters)`);
}
if (query.length > this.config.input.query.maxLength) {
errors.push(`Query too long (maximum ${this.config.input.query.maxLength} characters)`);
}
// Character allowlist validation (syntactic)
if (!this.config.input.query.allowedPattern.test(query)) {
errors.push('Query contains invalid characters');
}
// Unicode normalization (OWASP recommendation)
let sanitizedQuery = query;
try {
sanitizedQuery = query.normalize('NFC');
} catch (error) {
errors.push('Invalid Unicode characters in query');
}
// Semantic validation - check for potential injection patterns
if (this.containsSuspiciousPatterns(sanitizedQuery)) {
errors.push('Query contains potentially malicious patterns');
}
return {
isValid: errors.length === 0,
sanitizedValue: errors.length === 0 ? sanitizedQuery.trim() : undefined,
errors,
};
}
/**
* Validate document reference path
* Prevents path traversal attacks and validates file extensions
*/
validateDocumentReference(docRef: unknown): ValidationResult {
const errors: string[] = [];
// Type validation
if (typeof docRef !== 'string') {
return {
isValid: false,
errors: ['Document reference must be a string'],
};
}
// Length validation
if (docRef.length > this.config.input.documentReference.maxLength) {
errors.push(`Document reference too long (maximum ${this.config.input.documentReference.maxLength} characters)`);
}
// Character allowlist validation
if (!this.config.input.documentReference.allowedPattern.test(docRef)) {
errors.push('Document reference contains invalid characters');
}
// Path traversal prevention (critical validation check)
if (this.config.input.documentReference.preventTraversal) {
if (this.containsPathTraversal(docRef)) {
errors.push('Document reference contains path traversal attempts');
}
}
// File extension validation (allowlist approach)
const extension = this.getFileExtension(docRef);
if (extension && !this.config.input.documentReference.allowedExtensions.includes(extension)) {
errors.push(`File extension '${extension}' is not allowed`);
}
// Absolute path prevention
if (docRef.startsWith('/') || /^[a-zA-Z]:\\/.test(docRef)) {
errors.push('Absolute paths are not allowed');
}
return {
isValid: errors.length === 0,
sanitizedValue: errors.length === 0 ? docRef.trim() : undefined,
errors,
};
}
/**
* Validate maxResults parameter
*/
validateMaxResults(maxResults: unknown): ValidationResult {
const errors: string[] = [];
// Allow undefined (optional parameter)
if (maxResults === undefined) {
return {
isValid: true,
sanitizedValue: this.config.input.maxResults.default,
errors: [],
};
}
// Type validation
if (typeof maxResults !== 'number') {
return {
isValid: false,
errors: ['maxResults must be a number'],
};
}
// Range validation
if (maxResults < this.config.input.maxResults.min) {
errors.push(`maxResults too small (minimum ${this.config.input.maxResults.min})`);
}
if (maxResults > this.config.input.maxResults.max) {
errors.push(`maxResults too large (maximum ${this.config.input.maxResults.max})`);
}
// Integer validation
if (!Number.isInteger(maxResults)) {
errors.push('maxResults must be an integer');
}
return {
isValid: errors.length === 0,
sanitizedValue: errors.length === 0 ? Math.floor(maxResults) : undefined,
errors,
};
}
/**
* Validate documentation path array
*/
validateDocumentationPath(docPath: unknown): ValidationResult {
const errors: string[] = [];
// Allow undefined (optional parameter)
if (docPath === undefined) {
return {
isValid: true,
sanitizedValue: undefined,
errors: [],
};
}
// Type validation
if (!Array.isArray(docPath)) {
return {
isValid: false,
errors: ['Documentation path must be an array'],
};
}
// Size validation
if (docPath.length > this.config.input.documentationPath.maxItems) {
errors.push(`Too many documentation paths (maximum ${this.config.input.documentationPath.maxItems})`);
}
// Individual item validation
const sanitizedPaths: string[] = [];
for (let i = 0; i < docPath.length; i++) {
const item = docPath[i];
if (typeof item !== 'string') {
errors.push(`Documentation path item ${i} must be a string`);
continue;
}
// Allowlist validation
if (!this.config.input.documentationPath.allowedValues.includes(item)) {
errors.push(`Documentation path '${item}' is not allowed`);
continue;
}
sanitizedPaths.push(item);
}
return {
isValid: errors.length === 0,
sanitizedValue: errors.length === 0 ? sanitizedPaths : undefined,
errors,
};
}
/**
* Check for suspicious patterns that might indicate injection attempts
* This is a denylist as additional defense layer, not primary protection
*/
private containsSuspiciousPatterns(input: string): boolean {
const suspiciousPatterns = [
/<script\b/i, // Script tags
/<\/script>/i, // Closing script tags
/javascript:/i, // JavaScript protocol
/vbscript:/i, // VBScript protocol
/on\w+\s*=/i, // Event handlers
/expression\s*\(/i, // CSS expressions
/\beval\s*\(/i, // eval() calls
/\bexec\s*\(/i, // exec() calls
/\x00/, // Null bytes (fixed from \\\x00)
/<iframe\b/i, // iframe injection
/<object\b/i, // object injection
/\bselect\b.*\bfrom\b/i, // SQL SELECT
/\bunion\b.*\bselect\b/i, // SQL UNION
];
// Check original input
if (suspiciousPatterns.some(pattern => pattern.test(input))) {
return true;
}
// URL decode and check again to catch encoded attacks like java%73cript
try {
const decoded = decodeURIComponent(input);
if (decoded !== input && suspiciousPatterns.some(pattern => pattern.test(decoded))) {
return true;
}
} catch (error) {
// If decoding fails, consider it suspicious
return true;
}
return false;
}
/**
* Check for path traversal patterns
*/
private containsPathTraversal(path: string): boolean {
const traversalPatterns = [
/\.\.\//, // ../
/\.\.\\/, // ..\
/%2e%2e%2f/i, // URL encoded ../
/%2e%2e%5c/i, // URL encoded ..\
/\.\./, // .. (general)
];
return traversalPatterns.some(pattern => pattern.test(path));
}
/**
* Extract file extension from path
*/
private getFileExtension(path: string): string | null {
const match = path.match(/\.([a-zA-Z0-9]+)$/);
return match ? `.${match[1].toLowerCase()}` : null;
}
}
/**
* Global validator instance with default configuration
*/
export const validators = new InputValidators();
/**
* Convenience function to validate all search-documentation parameters
*/
export function validateSearchDocumentationInput(params: {
query: unknown;
maxResults?: unknown;
documentationPath?: unknown;
}): ValidationResult {
const queryResult = validators.validateQuery(params.query);
const maxResultsResult = validators.validateMaxResults(params.maxResults);
const docPathResult = validators.validateDocumentationPath(params.documentationPath);
const allErrors = [
...queryResult.errors,
...maxResultsResult.errors,
...docPathResult.errors,
];
return {
isValid: allErrors.length === 0,
sanitizedValue: allErrors.length === 0 ? {
query: queryResult.sanitizedValue as string,
maxResults: maxResultsResult.sanitizedValue as number,
documentationPath: docPathResult.sanitizedValue as string[] | undefined,
} : undefined,
errors: allErrors,
};
}
/**
* Convenience function to validate read-documentation parameters
*/
export function validateReadDocumentationInput(params: {
documentReference: unknown;
}): ValidationResult {
return validators.validateDocumentReference(params.documentReference);
}