ArtikelSäkerhet

SQL-injektion i AI-genererad kod: parametrisera frågan, lås sorteringen

En kodassistent som bygger SQL med template strings eller f-strängar lämnar frågans struktur öppen för indata. Hitta mönstret, bind värdena, välj sorteringskolumner ur en tillåtelselista och bevisa rättningen med ett test.

Publicerad

Ett ansikte i profil byggt av tunna vita linjer och glasartade trianglar till vänster, och till höger stående svarta pusselbitar där en grönt lysande bit lutar in i en lucka, med fler svarta bitar utspridda på en mörk blank yta.
En bit kan passa formen utan att höra hemma där: en parameter fyller ett värde, inte frågans struktur.
Kärnan

SQL-injektion uppstår när indata får ändra vad en SQL-fråga betyder. CWE-89 beskriver svagheten som att produkten bygger hela eller delar av ett SQL-kommando av externt påverkad indata utan att neutralisera tecken som kan ändra kommandot. Rättningen är sällan att tvätta strängen. Den är att skicka värdet separat från SQL-texten, och att inte låta indata välja tabell- eller kolumnnamn direkt.

Mönstret: frågan byggs som en sträng

Be en kodassistent om ”en funktion som hämtar en användare via e-post” och du kan få något som ser ut så här. Exemplet är konstruerat för artikeln och är inget mätresultat om hur ofta en viss modell skriver så; någon sådan siffra redovisas inte här.

// Före: värdet klistras in i SQL-texten
function findUserByEmail(db, email) {
  return db.prepare(`SELECT id, email FROM users WHERE email = '${email}'`).all();
}

Koden fungerar i varje vanligt test, vilket är skälet till att mönstret överlever granskningen. Template strings i JavaScript och f-strängar i Python är bekväma sätt att bygga text, och SQL ser ut som text. Men när e-posten är x' OR '1'='1 blir villkoret email = 'x' OR '1'='1', som är sant för varje rad. OWASP:s SQL Injection Prevention Cheat Sheet pekar ut just kombinationen: dynamiska databasfrågor som använder strängkonkatenering och användarstyrd indata.

Lägg märke till att db.prepare används även i den sårbara versionen. Ett anrop som heter ”prepare” skyddar inget om värdet redan ligger inbakat i SQL-texten när den förbereds. Granska därför vad som skickas in, inte vad metoden heter.

Parametrisera värdet – och vet vad det inte täcker

Samma funktion med bunden parameter. Det enda som skiljer är frågetexten och att e-posten skickas som argument:

// Efter: värdet skickas som bunden parameter
function findUserByEmail(db, email) {
  return db.prepare('SELECT id, email FROM users WHERE email = ?').all(email);
}

Enligt OWASP gör förberedda satser med parametrar att databasen skiljer på kod och data oavsett vilken indata som skickas, så att en angripare inte kan ändra frågans avsikt. OWASP:s eget exempel är samma typ av sträng: med parametrar letar frågan efter ett användarnamn som bokstavligen är hela strängen tom' or '1'='1.

Platshållarens form beror på drivrutinen. Exemplen här använder Node.js inbyggda node:sqlite, som tar ? i ordning eller namngivna parametrar med $, : eller @. Andra drivrutiner har andra platshållare. Kontrollera dokumentationen för just den drivrutin projektet använder innan du godkänner en omskrivning.

Parametrar har två gränser som är lätta att missa. Den första: bara värden kan bindas. OWASP skriver att delar av en fråga som inte kan använda bindvariabler, som tabellnamn, kolumnnamn och sorteringsriktning (ASC eller DESC), kräver indatavalidering eller en omdesignad fråga. Den andra: CWE-89 varnar för att bygga och köra frågesträngar dynamiskt inuti lagrade procedurer med exec eller liknande, eftersom det kan återinföra möjligheten till SQL-injektion. Att flytta strängbygget till databasen flyttar alltså bara problemet.

Dynamiska identifierare: tillåtelselista, inte escaping

Sortering är ett typiskt fall. En listvy får ?sort=email&dir=desc, och den genererade koden lägger in båda i ORDER BY ${sort} ${dir}. Värdena för WHERE kan vara bundna och frågan ändå vara öppen. Om ordning i listor är ett återkommande tema i projektet, se också stabil cursorpaginering, där sorteringskolumnen avgör om sidorna går att lita på.

const SORT_COLUMNS = new Map([['name', 'name'], ['email', 'email'], ['newest', 'id']]);

function listUsers(db, sortKey, descending) {
  const column = SORT_COLUMNS.get(sortKey);
  if (column === undefined) throw new Error(`unknown sort key: ${sortKey}`);
  const direction = descending === true ? 'DESC' : 'ASC';
  return db.prepare(`SELECT id, name FROM users ORDER BY ${column} ${direction}`).all();
}

Här interpoleras fortfarande text i SQL, men texten kommer bara från koden. Användaren väljer en nyckel; kolumnnamnet hämtas ur en tabell som du skrivit. Riktningen följer OWASP:s råd för sortering: gör om indata till ett booleskt värde och använd det för att välja ett säkert värde som läggs till frågan.

Två detaljer avgör om tillåtelselistan håller. Använd en Map eller en kontroll av egna nycklar, inte ett vanligt objekt med obj[key]. Ett vanligt objekt ärver egenskaper från Object.prototype, så nycklar som constructor och __proto__ kan ge träffar som du inte skrivit in. Och avvisa okända nycklar med ett fel i stället för att falla tillbaka på en standardkolumn i tysthet; då syns felaktiga anrop i loggen.

Escaping är inget alternativ. OWASP listar ”Escaping All User Supplied Input” som starkt avrådd, kallar metoden skör jämfört med övriga skydd och skriver att den inte kan garanteras stoppa all SQL-injektion i alla situationer.

ORM:ens nödutgångar

En ORM skyddar de vanliga frågorna, men varje ORM har en väg till rå SQL, och dit kan AI-genererad kod ta sig när en fråga blir krånglig. CWE-89 nämner persistenslager som Hibernate som ett betydande skydd om de används rätt. OWASP påpekar att även abstraktionslager som Hibernate Query Language har samma typ av injektionsproblem, HQL-injektion, och att de också stöder parametriserade frågor.

Prisma är ett tydligt exempel på hur namnet signalerar risken. Enligt Prismas dokumentation om råa frågor blir variablerna i en tagged template med $queryRaw parametrar i en förberedd sats, medan $queryRawUnsafe med användarindata i strängen öppnar för SQL-injektion. Dokumentationen säger också att tabell- och kolumnnamn inte kan skickas som platshållare, och den visar hur Prisma.raw() med obetrodd indata blir sårbart. Samma regel gäller alltså där: värden binds, identifierare väljs ur en lista.

Råa frågor kan dyka upp när någon optimerar bort många små anrop. Om du rättar N+1-frågor i AI-genererad kod med handskriven SQL, granska den nya frågan med samma krav som här.

Hitta mönstret i kodbasen

Tre sökningar med ripgrep ger en första lista. Mönstren saknar citattecken inuti, så samma rader fungerar i både PowerShell och bash:

# SQL-nyckelord på samma rad som ${...}, strängkonkatenering eller Pythons %-formatering
rg -n -i '\b(select|insert|update|delete)\b.*(\$\{|[\x22\x27]\s*\+|\+\s*[\x22\x27]|[\x22\x27]\s*%\s)'

# Python-f-strängar som börjar med SQL
rg -n -i '\bf[\x22\x27](select|insert|update|delete|with)\b'

# Prismas råa nödutgångar; lägg till motsvarande anrop för den ORM projektet använder
rg -n '\$(query|execute)RawUnsafe|Prisma\.raw\('

Vi provade de tre mönstren på en liten provmapp med åtta kodrader: sex medvetet sårbara (template string i WHERE, template string i ORDER BY, konkatenering med enkla och dubbla citattecken, $queryRawUnsafe, f-sträng och %-formatering) och två parametriserade. De sex sårbara raderna träffades och de två parametriserade gjorde det inte. Det säger ingenting om träffbilden i en riktig kodbas.

Avgränsningen är viktig. Mönstren läser en rad i taget, så en fråga där SELECT står på en rad och ${...} tre rader längre ner missas. Frågebyggare som sätter ihop strängar i flera steg missas också. Använd sökningen som startpunkt för en manuell genomgång, inte som bevis på att kodbasen är ren. En bredare checklista för resten av säkerhetsgranskningen finns i säkerhetsgranska en AI-byggd kodbas.

Testet som bevisar rättningen

Ett bra test för SQL-injektion visar två saker: att attacksträngen inte ändrar frågan, och att den behandlas som vanlig data. Därför lägger fixturen in en användare vars e-post är attacksträngen. Efter rättningen ska sökningen hitta exakt den raden, inte noll rader och inte alla.

Spara som sql-param-test.mjs och kör node sql-param-test.mjs. Skriptet använder en databas i minnet och skriver inga filer. node:sqlite fungerar utan flagga från Node.js 22.13.0 och 23.4.0. I vårt prov på Node.js 25.5.0 skrev den en experimentvarning; hur andra versioner beter sig har vi inte prövat.

import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';

// Fixtur i minnet: två vanliga användare plus en vars e-post ÄR attacksträngen.
const PAYLOAD = "x' OR '1'='1";
const db = new DatabaseSync(':memory:');
db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL, name TEXT NOT NULL)`);
const insert = db.prepare('INSERT INTO users (email, name) VALUES (?, ?)');
insert.run('ada@example.com', 'Ada');
insert.run('linus@example.com', 'Linus');
insert.run(PAYLOAD, 'Payload');

const variants = {
  before: (db, email) => db.prepare(`SELECT id, email FROM users WHERE email = '${email}'`).all(),
  after: (db, email) => db.prepare('SELECT id, email FROM users WHERE email = ?').all(email),
};

const SORT_COLUMNS = new Map([['name', 'name'], ['email', 'email'], ['newest', 'id']]);
function listUsers(db, sortKey, descending) {
  const column = SORT_COLUMNS.get(sortKey);
  if (column === undefined) throw new Error(`unknown sort key: ${sortKey}`);
  const direction = descending === true ? 'DESC' : 'ASC';
  return db.prepare(`SELECT id, name FROM users ORDER BY ${column} ${direction}`).all();
}

const results = [];
assert.equal(variants.before(db, PAYLOAD).length, 3);
results.push('BEFORE: payload returned 3 of 3 rows (the WHERE clause was rewritten)');

const rows = variants.after(db, PAYLOAD);
assert.equal(rows.length, 1);
assert.equal(rows[0].email, PAYLOAD);
results.push('PASS after: payload matched only the row whose email is the literal string');

assert.equal(variants.after(db, 'ada@example.com').length, 1);
results.push('PASS after: normal lookup still returns 1 row');

assert.deepEqual(listUsers(db, 'name', false).map(r => r.name), ['Ada', 'Linus', 'Payload']);
assert.deepEqual(listUsers(db, 'newest', true).map(r => r.id), [3, 2, 1]);
results.push('PASS allowlist: known sort keys produce expected order');

for (const bad of ['name; DROP TABLE users', 'constructor', '__proto__', '(SELECT 1)']) {
  assert.throws(() => listUsers(db, bad, false), /unknown sort key/);
}
assert.equal(db.prepare('SELECT count(*) AS n FROM users').get().n, 3);
results.push('PASS allowlist: 4 unknown sort keys rejected before SQL, table intact');

console.log(JSON.stringify({ node: process.version, platform: process.platform, results }, null, 2));
db.close();

Faktiskt utfört prov: den 10 oktober 2026 kördes skriptet med Node.js 25.5.0 på Windows och avslutades med kod 0. Den sårbara varianten returnerade 3 av 3 rader för attacksträngen. Den parametriserade varianten returnerade 1 rad, den vars e-post bokstavligen är strängen, och en vanlig sökning gav fortsatt 1 rad. Tillåtelselistan gav väntad ordning för två kända nycklar, avvisade fyra okända nycklar innan någon SQL byggdes och tabellen hade kvar sina 3 rader.

Provet gäller SQLite i minnet och den visade koden. Det verifierar inte din drivrutin, din ORM eller dina riktiga routes. Flytta samma tre förväntningar till ett integrationstest mot den endpoint som tar emot indata: attacksträngen ger exakt den rad som matchar bokstavligt, en okänd sorteringsnyckel ger ett fel och antalet rader är oförändrat efteråt. Att testet har en lyckad variant är viktigt; ett test som bara kontrollerar ”noll rader” godkänner också en funktion som aldrig hittar något. Säkerhetsarbetet i stort samlas i säkerhetsguiden.

Källor och metod

Källorna kontrollerades 10 oktober 2026: OWASP SQL Injection Prevention Cheat Sheet för parametrar, identifierare, sortering, escaping och HQL; CWE-89 för definitionen, varningen om exec i lagrade procedurer och persistenslager; Prismas dokumentation om råa frågor för $queryRaw, $queryRawUnsafe och Prisma.raw(); Node.js dokumentation för node:sqlite för platshållare och versioner; MDN om Object för ärvda nycklar. Kodexemplen, sökmönstren och testet är AI-kreatörs egna. Artikeln gör inget påstående om hur ofta AI-verktyg skriver injicerbar kod.