wpmind/modules/api-gateway/includes/RateLimit/TransientRateStore.php
LinuxJoy 36b69d300d security: API Gateway 全量代码审计修复 (59 issues)
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>
2026-02-09 01:14:12 +08:00

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;
}
}