Внешние БД: пустой список таблиц = доступ ко всем (allowlist опционален)

Основная отсечка прав — на уровне пользователя внешней БД (GRANT SELECT);
allowlist в подключении теперь опциональное дополнение (было обязательным).
This commit is contained in:
2026-09-30 00:16:36 +03:00
parent 98499ca9d4
commit 67b71ffd0e
2 changed files with 19 additions and 17 deletions

View File

@@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -139,7 +138,8 @@ export function ExternalDbSettings() {
</CardTitle> </CardTitle>
<CardDescription className="text-xs mt-1"> <CardDescription className="text-xs mt-1">
Read-only подключения для отчётов и виджетов. Запросы через шлюз: только SELECT, Read-only подключения для отчётов и виджетов. Запросы через шлюз: только SELECT,
только таблицы из списка, лимит 5000 строк. Каждый запрос пишется в журнал. лимит 5000 строк. Ограничение по таблицам опционально (пусто = все таблицы).
Каждый запрос пишется в журнал.
</CardDescription> </CardDescription>
</div> </div>
<Button size="sm" onClick={openCreate} className="gap-1"> <Button size="sm" onClick={openCreate} className="gap-1">
@@ -175,7 +175,7 @@ export function ExternalDbSettings() {
{c.allowedTables.join(", ")} {c.allowedTables.join(", ")}
</span> </span>
) : ( ) : (
<Badge variant="destructive" className="text-[10px]">шлюз закрыт</Badge> <span className="text-muted-foreground">все таблицы</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -260,14 +260,15 @@ export function ExternalDbSettings() {
<Input type="password" value={form.password} onChange={set("password")} autoComplete="new-password" /> <Input type="password" value={form.password} onChange={set("password")} autoComplete="new-password" />
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<Label className="text-xs">Разрешённые таблицы (через запятую)</Label> <Label className="text-xs">Разрешённые таблицы (через запятую; пусто = все таблицы)</Label>
<Input <Input
value={form.allowedTables} value={form.allowedTables}
onChange={set("allowedTables")} onChange={set("allowedTables")}
placeholder="nds_monthly, nds_input_balance, nds_sync_log, nds_contract_marker, nds_advance_vat_tracker" placeholder="Пусто — доступ ко всем таблицам"
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Только эти таблицы доступны через read-only шлюз. Пусто = шлюз закрыт. Опционально: ограничьте список, если не хотите давать доступ ко всей схеме.
Основную отсечку делайте правами пользователя БД (GRANT SELECT).
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -4,11 +4,11 @@
// //
// Безопасность: // Безопасность:
// - пароль шифруется AES-256-GCM, по API не отдаётся (только маска); // - пароль шифруется AES-256-GCM, по API не отдаётся (только маска);
// - шлюз: только SELECT, без мульти-выражений, allowlist таблиц на подключение, // - шлюз: только SELECT, без мульти-выражений, принудительный LIMIT (обёртка подзапросом),
// принудительный LIMIT (обёртка подзапросом), statement_timeout 10 с; // statement_timeout 10 с; опциональный allowlist таблиц (пустой = все таблицы);
// - каждый запрос пишется в external_db_query_log (кто/когда/SQL/строки/мс); // - каждый запрос пишется в external_db_query_log (кто/когда/SQL/строки/мс);
// - рекомендуется заводить во внешней БД пользователя с реальным GRANT SELECT // - основной рубеж — пользователь внешней БД с реальным GRANT SELECT ( defence in depth:
// только на нужные таблицы (defence in depth — шлюз не заменяет права БД). // шлюз не заменяет права БД).
import type { Express, Response } from "express"; import type { Express, Response } from "express";
import { Router } from "express"; import { Router } from "express";
@@ -270,14 +270,15 @@ export function registerExternalDbRoutes(app: Express): void {
const cleaned = validateReadOnly(sqlText); const cleaned = validateReadOnly(sqlText);
const allowed = (conn.allowed_tables ?? []).map((t) => String(t).toLowerCase()); const allowed = (conn.allowed_tables ?? []).map((t) => String(t).toLowerCase());
if (allowed.length === 0) { // Пустой список = доступ ко ВСЕМ таблицам (ограничение — опционально;
return res.status(403).json({ error: "Для подключения не задан список разрешённых таблиц" }); // основной рубеж — права самого пользователя внешней БД, GRANT SELECT).
} if (allowed.length > 0) {
const usedTables = extractTableNames(stripStringLiterals(cleaned)); const usedTables = extractTableNames(stripStringLiterals(cleaned));
const denied = usedTables.filter((t) => !allowed.includes(t)); const denied = usedTables.filter((t) => !allowed.includes(t));
if (denied.length > 0) { if (denied.length > 0) {
return res.status(403).json({ error: `Таблицы не в allowlist: ${denied.join(", ")}` }); return res.status(403).json({ error: `Таблицы не в allowlist: ${denied.join(", ")}` });
} }
}
const wrapped = `SELECT * FROM (${cleaned}) AS __gateway_q LIMIT ${MAX_ROWS}`; const wrapped = `SELECT * FROM (${cleaned}) AS __gateway_q LIMIT ${MAX_ROWS}`;
const result = await getPool(conn).query(wrapped); const result = await getPool(conn).query(wrapped);