CaiWuXiTong/modules/EcommerceFinance/Services/UpstreamReadOnlyQuery.php

87 lines
3.0 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use InvalidArgumentException;
use PDO;
/**
* 新上游适配器的只读 SQL 入口。
*
* 账号本身是否拥有写权限不能作为应用安全边界,因此这里只暴露查询方法,
* 并在连接上执行前拒绝多语句、写操作、存储过程和可产生副作用的语法。
*/
class UpstreamReadOnlyQuery
{
public function __construct(private readonly string $source)
{
}
public function sourceKey(): string
{
return $this->source;
}
/** @return array<int,array<string,mixed>> */
public function select(string $sql, array $bindings = []): array
{
self::assertReadOnlySql($sql);
$statement = UpstreamConnection::get($this->source)->prepare($sql);
$statement->execute($bindings);
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
/** @return array<string,mixed>|null */
public function first(string $sql, array $bindings = []): ?array
{
$rows = $this->select($sql, $bindings);
return $rows[0] ?? null;
}
public function scalar(string $sql, array $bindings = []): mixed
{
self::assertReadOnlySql($sql);
$statement = UpstreamConnection::get($this->source)->prepare($sql);
$statement->execute($bindings);
return $statement->fetchColumn();
}
public static function assertReadOnlySql(string $sql): void
{
$sql = trim(ltrim($sql, "\xEF\xBB\xBF"));
if ($sql === '' || str_contains($sql, ';')) {
throw new InvalidArgumentException('上游查询只允许单条只读 SELECT。');
}
// 禁止注释,避免通过注释拆词绕过关键字检查,也便于代码审查。
if (preg_match('/--|\/\*|\*\//', $sql) === 1) {
throw new InvalidArgumentException('上游查询不得包含 SQL 注释。');
}
// 去掉字符串和带引号标识符后再检查关键字,避免普通文本误报。
$inspect = preg_replace("/'(?:''|[^'])*'/s", "''", $sql) ?? $sql;
$inspect = preg_replace('/\[(?:\]\]|[^\]])*\]/s', '[]', $inspect) ?? $inspect;
$inspect = preg_replace('/"(?:""|[^"])*"/s', '""', $inspect) ?? $inspect;
$inspect = preg_replace('/`(?:``|[^`])*`/s', '``', $inspect) ?? $inspect;
$inspect = strtoupper(ltrim($inspect));
if (preg_match('/^(SELECT|WITH)\b/', $inspect) !== 1) {
throw new InvalidArgumentException('上游查询只允许 SELECT 或只读 CTE。');
}
$forbidden = '/\b(?:INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|'
. 'GRANT|REVOKE|DENY|BACKUP|RESTORE|DBCC|BULK|OPENROWSET|OPENDATASOURCE|INTO|'
. 'WAITFOR|KILL|SHUTDOWN|SET_CONFIG|NEXTVAL|SETVAL|SLEEP|BENCHMARK|GET_LOCK|RELEASE_LOCK|LOAD_FILE|PG_SLEEP)\b|\bNEXT\s+VALUE\s+FOR\b/i';
if (preg_match($forbidden, $inspect) === 1) {
throw new InvalidArgumentException('上游查询包含禁止的写入或高风险语法。');
}
}
}