Codex 审计发现 124 个问题,修复 59 个 P0-P2 级别问题: P0 (CRITICAL): - TOCTOU 竞态: TransientRateStore/SseConcurrencyGuard 改用 wp_cache_add 原子锁 - REST 路由: __return_true → check_bearer_present 轻量鉴权 - Schema 验证: messages/tools/tool_choice 添加完整 validate_callback - SQL 列名不匹配: upsert 查询对齐 SchemaManager 定义 - SQL 表名插值: 统一使用 %i 占位符 P1 (HIGH): - IP hash 加盐 (HMAC) + IP 解析统一 (context 共享) - Header 注入防护 (X-Request-Id/响应头 \r\n 过滤) - 数值参数范围校验 (temperature/top_p/penalties/max_tokens) - insert_key 错误处理 + revoke_key 审计日志 - Pipeline finally 保护 + RateLimiter rollback 双 store - wp_unslash 补全 + strtotime 返回值检查 - RedisRateStore $rid 冒号验证 - UpstreamStreamClient catch \Throwable P2 (MEDIUM): - AuditLogRepository DRY (提取 build_where_clause) - Token 估算 CJK 优化 (mb_strlen) - ResponseTransformMiddleware null 检查 - 嵌入响应 usage 数据修复 - SchemaManager autoload 优化 25 files changed, ~530 insertions, ~190 deletions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
4.5 KiB
PHP
186 lines
4.5 KiB
PHP
<?php
|
|
/**
|
|
* Transient Rate Store
|
|
*
|
|
* Sliding-window rate limiter using WordPress transients as fallback.
|
|
*
|
|
* @package WPMind\Modules\ApiGateway\RateLimit
|
|
* @since 1.0.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace WPMind\Modules\ApiGateway\RateLimit;
|
|
|
|
/**
|
|
* Class TransientRateStore
|
|
*
|
|
* Fallback rate limiter when Redis is unavailable.
|
|
* Uses WordPress transients with a simple spin-lock for atomicity.
|
|
*/
|
|
final class TransientRateStore implements RateStoreInterface {
|
|
|
|
/**
|
|
* Lock TTL in seconds.
|
|
*
|
|
* @var int
|
|
*/
|
|
private const LOCK_TTL = 2;
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function consume( string $key, int $window_sec, int $cost, int $limit, string $rid, int $now ): RateStoreResult {
|
|
$lock_key = 'wpmind_rl_lock_' . md5( $key );
|
|
$reset = $now + $window_sec;
|
|
|
|
if ( ! $this->acquire_lock( $lock_key ) ) {
|
|
error_log( '[WPMind] TransientRateStore: lock acquisition failed for ' . $key . ', failing open.' );
|
|
return new RateStoreResult( true, $limit, $reset );
|
|
}
|
|
|
|
try {
|
|
$entries = $this->get_entries( $key );
|
|
$entries = $this->prune_expired( $entries, $now, $window_sec );
|
|
$current_count = $this->sum_costs( $entries );
|
|
|
|
if ( $current_count + $cost > $limit ) {
|
|
$this->save_entries( $key, $entries, $window_sec );
|
|
return new RateStoreResult( false, max( 0, $limit - $current_count ), $reset );
|
|
}
|
|
|
|
$entries[] = [
|
|
'rid' => $rid,
|
|
'cost' => $cost,
|
|
'time' => $now,
|
|
];
|
|
|
|
$this->save_entries( $key, $entries, $window_sec );
|
|
|
|
return new RateStoreResult( true, max( 0, $limit - $current_count - $cost ), $reset );
|
|
} finally {
|
|
$this->release_lock( $lock_key );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function rollback( string $key, string $rid ): void {
|
|
$lock_key = 'wpmind_rl_lock_' . md5( $key );
|
|
|
|
if ( ! $this->acquire_lock( $lock_key ) ) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$entries = $this->get_entries( $key );
|
|
$filtered = array_values(
|
|
array_filter(
|
|
$entries,
|
|
static fn( array $entry ): bool => $entry['rid'] !== $rid
|
|
)
|
|
);
|
|
|
|
if ( count( $filtered ) !== count( $entries ) ) {
|
|
$this->save_entries( $key, $filtered, 120 );
|
|
}
|
|
} finally {
|
|
$this->release_lock( $lock_key );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempt to acquire a transient-based lock.
|
|
*
|
|
* @param string $lock_key Transient key for the lock.
|
|
* @return bool True if lock acquired.
|
|
*/
|
|
private function acquire_lock( string $lock_key ): bool {
|
|
if ( wp_using_ext_object_cache() ) {
|
|
return wp_cache_add( $lock_key, 1, 'wpmind_rate', self::LOCK_TTL );
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
return (bool) $wpdb->query( $wpdb->prepare(
|
|
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
|
|
'_transient_' . $lock_key,
|
|
time()
|
|
) );
|
|
}
|
|
|
|
/**
|
|
* Release a transient-based lock.
|
|
*
|
|
* @param string $lock_key Transient key for the lock.
|
|
*/
|
|
private function release_lock( string $lock_key ): void {
|
|
if ( wp_using_ext_object_cache() ) {
|
|
wp_cache_delete( $lock_key, 'wpmind_rate' );
|
|
return;
|
|
}
|
|
|
|
delete_transient( $lock_key );
|
|
}
|
|
|
|
/**
|
|
* Get stored rate-limit entries from a transient.
|
|
*
|
|
* @param string $key Bucket identifier.
|
|
* @return array<int, array{rid: string, cost: int, time: int}>
|
|
*/
|
|
private function get_entries( string $key ): array {
|
|
$transient_key = 'wpmind_rl_' . md5( $key );
|
|
$data = get_transient( $transient_key );
|
|
|
|
return is_array( $data ) ? $data : [];
|
|
}
|
|
|
|
/**
|
|
* Save rate-limit entries to a transient.
|
|
*
|
|
* @param string $key Bucket identifier.
|
|
* @param array $entries Entry list.
|
|
* @param int $window_sec TTL for the transient.
|
|
*/
|
|
private function save_entries( string $key, array $entries, int $window_sec ): void {
|
|
$transient_key = 'wpmind_rl_' . md5( $key );
|
|
set_transient( $transient_key, $entries, $window_sec * 2 );
|
|
}
|
|
|
|
/**
|
|
* Remove entries older than the sliding window.
|
|
*
|
|
* @param array $entries Current entries.
|
|
* @param int $now Current timestamp.
|
|
* @param int $window_sec Window size in seconds.
|
|
* @return array Pruned entries.
|
|
*/
|
|
private function prune_expired( array $entries, int $now, int $window_sec ): array {
|
|
$cutoff = $now - $window_sec;
|
|
|
|
return array_values(
|
|
array_filter(
|
|
$entries,
|
|
static fn( array $entry ): bool => $entry['time'] > $cutoff
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sum the cost of all entries.
|
|
*
|
|
* @param array $entries Entry list.
|
|
* @return int Total cost.
|
|
*/
|
|
private function sum_costs( array $entries ): int {
|
|
$total = 0;
|
|
|
|
foreach ( $entries as $entry ) {
|
|
$total += (int) $entry['cost'];
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
}
|