SQL injection: co to jest i jak się przed nim bronić (Python, PHP, Next.js)

Mateusz Bogolubow

21-10-2026

(aktualizacja: 21-10-2026)

Jak działa SQL injection i jak się przed nim bronić: zapytania parametryzowane w Pythonie, PHP (PDO) i Next.js z better-sqlite3 oraz Drizzle, a do tego LIKE, IN i ORDER BY.

SQL injection: co to jest i jak się przed nim bronić (Python, PHP, Next.js)
SQL injection to atak, w którym dane od użytkownika zmieniają treść zapytania do bazy, bo aplikacja skleiła je z kodem SQL, na przykład w f-stringu, konkatenacji albo szablonie tekstowym. Występuje tak samo w Pythonie, PHP i Node.js, a obrona jest wszędzie ta sama: zapytania parametryzowane, w których wartości przekazujesz osobno od tekstu zapytania. ORM, taki jak Drizzle czy SQLAlchemy, robi to domyślnie, dopóki nie sięgniesz po surowy SQL.

Zapytanie do bazy to tekst, a tekst da się skleić z danymi użytkownika jednym znakiem plus lub jednym f-stringiem. Wygoda tego zapisu bywa zdradliwa: jeśli użytkownik wpisze w polu coś, co wygląda jak fragment SQL, baza wykona to jako część zapytania. Problem nie zależy od języka. Poniżej pokazuję atak na lokalnej bazie SQLite z fikcyjnymi użytkownikami i sprawdzam go w trzech światach: Pythonie, PHP i Next.js (better-sqlite3 oraz Drizzle). Wszystkie przykłady uruchomiłem (Python 3.13, PHP 8.4, Node 22, Next.js 16.3.5).

Czym jest SQL injection?

SQL injection (wstrzyknięcie SQL) to sytuacja, w której dane podane przez użytkownika stają się częścią polecenia SQL, zamiast pozostać zwykłą wartością. Atakujący nie włamuje się do bazy: korzysta z tego, że Twoja aplikacja sama podaje bazie jego tekst jako kod. Skutki to ominięcie logowania, odczyt danych, do których nie powinien mieć dostępu, a czasem ich zmiana lub usunięcie.

Jak wygląda atak?

Tabela users z trzema kontami i funkcja logowania, która skleja zapytanie z danymi z formularza. Poniżej ten sam błąd w trzech językach:

def vulnerable_login(name, password):
    query = f"SELECT id, name FROM users WHERE name = '{name}' AND password_hash = '{password}'"
    return db.execute(query).fetchall()
function vulnerable_login(PDO $pdo, string $name, string $password): array {
    $sql = "SELECT id, name FROM users WHERE name = '$name' AND password_hash = '$password'";
    return $pdo->query($sql)->fetchAll();
}
const vulnerableLogin = (name, password) =>
  db
    .prepare(`SELECT id, name FROM users WHERE name = '${name}' AND password_hash = '${password}'`)
    .all();

(Dla czytelności porównuję hash tekstem. W prawdziwej aplikacji hasło weryfikujesz funkcją hashującą, jak w artykule o uwierzytelnianiu w FastAPI.)

We wszystkich trzech językach wyniki były identyczne:

DaneWynikCo się stało
anna / hash-anny1 użytkownik (anna)poprawne logowanie
anna / zlepusta listazłe hasło odrzucone
anna / ' OR '1'='1wszyscy 3 użytkownicywarunek OR '1'='1' jest zawsze prawdziwy
admin'-- / cokolwiekzalogowany admin-- zaczyna komentarz, więc sprawdzenie hasła znika z zapytania

W trzecim wierszu zapytanie zamieniło się w ... WHERE name = 'anna' AND password_hash = '' OR '1'='1'. Wartość wpisana przez użytkownika zamknęła apostrof i dopisała własny warunek. W czwartym komentarz obciął resztę zapytania, więc hasło w ogóle nie brało udziału w sprawdzeniu.

Co jeszcze może wyciec?

Logowanie to dopiero początek. Jeśli wynik zapytania trafia do odpowiedzi (lista, wyszukiwarka), atakujący może dołączyć dane z innej kolumny lub tabeli instrukcją UNION. W teście wpisanie x' UNION SELECT id, password_hash FROM users -- w pole nazwy zwróciło hashe haseł wszystkich użytkowników, w każdym z trzech języków. Dlatego hasła muszą być przechowywane jako hashe (Argon2), a nie jawnie: nawet po udanym wycieku nie da się ich odczytać.

Czy atak może usunąć tabelę?

Znany z żartów obrazek '; DROP TABLE users -- wymaga wykonania dwóch poleceń w jednym wywołaniu. To, czy się uda, zależy od sterownika, a nie od Twojej aplikacji. Sprawdziłem go na trzech:

SterownikDwie instrukcje w jednym wywołaniu
Python sqlite3błąd You can only execute one statement at a time
Node better-sqlite3błąd The supplied SQL string contains more than one statement
PHP PDO (pdo_sqlite), metoda query()bez błędu, ale tabela została
PHP PDO (pdo_sqlite), metoda exec()bez błędu, tabela usunięta

Ostatni wiersz pokazuje, dlaczego nie wolno polegać na „sterownik i tak to zablokuje”. Wyciek danych przez UNION i obejście logowania działają niezależnie od tego, a w innych bazach i konfiguracjach wiele instrukcji może przejść (MySQL i PostgreSQL tu nie testowałem).

Jak się bronić przed SQL injection?

Zapytania parametryzowane. Zamiast wklejać wartość do tekstu zapytania, zostawiasz w nim znak zastępczy, a wartość podajesz osobno. Baza dostaje zapytanie i wartości osobnymi kanałami i traktuje wartość zawsze jako tekst do porównania, nigdy jako polecenie. Znak zastępczy zależy od sterownika (?, :nazwa, @nazwa, %s).

Jak to wygląda w Pythonie?

def safe_login(name, password):
    return db.execute(
        "SELECT id, name FROM users WHERE name = ? AND password_hash = ?",
        (name, password),
    ).fetchall()

Jak to wygląda w PHP?

W PHP służą do tego zapytania przygotowane w PDO: prepare() przyjmuje zapytanie ze znacznikami, a execute() wartości:

function safe_login(PDO $pdo, string $name, string $password): array {
    $stmt = $pdo->prepare('SELECT id, name FROM users WHERE name = :name AND password_hash = :hash');
    $stmt->execute(['name' => $name, 'hash' => $password]);
    return $stmt->fetchAll();
}

W moim teście ataki ' OR '1'='1 i admin'-- zwracają teraz pustą tablicę. W bazie MySQL PDO domyślnie emuluje zapytania przygotowane po stronie PHP (opcja PDO::ATTR_EMULATE_PREPARES). Tego wariantu nie testowałem, więc przy MySQL sprawdź w dokumentacji PDO, jak ustawić kodowanie połączenia i tę opcję. Ze starych poradników unikaj mysql_real_escape_string i ręcznego escapowania: parametry są prostsze i bezpieczniejsze. Walidację samych pól formularza w PHP opisuje artykuł o walidacji formularza w PHP.

Jak to wygląda w Next.js z better-sqlite3?

W Next.js dane od użytkownika trafiają do Twojego kodu głównie trzema drogami. Parametry zapytania w Route Handlerze to request.nextUrl.searchParams, w stronie searchParams (w tej wersji Next.js jest to Promise, więc czekasz na nie przez await), a w Server Functions argumenty wywołane z przeglądarki. We wszystkich trzech przypadkach to dane z zewnątrz, takie same jak pole formularza.

Podatny Route Handler (logowanie przez GET z hasłem w adresie to zła praktyka sama w sobie, tu służy tylko do pokazania podatności):

// app/api/login/route.ts
import { type NextRequest } from 'next/server';
import { db } from '@/lib';

export async function GET(request: NextRequest) {
  const name = request.nextUrl.searchParams.get('name') ?? '';
  const password = request.nextUrl.searchParams.get('password') ?? '';
  const rows = db
    .prepare(`SELECT id, name FROM users WHERE name = '${name}' AND password_hash = '${password}'`)
    .all();
  return Response.json(rows);
}

Uruchomiłem tę aplikację w Next.js 16.3.5. Żądanie ?name=anna&password=' OR '1'='1 zwróciło wszystkich trzech użytkowników, a ?name=admin'-- zalogowało admina. Wersja bezpieczna używa znaków ?:

export async function POST(request: NextRequest) {
  const { name, password } = await request.json();
  const rows = db
    .prepare('SELECT id, name FROM users WHERE name = ? AND password_hash = ?')
    .all(name, password);
  return Response.json(rows);
}

Ten sam atak wysłany do tej wersji zwrócił pustą listę, a poprawne dane jednego użytkownika. Dla strony z wyszukiwarką działa to samo:

// app/szukaj/page.tsx
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  const q = String((await searchParams).q ?? '');
  const users = db.prepare('SELECT name FROM users WHERE name LIKE ?').all(`%${q}%`);
  // ...
}

Adres /szukaj?q=an zwrócił anna i jan, a q=' OR '1'='1 nie zwrócił nic. Argumenty Server Functions ('use server') traktuj tak samo: są dostępne z przeglądarki jak publiczny endpoint, więc to nie jest „wewnętrzny kod”. Samych Server Actions tu nie uruchamiałem, opieram się na dokumentacji Next.js. Pakiet better-sqlite3 jest modułem natywnym, który Next.js domyślnie wyłącza z bundlowania (lista serverExternalPackages w dokumentacji), więc zadziałał bez dodatkowej konfiguracji.

A co z Drizzle i innymi ORM?

ORM, czyli zapytania budowane metodami zamiast tekstu, przekazuje wartości jako parametry. Sprawdziłem to w Drizzle (0.45) razem z kodem, który generuje:

import { eq, and, sql } from 'drizzle-orm';

const evil = "x' OR '1'='1";

db.select({ id: users.id, name: users.name })
  .from(users)
  .where(eq(users.name, evil))
  .all(); // []

db.select({ name: users.name }).from(users).where(eq(users.name, evil)).toSQL();
// { sql: 'select "name" from "users" where "users"."name" = ?', params: ["x' OR '1'='1"] }

Wartość trafia do params, a nie do tekstu zapytania. Ten sam efekt daje znacznik sql z interpolacją: sql`... ${evil}` jest parametryzowany. Pułapką jest sql.raw(), który wstawia tekst dosłownie:

// PODATNE: zwróciło wszystkich 3 użytkowników
db.all(sql.raw(`SELECT name FROM users WHERE name = '${evil}'`));

// BEZPIECZNE: pusta lista
db.all(sql`SELECT name FROM users WHERE name = ${evil}`);

To samo dotyczy innych bibliotek. W SQLAlchemy text(f"... '{x}'") zwraca oba wiersze przy ataku, a text("... :n") ze słownikiem wartości zwraca pustą listę. Zasada brzmi: bezpiecznie jest, dopóki wartości przekazujesz osobno, a niebezpiecznie, gdy sam składasz tekst zapytania.

Jak zrobić LIKE, IN i ORDER BY?

Dla LIKE znaki % doklejasz do wartości, nie do zapytania:

term = "an"
db.execute("SELECT name FROM users WHERE name LIKE ?", (f"%{term}%",)).fetchall()
# [('anna',), ('jan',)]

Dla IN generujesz tyle znaków zastępczych, ile masz wartości. Do tekstu zapytania trafiają tylko znaki ?, a same wartości idą osobno:

const ids = [1, 3];
const marks = ids.map(() => '?').join(',');
db.prepare(`SELECT name FROM users WHERE id IN (${marks})`).all(...ids);
// [ { name: 'anna' }, { name: 'jan' } ]

Parametr zastępuje wartość, nie nazwę kolumny czy tabeli. ORDER BY ? z parametrem "name DESC" zostaje potraktowane jako zwykła stała, więc wynik wraca w kolejności wstawienia, a nie posortowany. Jeśli użytkownik wybiera sortowanie, sprawdzaj nazwę na liście dozwolonych:

const ALLOWED_SORT = new Set(['name', 'id']);

function listUsers(sort) {
  if (!ALLOWED_SORT.has(sort)) throw new Error('niedozwolone sortowanie');
  return db.prepare(`SELECT name FROM users ORDER BY ${sort}`).all();
}

listUsers('name') działa, a listUsers('name; DROP TABLE users') jest odrzucone. Szablon tekstowy jest tu dopuszczalny tylko dlatego, że wartość przeszła przez własną, zamkniętą listę.

Czy sama walidacja danych wystarczy?

Nie. Walidacja typu (na przykład pole str w Pydantic) sprawdza, że to napis, a napis ' OR '1'='1 jest poprawnym napisem. Walidacja ogranicza to, co wchodzi do aplikacji, ale o bezpieczeństwie zapytania decyduje to, jak je zbudujesz. Używaj jej jako dodatkowej warstwy, nie zamiast parametrów.

Jakie błędy mentor poprawia najczęściej?

Uwaga mentora: ORM nie zwalnia z myślenia. Drizzle czy SQLAlchemy są bezpieczne, dopóki używasz ich metod. Problem wraca przy sql.raw i text(f"..."), czyli tam, gdzie sam składasz tekst zapytania.

Poniżej cała lista pułapek w jednym miejscu:

PułapkaSkutekJak uniknąć
f-string, + lub szablon `${x}` w zapytaniuominięcie logowania, wyciek danychparametry (?, :nazwa, @nazwa, %s)
text(f"...") w SQLAlchemy, sql.raw() w Drizzleto samo, mimo użycia popularnego ORM-awartości przez :n lub znacznik sql `` z interpolacją
Nazwa kolumny lub tabeli z formularzaparametr tego nie obsłużylista dozwolonych nazw
Poleganie na tym, że sterownik blokuje wiele poleceńw PDO exec() usunął tabelę, a UNION działa zawszeparametry, a nie cechy sterownika
Dane z searchParams i argumentów Server Functions traktowane jak „swoje”to dane z zewnątrz, jak pole formularzaparametry i walidacja po stronie serwera
Hasła zapisane jawniepo wycieku wszystkie konta są straconehash Argon2

Co dalej?

Jeśli dopiero uczysz się zapytań, zacznij od składni: relacje i JOIN w SQL. Atak, w którym cudzy kod trafia do przeglądarki, a nie do bazy, opisuje artykuł o XSS w JavaScripcie. Trzecią klasą problemów są sfałszowane żądania wysyłane w imieniu zalogowanego użytkownika, czyli CSRF. Podobna zasada dotyczy poleceń systemowych i eval: zobacz command injection i eval. Jeśli chcesz uczyć się backendu z mentorem, który ocenia Twój kod, sprawdź kurs PHP, kurs Python backend albo kurs Zaawansowany JS, w którym w module Next.js pracujesz z SQLite i ORM.

Konsultacja i warsztaty devmentor.pl

Masz pytania?