wpmind/modules/api-gateway/includes/RateLimit/RedisRateStore.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

132 lines
No EOL
3.5 KiB
PHP

<?php
/**
* Redis Rate Store
*
* Sliding-window rate limiter backed by Redis sorted sets.
*
* @package WPMind\Modules\ApiGateway\RateLimit
* @since 1.0.0
*/
declare(strict_types=1);
namespace WPMind\Modules\ApiGateway\RateLimit;
/**
* Class RedisRateStore
*
* Uses a Lua script for atomic sliding-window rate limiting via Redis.
* Requires a Redis object cache drop-in (e.g. object-cache.php with Redis).
*/
final class RedisRateStore implements RateStoreInterface {
private \Redis $redis;
/**
* Lua script for atomic sliding-window consume.
*
* KEYS[1] = sorted set key
* ARGV[1] = window start (now - window_sec)
* ARGV[2] = now (score for new entry)
* ARGV[3] = member value "{rid}:{cost}"
* ARGV[4] = limit
* ARGV[5] = TTL in seconds
* ARGV[6] = cost
*
* Members are stored as "{rid}:{cost}" so we can sum actual costs.
* Returns: { allowed (0|1), remaining, reset_epoch }
*/
private const LUA_CONSUME = <<<'LUA'
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
local members = redis.call('ZRANGE', KEYS[1], 0, -1)
local used = 0
for _, m in ipairs(members) do
local c = tonumber(string.match(m, ':(%d+)$')) or 1
used = used + c
end
local limit = tonumber(ARGV[4])
local cost = tonumber(ARGV[6])
if used + cost <= limit then
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5]))
return {1, limit - used - cost, tonumber(ARGV[2]) + tonumber(ARGV[5])}
end
return {0, limit - used, tonumber(ARGV[2]) + tonumber(ARGV[5])}
LUA;
public function __construct() {
$this->redis = $this->get_redis_instance();
}
/**
* {@inheritDoc}
*/
public function consume( string $key, int $window_sec, int $cost, int $limit, string $rid, int $now ): RateStoreResult {
if ( str_contains( $rid, ':' ) ) {
throw new \InvalidArgumentException( 'Request ID must not contain colons.' );
}
$window_start = $now - $window_sec;
$member = "{$rid}:{$cost}";
$ttl = $window_sec * 2;
/** @var array $result */
$result = $this->redis->eval(
self::LUA_CONSUME,
[ $key, (string) $window_start, (string) $now, $member, (string) $limit, (string) $ttl, (string) $cost ],
1
);
if ( ! is_array( $result ) || count( $result ) < 3 ) {
return new RateStoreResult( true, $limit, $now + $window_sec );
}
return new RateStoreResult(
(bool) $result[0],
max( 0, (int) $result[1] ),
(int) $result[2]
);
}
/**
* {@inheritDoc}
*/
public function rollback( string $key, string $rid ): void {
$iterator = null;
// Scan sorted set members to find entries matching the request ID prefix.
while ( $members = $this->redis->zScan( $key, $iterator, "{$rid}:*", 100 ) ) {
foreach ( array_keys( $members ) as $member ) {
$this->redis->zRem( $key, $member );
}
if ( $iterator === 0 ) {
break;
}
}
}
/**
* Obtain the underlying Redis instance from the WordPress object cache.
*
* @return \Redis
* @throws \RuntimeException If Redis is not available.
*/
private function get_redis_instance(): \Redis {
global $wp_object_cache;
// Try common Redis object cache drop-in patterns.
if ( isset( $wp_object_cache->redis ) && $wp_object_cache->redis instanceof \Redis ) {
return $wp_object_cache->redis;
}
if ( method_exists( $wp_object_cache, 'get_redis' ) ) {
$redis = $wp_object_cache->get_redis();
if ( $redis instanceof \Redis ) {
return $redis;
}
}
throw new \RuntimeException( 'Redis object cache is not available.' );
}
}