Zapobieganie wstrzykiwaniu SQL w Node.js, Pythonie i Go
Podatne i poprawione fragmenty SQL dla pg, Prismy, psycopg, SQLAlchemy i database/sql w Go, plus bezpieczne dynamiczne ORDER BY i listy IN. Zmapowane na CWE-89.
· 7 min czytania · Lina Source LLC
Wstrzykiwanie SQL (CWE-89) to jeden z najstarszych błędów w sieci i wciąż jeden z najbardziej niszczycielskich. Przyczyna nigdy się nie zmieniła: dane od użytkownika są wklejane w tekst zapytania, więc baza danych nie potrafi odróżnić danych od kodu. Poprawka też się nie zmieniła: wysyłaj zapytanie i wartości osobno i pozwól sterownikowi je powiązać.
Zmieniło się to, gdzie mieszka ten błąd. Większość zespołów używa ORM-a do codziennych zapytań, więc wstrzyknięcie pojawia się dziś w furtkach awaryjnych: w surowym zapytaniu napisanym na potrzeby raportu, w endpoincie wyszukiwania z dynamicznym sortowaniem, w skrypcie migracji, w narzędziu administracyjnym. Ten przewodnik przechodzi przez wersję podatną i poprawioną w trzech ekosystemach, a potem omawia dwa przypadki, których same parametry nie rozwiązują.
Skutki rzadko ograniczają się do jednej tabeli. Pojedyncze podatne zapytanie zwykle działa z pełnymi uprawnieniami aplikacji do bazy danych, więc może odczytać dane wszystkich klientów, w tym hasze haseł i tokeny API, zmieniać wiersze, a w niektórych bazach sięgnąć do systemu plików lub innych serwerów. Techniki ślepe wyciągają dane bit po bicie nawet wtedy, gdy wynik zapytania nigdy nie jest pokazywany użytkownikowi, więc endpoint zwracający tylko prawdę lub fałsz wciąż da się wykorzystać.
Dlaczego parametry działają, a escapowanie nie
Przy zapytaniu parametryzowanym sterownik wysyła tekst SQL z symbolami zastępczymi, a wartości podróżują jako osobne dane. Baza danych parsuje instrukcję, zanim w ogóle zobaczy wartość, więc apostrof czy średnik w danych wejściowych to po prostu znak w łańcuchu. Ręczne escapowanie próbuje uczynić wartość bezpieczną do wklejenia w tekst SQL i zawodzi na kodowaniach, kontekstach liczbowych, identyfikatorach i każdym przypadku brzegowym, o którym nie pomyślałeś. Nie escapuj; wiąż.
Node.js: pg i Prisma
W node-postgres podatnym wzorcem jest szablon literalny przekazany do query. Poprawką jest symbol zastępczy $1 i tablica wartości. Prisma jest domyślnie bezpieczna, ale ma dwa surowe API i tylko jedno z nich jest bezpieczne przy interpolacji.
import { Pool } from "pg";
import { PrismaClient } from "@prisma/client";
const pool = new Pool();
const prisma = new PrismaClient();
// Podatne: dane wejściowe stają się częścią tekstu SQL
await pool.query(`SELECT id, email FROM users WHERE email = '${email}'`);
// Poprawione: wartość jest wiązana jako parametr
await pool.query("SELECT id, email FROM users WHERE email = $1", [email]);
// Prisma: $queryRaw to tagowany szablon, interpolacje stają się parametrami
await prisma.$queryRaw`SELECT id, email FROM users WHERE email = ${email}`;
// Prisma: $queryRawUnsafe z konkatenacją pozwala na wstrzyknięcie
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);
// Jeśli musisz użyć $queryRawUnsafe, przekazuj wartości jako argumenty
await prisma.$queryRawUnsafe(
"SELECT id, email FROM users WHERE email = $1",
email
);Subtelna pułapka Prismy: zbudowanie najpierw łańcucha zapytania i przekazanie go do $queryRaw traci całą ochronę. Bezpieczeństwo bierze się ze składni tagowanego szablonu. Jeśli musisz składać fragmenty, użyj Prisma.sql i Prisma.join, które zachowują wartości jako parametry. Ta sama reguła dotyczy innych bibliotek z tagowanymi szablonami, takich jak postgres.js i slonik: to tag sprawia, że interpolacja jest bezpieczna, więc łańcuch zapytania złożony wcześniej i przekazany jako zwykła wartość ją omija.
Python: psycopg i SQLAlchemy
W Pythonie niebezpiecznymi narzędziami są f-stringi, operator % i str.format zastosowane do SQL. psycopg używa symboli %s, które wyglądają jak formatowanie łańcuchów, ale nim nie są: wartości trafiają do drugiego argumentu execute, nigdy przez operator %. Konstrukcja text() z SQLAlchemy jest bezpieczna, gdy używasz nazwanych parametrów wiązanych, i niebezpieczna, gdy wstawiasz wartości do łańcucha.
Jeden szczegół psycopg zaskakuje wielu: symbol zastępczy to %s (albo %(name)s dla wartości nazwanych) niezależnie od typu kolumny i nigdy nie dodaje się wokół niego cudzysłowów. Napisanie '%s' w apostrofach zamienia parametr z powrotem w część literału tekstowego i psuje zapytanie. To samo dotyczy nazwanych parametrów w SQLAlchemy: :status, a nie ':status'.
from sqlalchemy import text
# psycopg: podatne
cur.execute(f"SELECT id, email FROM users WHERE email = '{email}'")
# psycopg: poprawione, wartości przekazane osobno
cur.execute("SELECT id, email FROM users WHERE email = %s", (email,))
# SQLAlchemy text(): podatne
conn.execute(text(f"SELECT id FROM orders WHERE status = '{status}'"))
# SQLAlchemy text(): poprawione z parametrem wiązanym
conn.execute(
text("SELECT id FROM orders WHERE status = :status"),
{"status": status},
)Go: database/sql
Pakiet database/sql w Go natywnie obsługuje symbole zastępcze, ale ich składnia zależy od sterownika: $1 dla sterowników PostgreSQL, takich jak pgx, oraz ? dla MySQL i SQLite. Podatnym wzorcem jest fmt.Sprintf lub konkatenacja łańcuchów przy budowaniu klauzuli WHERE. Ponieważ Go jest typowany statycznie, kuszące jest założenie, że parametr typu int nie może być groźny. To prawda w odniesieniu do samej wartości, ale nawyk budowania zapytań przez Sprintf przenosi się na sąsiadujące parametry tekstowe, więc trzymaj każde zapytanie na symbolach zastępczych.
// Podatne: konkatenacja łańcuchów
q := "SELECT id, email FROM users WHERE email = '" + email + "'"
rows, err := db.QueryContext(ctx, q)
// Poprawione: symbol zastępczy plus argument (składnia PostgreSQL; MySQL używa ?)
var u User
err = db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email = $1", email,
).Scan(&u.ID, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
// nie znaleziono
}Furtki awaryjne w ORM-ach
ORM-y chronią konstruktor zapytań, a nie każdą metodę ORM-a. Oto miejsca, w które warto zajrzeć najpierw w dowolnej bazie kodu:
- Prisma: $queryRawUnsafe i $executeRawUnsafe oraz każde wywołanie $queryRaw, które dostaje gotowy łańcuch zamiast tagowanego szablonu.
- Sequelize i TypeORM: sequelize.query, wywołania .where() konstruktora zapytań z konkatenowanym łańcuchem oraz surowe klauzule order i group.
- Knex: knex.raw i whereRaw z interpolowanymi wartościami zamiast wiązań ?.
- SQLAlchemy: text() z f-stringami oraz literal_column() lub nazwy kolumn pobierane z danych wejściowych.
- Django: .raw(), .extra() i cursor.execute z formatowanymi łańcuchami.
- GORM: Where, Order i Raw wywoływane z wynikiem fmt.Sprintf zamiast z argumentami.
Dynamiczne ORDER BY: parametry nie pomogą
Symbole zastępcze wiążą wartości, a nie identyfikatory. Nie możesz napisać ORDER BY $1 i przekazać nazwy kolumny; baza danych posortowałaby po stałej. Dlatego sortowalna tabela zwykle spycha programistów z powrotem do sklejania łańcuchów i właśnie tam wraca wstrzykiwanie. To samo ograniczenie dotyczy nazw tabel, nazw schematów i słów kluczowych SQL, takich jak ASC i DESC.
Bezpiecznym wzorcem jest lista dozwolonych wartości, która mapuje klucze sortowania widoczne dla użytkownika na znane nazwy kolumn. Dane wejściowe wybierają wpis; nigdy nie stają się SQL-em. Kierunek traktujemy tak samo: mapujemy go na stałe ASC lub DESC.
var sortColumns = map[string]string{
"created": "created_at",
"name": "name",
"price": "price_cents",
}
col, ok := sortColumns[r.URL.Query().Get("sort")]
if !ok {
col = "created_at"
}
dir := "ASC"
if r.URL.Query().Get("dir") == "desc" {
dir = "DESC"
}
// Bezpieczne: col i dir mogą przyjąć tylko wartości z powyższego kodu
query := fmt.Sprintf(
"SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1",
col, dir,
)
rows, err := db.QueryContext(ctx, query, limit)Jeśli naprawdę potrzebujesz dynamicznego identyfikatora, którego nie da się umieścić na liście dozwolonych, użyj cytowania identyfikatorów wbudowanego w sterownik, a nie własnego. W psycopg to sql.SQL(...).format(sql.Identifier(name)); w pgx to pgx.Identifier{name}.Sanitize(). Lista dozwolonych wartości jest mimo wszystko lepsza, bo cytowanie czyni nazwę bezpieczną, ale niekoniecznie poprawną czy dozwoloną. Zacytowany identyfikator nadal pozwala wywołującemu sortować lub filtrować po dowolnej kolumnie tabeli, także po takiej, której nigdy nie chciałeś udostępniać, jak hasz hasła czy wewnętrzny wskaźnik. Sortowanie po ukrytej kolumnie może ujawnić jej wartości przez kolejność wyników, jedno porównanie na raz.
Listy IN bez sklejania łańcuchów
Filtrowanie po liście identyfikatorów to druga popularna wymówka dla konkatenacji. Każdy większy stos ma na to bezpieczny sposób:
- PostgreSQL z pg lub pgx: przekaż całą listę jako jeden parametr tablicowy i napisz WHERE id = ANY($1). Sterownik wyśle ją jako tablicę Postgresa.
- Prisma: Prisma.sql`SELECT * FROM users WHERE id IN (${Prisma.join(ids)})` rozwija się do jednego symbolu zastępczego na element.
- SQLAlchemy: text("... WHERE id IN :ids").bindparams(bindparam("ids", expanding=True)), a następnie przekaż listę.
- Go z MySQL: wygeneruj łańcuch symboli zastępczych na podstawie długości listy (strings.Repeat("?,", n) z przyciętym końcem) i przekaż wartości jako argumenty. Generowane są wyłącznie symbole zastępcze, nigdy wartości.
- Odrzucaj puste listy przed uruchomieniem zapytania i ogranicz rozmiar listy, żeby endpointu nie dało się użyć do budowania ogromnych instrukcji.
Obrona w głąb
Parametryzacja jest właściwą poprawką. Poniższe środki zmniejszają skalę szkód, gdy jedno zapytanie się prześlizgnie:
- Łącz się z rolą bazodanową mającą tylko te uprawnienia, których aplikacja potrzebuje. Aplikacja webowa rzadko potrzebuje DROP, ALTER czy dostępu do innych schematów.
- Nie zwracaj klientom surowych błędów bazy danych. Wstrzykiwanie oparte na błędach polega właśnie na ich zobaczeniu.
- Waliduj typy na granicy systemu: identyfikator, który ma być liczbą całkowitą lub UUID, powinien zostać jako taki sparsowany, zanim dotrze do warstwy danych.
- Ustaw limit czasu instrukcji, co ogranicza zarówno ślepe wstrzykiwanie oparte na czasie, jak i rozbiegane zapytania.
Znajdowanie tych, które już masz
Zacznij od wyszukania wymienionych wyżej furtek awaryjnych, a potem słów kluczowych SQL w pobliżu formatowania łańcuchów: SELECT lub WHERE wewnątrz szablonów literalnych, f-stringów, Sprintf albo konkatenacji przez +. Każde trafienie jest albo poprawnie powiązane, albo zbudowane z listy dozwolonych wartości, albo jest błędem. Nie pomijaj ścieżek kodu, które wyglądają na wewnętrzne: importery CSV, zadania cron i panele administracyjne często przyjmują dane, które pierwotnie pochodziły od użytkownika, tylko o krok dalej. Wartości odczytane z Twojej własnej bazy danych mogą nieść ładunek zapisany wcześniej bezpiecznie, a potem wklejony do zapytania, co nazywa się wstrzykiwaniem drugiego rzędu. CodeAuditAgent śledzi to między plikami dla publicznych repozytoriów GitHub i wklejonego kodu oraz zgłasza znaleziska CWE-89 z zacytowanym zapytaniem, ścieżką, którą docierają do niego dane, i poprawioną wersją.
Zasada, z którą warto wyjść, jest krótka: wartości idą w parametrach, identyfikatory pochodzą z listy dozwolonych, a nic z żądania nigdy nie jest wklejane w tekst SQL.