Stage 5: Professional Skills

Web Security · Lesson 03

SQL Injection

Prepared statements, parameterized queries and ORM defenses.

Web Security3 min readFree lesson

SQL injection happens when user input is concatenated into a query. It is old, well understood and still found in production because string building is convenient.

Learning objectives

  • Recognise injectable code in Node and SQL.
  • Fix it with prepared statements and parameter binding.
  • Validate input without treating validation as the defence.
  • Apply least privilege to the database account.

1. The vulnerable query

app.get("/search", (req, res) => {
  const name = req.query.name;

  const rows = db
    .prepare(`SELECT * FROM products WHERE name LIKE '%${name}%'`)
    .all();

  res.json(rows);
});

Request:

/search?name=' UNION SELECT id, email, password FROM users --

The database executes the attacker's query. Depending on the driver, ; DROP TABLE products -- is also possible.

Login bypass:

const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
// email = "[email protected]' --"

The password check becomes a comment.

2. Prepared statements

The SQL text is fixed first; values are sent separately and never parsed as SQL.

const searchByName = db.prepare(
  "SELECT id, name, price, category FROM products WHERE name LIKE ? ORDER BY name ASC",
);

app.get("/search", (req, res) => {
  const name = String(req.query.name ?? "").trim();
  const rows = searchByName.all(`%${name}%`);
  res.json({ success: true, count: rows.length, data: rows });
});
const findByEmail = db.prepare("SELECT id, email, password_hash FROM users WHERE email = ?");
const user = findByEmail.get(email);

Named parameters are equally safe:

db.prepare("SELECT * FROM products WHERE category = @category AND price <= @max")
  .all({ category, max: Number(maxPrice) });

3. What you cannot parameterise

Identifiers and SQL keywords cannot be bound:

// column name from a query string — dangerous
db.prepare(`SELECT * FROM products ORDER BY ${req.query.sort}`).all();

Use an allow-list:

const sortable = {
  name: "name",
  price: "price",
  newest: "created_at DESC",
};

const orderBy = sortable[req.query.sort] ?? "name";
const rows = db.prepare(`SELECT * FROM products ORDER BY ${orderBy}`).all();

The interpolation is safe here because the value comes from your own map, never from the request.

4. ORMs help, but are not magic

Prisma, Sequelize and Mongoose parameterise normal queries. Raw escape hatches do not:

// Prisma — safe
await prisma.user.findMany({ where: { email } });

// Prisma raw — dangerous if interpolated
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);

// Prisma raw — safe
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

Mongoose $where, mapReduce and unvalidated operators in filters ({ $ne: null } from req.query) have their own injection class — validate and whitelist query operators.

5. Defence in depth

  1. Prepared statements everywhere. This is the actual fix.
  2. Validate types, lengths and formats at the boundary. Reject early with 400.
  3. Least privilege: the app's database user needs SELECT/INSERT/UPDATE/DELETE on its tables — not DROP, not access to other schemas. Never connect as root.
  4. Do not leak errors: a stack trace reveals table and column names.
  5. Log suspicious input and monitor for repeated failures.

Validation alone is not enough: a valid-looking string can still carry a payload.

6. A safe login

app.post("/login", async (req, res) => {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  const password = String(req.body.password ?? "");

  if (!email || !password) {
    return res.status(400).json({ error: "Email and password are required" });
  }

  const user = findByEmail.get(email);
  const ok = user && (await bcrypt.compare(password, user.password_hash));

  if (!ok) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  req.session.userId = user.id;
  res.json({ success: true });
});

One generic error for both unknown email and wrong password — do not tell an attacker which accounts exist.

Common mistakes

  • Escaping quotes manually instead of using parameters.
  • Assuming an ORM makes raw SQL safe.
  • Returning database errors to the client.
  • Using the same privileged database account for migrations and the app.
  • Trusting client-side validation.

Practice

Fix the vulnerable search and login in the assignment. Then try the original payloads against your fixed version.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

safe-login.jsJavaScript
// ============================================
// 강의03 예제: Prepared Statement로 방어된 로그인
// ============================================
// 실행: node safe-login.js
// 테스트: http://localhost:3000
// ============================================

const express  = require('express');
const Database = require('better-sqlite3');

const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// ─────────────────────────────────────────
// DB 초기화
// ─────────────────────────────────────────
const db = new Database(':memory:');

db.exec(`
    CREATE TABLE users (
        id       INTEGER PRIMARY KEY,
        username TEXT NOT NULL,
        password TEXT NOT NULL,
        role     TEXT DEFAULT 'user'
    );

    INSERT INTO users VALUES (1, 'admin', 'super_secret_password', 'admin');
    INSERT INTO users VALUES (2, 'alice', 'alice_password',        'user');
    INSERT INTO users VALUES (3, 'bob',   'bob_password',          'user');
`);

// ─────────────────────────────────────────
// Prepared Statement 미리 준비
// ? 플레이스홀더로 데이터와 SQL을 분리
// ─────────────────────────────────────────
const stmts = {
    // 로그인 쿼리: username과 password는 항상 데이터로만 처리됨
    findUser: db.prepare('SELECT * FROM users WHERE username = ? AND password = ?'),

    // 아이디로만 조회 (이미 검증된 경우)
    findById: db.prepare('SELECT id, username, role FROM users WHERE id = ?'),
};

// ─────────────────────────────────────────
// 로그인 페이지
// ─────────────────────────────────────────
app.get('/', (req, res) => {
    res.send(`
        <!DOCTYPE html>
        <html lang="ko">
        <head>
            <meta charset="UTF-8">
            <title>안전한 로그인</title>
            <style>
                body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 20px; }
                .safe { background: #e8f5e9; border: 2px solid #4caf50; padding: 12px; border-radius: 6px; margin-bottom: 20px; }
                input { display: block; width: 100%; margin: 8px 0; padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
                button { padding: 10px 20px; background: #2e7d32; color: white; border: none; border-radius: 4px; cursor: pointer; }
                .payload { background: #f5f5f5; padding: 10px; border-radius: 4px; font-family: monospace; }
            </style>
        </head>
        <body>
            <div class="safe">✓ <strong>안전한 버전</strong> — Prepared Statement 사용</div>
            <h1>로그인 (안전한 버전)</h1>
            <form method="POST" action="/login">
                <label>아이디</label>
                <input type="text" name="username" placeholder="아이디">
                <label>비밀번호</label>
                <input type="password" name="password" placeholder="비밀번호">
                <button type="submit">로그인</button>
            </form>

            <hr style="margin: 30px 0">
            <h2>테스트</h2>
            <p>아래 페이로드를 입력해도 로그인 우회가 되지 않습니다:</p>
            <div class="payload">
                아이디: admin' --<br>
                아이디: ' OR '1'='1<br>
                비밀번호: 아무거나
            </div>
            <p>정상 계정: admin / super_secret_password</p>
        </body>
        </html>
    `);
});

// ─────────────────────────────────────────
// 안전한 로그인 처리
// ─────────────────────────────────────────
app.post('/login', (req, res) => {
    const { username, password } = req.body;

    // 기본 입력 검증 (빈 값 거부)
    if (!username || !password) {
        return res.status(400).send('<p>아이디와 비밀번호를 입력해주세요.</p><a href="/">돌아가기</a>');
    }

    try {
        // ✓ Prepared Statement 사용
        // username과 password는 ? 플레이스홀더로 바인딩됨
        // SQL 문법으로 해석되지 않고 단순 문자열로 처리됨
        // 따라서 ' OR '1'='1 같은 페이로드도 효과 없음
        const user = stmts.findUser.get(username, password);

        if (user) {
            res.send(`
                <h2>로그인 성공!</h2>
                <p>아이디: ${user.username} / 역할: ${user.role}</p>
                <p style="color:green">✓ Prepared Statement로 SQL 인젝션 차단!</p>
                <a href="/">돌아가기</a>
            `);
        } else {
            // 에러 메시지는 일반적으로 — DB 구조 정보 노출 금지
            res.send('<h2>로그인 실패</h2><p>아이디 또는 비밀번호가 올바르지 않습니다.</p><a href="/">돌아가기</a>');
        }
    } catch (err) {
        // 서버 로그에만 상세 에러 기록
        console.error('[에러]', err);
        // 클라이언트에는 최소한의 정보만 반환
        res.status(500).send('<p>서버 오류가 발생했습니다.</p><a href="/">돌아가기</a>');
    }
});

app.listen(3000, () => {
    console.log('안전한 서버 실행: http://localhost:3000');
    console.log('테스트 계정: admin / super_secret_password');
});
vulnerable-login.jsJavaScript
// ============================================
// 강의03 예제: SQL 인젝션 취약한 로그인 (학습용)
// ============================================
// ⚠ 이 코드는 SQL 인젝션 취약점을 보여주기 위한 예제입니다.
//   실제 서비스에서 절대 사용하지 마세요!
// ============================================
// 실행: node vulnerable-login.js
// 테스트: http://localhost:3001
// ============================================

const express    = require('express');
const Database   = require('better-sqlite3');

const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// ─────────────────────────────────────────
// DB 초기화 (메모리 DB 사용)
// ─────────────────────────────────────────
const db = new Database(':memory:'); // 메모리 DB

db.exec(`
    CREATE TABLE users (
        id       INTEGER PRIMARY KEY,
        username TEXT NOT NULL,
        password TEXT NOT NULL,
        role     TEXT DEFAULT 'user'
    );

    INSERT INTO users VALUES (1, 'admin', 'super_secret_password', 'admin');
    INSERT INTO users VALUES (2, 'alice', 'alice_password',        'user');
    INSERT INTO users VALUES (3, 'bob',   'bob_password',          'user');
`);

// ─────────────────────────────────────────
// 로그인 페이지
// ─────────────────────────────────────────
app.get('/', (req, res) => {
    res.send(`
        <!DOCTYPE html>
        <html lang="ko">
        <head>
            <meta charset="UTF-8">
            <title>취약한 로그인 (학습용)</title>
            <style>
                body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 20px; }
                .warning { background: #fff3cd; border: 2px solid #ffc107; padding: 12px; border-radius: 6px; margin-bottom: 20px; }
                input { display: block; width: 100%; margin: 8px 0; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
                button { padding: 10px 20px; background: #cc0000; color: white; border: none; border-radius: 4px; cursor: pointer; }
                .payload { background: #fff0f0; border: 1px solid #cc0000; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 0.9rem; }
            </style>
        </head>
        <body>
            <div class="warning">
                ⚠ <strong>학습용 취약 코드</strong> — 절대 실제 서비스에 사용 금지
            </div>

            <h1>로그인 (취약 버전)</h1>

            <form method="POST" action="/login">
                <label>아이디</label>
                <input type="text" name="username" placeholder="아이디">
                <label>비밀번호</label>
                <input type="password" name="password" placeholder="비밀번호">
                <button type="submit">로그인</button>
            </form>

            <hr style="margin: 30px 0">
            <h2>SQL 인젝션 테스트</h2>
            <p>아이디 입력창에 아래 페이로드를 입력해보세요:</p>
            <div class="payload">
                admin' --<br>
                ' OR '1'='1<br>
                ' OR 1=1 --
            </div>
            <p>비밀번호는 아무거나 입력해도 됩니다.</p>
        </body>
        </html>
    `);
});

// ─────────────────────────────────────────
// 취약한 로그인 처리
// ⚠ 사용자 입력을 SQL 문자열에 직접 삽입!
// ─────────────────────────────────────────
app.post('/login', (req, res) => {
    const { username, password } = req.body;

    // ⚠ 핵심 취약점: 문자열 템플릿으로 SQL 직접 구성
    // 공격자가 username에 admin' -- 를 입력하면:
    // SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
    // -- 이후가 주석 처리되어 비밀번호 확인이 무효화됨!
    const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;

    console.log('[취약] 실행된 쿼리:', query); // 실제로 어떤 쿼리가 실행되는지 확인

    try {
        const user = db.prepare(query).get();

        if (user) {
            res.send(`
                <h2>로그인 성공!</h2>
                <p>ID: ${user.id} / 아이디: ${user.username} / 역할: ${user.role}</p>
                <p style="color:red">⚠ SQL 인젝션으로 로그인 우회 성공!</p>
                <a href="/">돌아가기</a>
            `);
        } else {
            res.send('<h2>로그인 실패</h2><a href="/">돌아가기</a>');
        }
    } catch (err) {
        // ⚠ 에러 메시지를 그대로 노출 → DB 구조 정보 유출
        res.status(500).send(`<p>에러: ${err.message}</p>`);
    }
});

app.listen(3001, () => {
    console.log('취약한 서버 실행: http://localhost:3001');
    console.log('테스트: 아이디에 admin\' -- 입력');
});

Assignment

Assignment 03 — Fix SQL injection

Create safe-search.js with a product search and a login, both injection-proof.

Setup

npm install express better-sqlite3 bcrypt

Create the database at startup:

CREATE TABLE IF NOT EXISTS products (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  category TEXT,
  price INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL
);

Seed at least six products and one user with a bcrypt hash.

Endpoints

Method Path Notes
GET /search?name=&category=&sort= prepared filters, allow-listed sort
POST /login email + password, generic 401

Requirements

  1. Build the search with a prepared statement and bound parameters.
  2. Support name (LIKE), category (exact) and both together.
  3. sort is mapped through an allow-list (name, price, newest); anything else falls back to name.
  4. Login uses a prepared statement and bcrypt.compare.
  5. Unknown email and wrong password both return the same 401 message.
  6. Database errors are logged server-side and returned as a generic 500.
  7. No query string is built with template literals containing request data.

Hints

const searchComplex = db.prepare(`
  SELECT id, name, category, price
  FROM products
  WHERE name LIKE ? AND category = ?
  ORDER BY name ASC
`);
const sortable = { name: "name", price: "price", newest: "id DESC" };
const orderBy = sortable[req.query.sort] ?? "name";

Test payloads

/search?name=' OR '1'='1
/search?name=' UNION SELECT id, email, password_hash FROM users --
/search?sort=price; DROP TABLE products --

Done when

  • Every payload returns normal search results or an empty list, never extra rows.
  • The DROP TABLE payload does not change the schema.
  • Wrong password and unknown email produce identical responses.
  • The server log contains the real error for a forced database failure, but the client sees only 500.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

safe-search.jsJavaScript
// ============================================
// 과제03 정답: SQL 인젝션 방어된 검색 기능
// ============================================
// 실행: node safe-search.js
// 테스트: http://localhost:3000/search?q=노트북
// ============================================

const express  = require('express');
const Database = require('better-sqlite3');

const app = express();
const db  = new Database(':memory:');

// ─────────────────────────────────────────
// 테스트 데이터 준비
// ─────────────────────────────────────────
db.exec(`
    CREATE TABLE products (
        id       INTEGER PRIMARY KEY,
        name     TEXT    NOT NULL,
        price    INTEGER NOT NULL,
        category TEXT
    );
    INSERT INTO products VALUES (1, '노트북',      1500000, '전자제품');
    INSERT INTO products VALUES (2, '마우스',         30000, '전자제품');
    INSERT INTO products VALUES (3, '클린코드',       32000, '도서');
    INSERT INTO products VALUES (4, '자바스크립트책', 45000, '도서');
    INSERT INTO products VALUES (5, '의자',          350000, '가구');
`);

// ─────────────────────────────────────────
// Prepared Statement 미리 준비
// ?가 플레이스홀더 역할: 바인딩된 값은 SQL로 해석되지 않음
// ─────────────────────────────────────────
const stmts = {
    // LIKE 검색: %값% 를 파라미터로 전달
    // 주의: LIKE의 %는 파라미터 안에 포함시킴 → 안전
    searchByName: db.prepare('SELECT * FROM products WHERE name LIKE ? ORDER BY name ASC'),

    // 카테고리 필터
    searchByCategory: db.prepare('SELECT * FROM products WHERE category = ? ORDER BY name ASC'),

    // 이름 + 카테고리 복합 검색
    searchComplex: db.prepare('SELECT * FROM products WHERE name LIKE ? AND category = ? ORDER BY name ASC'),
};

// ─────────────────────────────────────────
// 입력 검증 함수 (화이트리스트 방식)
// ─────────────────────────────────────────
function validateSearchInput(keyword) {
    if (typeof keyword !== 'string') return null;

    // 길이 제한: 최대 100자
    if (keyword.length > 100) {
        return null;
    }

    // 앞뒤 공백 제거
    return keyword.trim();
}

// ─────────────────────────────────────────
// GET /search — 안전한 상품 검색
// 쿼리: ?q=검색어 (필수 아님)
//       ?category=카테고리 (선택)
// ─────────────────────────────────────────
app.get('/search', (req, res) => {
    try {
        // ─────────────────────────────────────────
        // 1. 입력값 검증 및 정제
        // ─────────────────────────────────────────
        const rawKeyword  = req.query.q        || '';
        const rawCategory = req.query.category || '';

        const keyword  = validateSearchInput(rawKeyword);
        const category = validateSearchInput(rawCategory);

        // 검증 실패 (null 반환) → 400 오류
        if (keyword === null || category === null) {
            return res.status(400).json({
                success: false,
                message: '검색어는 100자 이하여야 합니다.',
            });
        }

        // ─────────────────────────────────────────
        // 2. Prepared Statement로 안전한 쿼리 실행
        //    ? 플레이스홀더 사용: 바인딩된 값은 SQL 문법으로 처리되지 않음
        //    ' OR '1'='1 을 입력해도 문자열 그대로 비교됨
        // ─────────────────────────────────────────
        let results;

        if (keyword && category) {
            // 검색어 + 카테고리 복합 검색
            // LIKE 패턴의 %는 파라미터 값 안에 포함
            results = stmts.searchComplex.all(`%${keyword}%`, category);
        } else if (keyword) {
            // 검색어만
            results = stmts.searchByName.all(`%${keyword}%`);
        } else if (category) {
            // 카테고리만
            results = stmts.searchByCategory.all(category);
        } else {
            // 검색 조건 없음 → 빈 배열 반환 (전체 조회 금지)
            results = [];
        }

        res.json({
            success: true,
            count: results.length,
            query: {
                keyword:  keyword  || null,
                category: category || null,
            },
            data: results,
        });

    } catch (err) {
        // 서버 내부 에러: 로그에 기록하되 클라이언트에는 최소 정보만 반환
        // DB 구조, 테이블명, 컬럼명 등을 노출하지 않음
        console.error('[DB 에러]', err.message, '| 쿼리 파라미터:', req.query);
        res.status(500).json({
            success: false,
            message: '검색 중 오류가 발생했습니다.',
            // message: err.message  ← ⚠ 절대 이렇게 하면 안 됨! DB 정보 노출
        });
    }
});

// ─────────────────────────────────────────
// 서버 시작
// ─────────────────────────────────────────
app.listen(3000, () => {
    console.log('안전한 검색 서버: http://localhost:3000');
    console.log('');
    console.log('테스트:');
    console.log('  정상: GET /search?q=노트북');
    console.log('  공격: GET /search?q=\' OR \'1\'=\'1');
    console.log('  공격: GET /search?q=\'; DROP TABLE --');
});

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now