87 lines
3.0 KiB
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('上游查询包含禁止的写入或高风险语法。');
|
|
}
|
|
}
|
|
}
|