/*
* 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';
/**
* OWASP-compliant output encoders
* Provides context-aware encoding for different output contexts
*/
export class OutputEncoders {
private config: SecurityConfig;
constructor(config: SecurityConfig = DEFAULT_SECURITY_CONFIG) {
this.config = config;
}
/**
* JSON-safe encoding for content that will be included in JSON responses
* Prevents JSON injection and malformed JSON
*/
encodeJson(input: string): string {
if (!this.config.output.enableJsonSanitization) {
return input;
}
return input
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\f/g, '\\f')
.replace(/\u0000/g, '\\u0000'); // Null byte
}
/**
* URL encoding for content that will be included in URLs
*/
encodeUrl(input: string): string {
return encodeURIComponent(input);
}
/**
* Sanitize API response content recursively
* Applies appropriate encoding based on content type and context
*/
sanitizeApiResponse(response: unknown): unknown {
if (typeof response === 'string') {
return this.sanitizeString(response);
}
if (Array.isArray(response)) {
return response.map(item => this.sanitizeApiResponse(item));
}
if (response && typeof response === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(response)) {
sanitized[this.sanitizeString(key)] = this.sanitizeApiResponse(value);
}
return sanitized;
}
return response;
}
/**
* Context-aware sanitization with minimal encoding for mixed JSON/text content
* Preserves JSON readability while preventing dangerous attacks
*/
private sanitizeString(input: string): string {
return this.contextAwareSanitize(input);
}
/**
* Minimal safe encoding that preserves JSON formatting
* Only encodes the most dangerous characters while keeping content readable
*/
private contextAwareSanitize(input: string): string {
let sanitized = input;
// Remove null bytes and control characters (except common whitespace)
sanitized = sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
// Normalize Unicode to prevent bypass attempts
try {
sanitized = sanitized.normalize('NFC');
} catch (error) {
// If normalization fails, use the original string
}
// Minimal HTML encoding - only encode the most dangerous chars
// Preserve quotes and slashes for JSON readability
return sanitized
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&(?!quot;|#x2F;|#x27;|lt;|gt;|amp;)/g, '&'); // Only encode & that aren't already encoded
}
/**
* Sanitize error messages to prevent information leakage
* Removes sensitive information while preserving debugging capability
*/
sanitizeErrorMessage(error: Error | string, includeStack: boolean = false): string {
const message = typeof error === 'string' ? error : error.message;
// Remove potentially sensitive information patterns
let sanitizedMessage = message
.replace(/\b\d{4}-\d{4}-\d{4}-\d{4}\b/g, '[REDACTED]') // Credit card-like patterns
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[EMAIL]') // Email addresses
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP]') // IP addresses
.replace(/\/[^\s]*\/[^\s]*/g, '[PATH]') // File paths
.replace(/password|secret|key|token/gi, '[CREDENTIAL]'); // Credential terms
// Apply basic sanitization
sanitizedMessage = this.sanitizeString(sanitizedMessage);
// Include stack trace if requested and error is an Error object
if (includeStack && typeof error === 'object' && error.stack) {
const sanitizedStack = this.sanitizeString(error.stack);
return `${sanitizedMessage}\nStack: ${sanitizedStack}`;
}
return sanitizedMessage;
}
/**
* Check if response size is within limits
*/
validateResponseSize(content: string): boolean {
const sizeInBytes = new TextEncoder().encode(content).length;
return sizeInBytes <= this.config.output.maxResponseSize;
}
/**
* Truncate response if it exceeds size limits
*/
truncateResponse(content: string): string {
if (this.validateResponseSize(content)) {
return content;
}
const maxSize = this.config.output.maxResponseSize;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// Binary search to find the maximum length that fits within the byte limit
let low = 0;
let high = content.length;
let result = content;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const truncated = content.substring(0, mid);
const size = encoder.encode(truncated).length;
if (size <= maxSize) {
result = truncated;
low = mid + 1;
} else {
high = mid - 1;
}
}
return result + '... [TRUNCATED]';
}
}
/**
* Global encoder instance with default configuration
*/
export const encoders = new OutputEncoders();
/**
* Convenience function to process search documentation responses
*/
export function processSearchResponse(response: {
results?: Array<{
text: string;
score: number;
documentReference: string;
}>;
}): unknown {
return encoders.sanitizeApiResponse(response);
}
/**
* Convenience function to process read documentation responses
*/
export function processReadResponse(response: unknown): unknown {
return encoders.sanitizeApiResponse(response);
}
/**
* Convenience function to create error responses
*/
export function createErrorResponse(error: Error | string): {
content: Array<{ type: string; text: string }>;
isError: boolean;
} {
const processedMessage = encoders.sanitizeErrorMessage(error, false);
return {
content: [
{
type: 'text',
text: `Error: ${processedMessage}`,
},
],
isError: true,
};
}