# Flask에서 SQL 쿼리를 파라미터 바인딩으로 고치기

> 회원 검색과 로그인 조회만 있는 작은 Flask 앱에서 문자열로 이어 붙인 SQL이 어떻게 깨지는지 pytest와 쿼리 기록으로 확인합니다. ? 자리표시자, 정렬 기준 허용 목록, LIKE 와일드카드 이스케이프를 차례로 적용하고 Bandit B608이 사라지는 것까지 봅니다. 다만 스캐너가 조용하다는 것이 안전의 증거는 아니라는 점도 짚습니다.

- 주소: https://copyandrun.com/blog/sql-parameter-binding-flask/
- 분야: 시큐어코딩
- 발행일: 2026-10-16
- 태그: Flask, SQL 인젝션, 파라미터 바인딩, pytest, Bandit

SQL 문장을 문자열로 이어 붙이면, 입력에 들어 있는 따옴표 하나가 쿼리의 구조를 바꿉니다. 작은 Flask 앱에서 그 쿼리를 pytest로 먼저 실패시키고, 파라미터 바인딩과 허용 목록으로 고쳐서 같은 테스트가 통과하는 것까지 확인합니다.

이 글의 앱은 직접 만든 연습용이며 비밀번호도 평문으로 저장하므로 실제 서비스에 쓰면 안 됩니다. 요청은 자신이 띄운 `127.0.0.1` 서비스에만 보냅니다. 로그인 우회와 경로 조작, 저장형 XSS를 직접 시도해 보는 과정은 [작은 Flask 서비스를 직접 공격하고 한 줄씩 고치기](/blog/flask-attack-and-fix-own-app/)에서 다루었고, 업로드는 [Flask 파일 업로드 검증과 경로 조작 방어하기](/blog/flask-file-upload-hardening/)에서 다루었습니다. 이 글은 SQL 쿼리를 고치는 방법과 그것을 테스트로 남기는 방법에 집중합니다.

## 준비물

| 도구 | 확인한 버전 |
|---|---|
| Python | 3.12.10 |
| Flask | 3.1.3 |
| pytest | 9.1.1 |
| Bandit | 1.9.4 |
| curl | Windows Git Bash에 포함된 curl |

명령은 Windows의 Git Bash 기준이고 전체 소요 시간은 20분 안팎입니다. Linux와 macOS에서는 `python`을 `python3`으로, 가상환경 활성화를 `source .venv/bin/activate`로 바꿉니다. 아래 버전은 이 글을 쓸 때 확인한 것이라 `pip install`에서 고정했으며, 다른 버전에서는 출력 모양이 조금 다를 수 있습니다.

## 1. 작업 폴더와 앱 만들기

```bash
mkdir sqlb-demo && cd sqlb-demo
python -m venv .venv
source .venv/Scripts/activate
pip install flask==3.1.3 pytest==9.1.1 bandit==1.9.4
```

`app.py`는 회원 이름 검색(`/members`)과 로그인(`/login`)만 가진 얇은 라우트입니다. SQL은 모두 `queries.py`에 모읍니다. 그래야 이후 수정이 한 파일 안에서 끝납니다.

```python
from flask import Flask, jsonify, request

import queries


def create_app(db_path):
    app = Flask(__name__)

    @app.get("/members")
    def members():
        name = request.args.get("name", "")
        sort = request.args.get("sort", "name")
        try:
            rows = queries.search_members(db_path, name, sort)
        except ValueError as e:
            return jsonify(error=str(e)), 400
        return jsonify([r["name"] for r in rows])

    @app.post("/login")
    def login():
        user = queries.find_user(
            db_path, request.form.get("username", ""), request.form.get("password", ""))
        if user is None:
            return jsonify(error="로그인 실패"), 401
        return jsonify(user=user["username"])

    return app


if __name__ == "__main__":
    queries.init_db("members.db")
    create_app("members.db").run(host="127.0.0.1", port=19800)
```

## 2. 문자열을 이어 붙인 쿼리 만들기

`queries.py`의 첫 버전입니다. `search_members`와 `find_user`가 입력을 SQL 문장 안에 직접 끼워 넣습니다. `run()`은 실행 직전의 SQL 문장과 파라미터를 `SQL_LOG`에 남기므로, 테스트에서 실제로 데이터베이스에 넘어간 문장을 볼 수 있습니다. 이 기록은 테스트 전용이며, 운영 환경에서는 비밀번호 같은 파라미터를 로그에 남기지 않아야 합니다. 데이터에는 이름에 아포스트로피가 든 `O'Brien`과 밑줄이 든 `a_b`를 일부러 넣었습니다. 둘 다 정상적인 이름입니다.

```python
import sqlite3

SQL_LOG = []  # 테스트 전용: 실행 직전의 (SQL 문장, 파라미터)를 남긴다.
# 운영 환경에서는 비밀번호 같은 파라미터를 로그에 남기지 마세요


def run(db_path, sql, params=()):
    SQL_LOG.append((sql, params))
    conn = sqlite3.connect(db_path)
    conn.row_factory = sqlite3.Row
    try:
        return conn.execute(sql, params).fetchall()
    finally:
        conn.close()


def init_db(db_path):
    conn = sqlite3.connect(db_path)
    conn.executescript("""
    DROP TABLE IF EXISTS members;
    CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT, username TEXT, password TEXT, joined TEXT);
    """)
    conn.executemany("INSERT INTO members (name, username, password, joined) VALUES (?, ?, ?, ?)", [
        ("kim", "kim", "pw-kim", "2024-03-01"),
        ("lee", "lee", "pw-lee", "2024-01-15"),
        ("park", "park", "pw-park", "2024-02-10"),
        ("O'Brien", "obrien", "pw-obrien", "2024-04-05"),
        ("a_b", "ab", "pw-ab", "2024-05-20"),
    ])
    conn.commit()
    conn.close()


def search_members(db_path, name, sort):
    sql = "SELECT id, name FROM members WHERE name LIKE '%" + name + "%' ORDER BY " + sort
    return run(db_path, sql)


def find_user(db_path, username, password):
    sql = "SELECT id, username FROM members WHERE username = '%s' AND password = '%s'" % (username, password)
    rows = run(db_path, sql)
    return rows[0] if rows else None
```

앱을 띄운 터미널을 그대로 두고 새 터미널에서 요청을 보냅니다. 새 터미널에서는 활성화 명령을 한 번 더 실행합니다.

```bash
python app.py
```

```bash
curl -s -G -w " [%{http_code}]\n" --data-urlencode "name=kim" http://127.0.0.1:19800/members
curl -s -G -o /dev/null -w "%{http_code}\n" --data-urlencode "name=O'Brien" http://127.0.0.1:19800/members
```

```
# 기대 출력
["kim"]
 [200]
500
```

![평범한 이름은 200으로 조회되고, 아포스트로피가 든 이름 O'Brien은 500이 나오는 것을 확인합니다.](https://copyandrun.com/img/sql-parameter-binding-flask/01-vulnerable-server-500.png)

`O'Brien`은 정상적인 회원 이름인데 500이 납니다. 앱을 띄운 터미널의 로그에는 `sqlite3.OperationalError: near "Brien": syntax error`가 찍힙니다. 입력 안의 따옴표가 SQL 문자열 리터럴을 일찍 닫아 버려서, 뒤에 남은 글자가 문법 오류가 되었기 때문입니다. 500은 입력이 쿼리의 구조를 깨뜨렸다는 신호이며, 정상 입력에는 나오지 않아야 합니다. 쿼리가 깨지는 이 원리 때문에 따옴표를 이용한 입력이 쿼리의 구조를 바꿀 수 있고, 이것이 SQL 인젝션입니다. 확인이 끝났으면 서버 터미널에서 `Ctrl+C`로 앱을 끕니다.

## 3. 회귀 테스트를 먼저 만들어 실패시키기

고치기 전에 기대하는 동작을 테스트로 적습니다. `test_queries.py`입니다. 요점은 두 가지입니다. 정상 입력이 정상 결과를 내야 하고, 입력이 SQL 문장 안에 들어가면 안 되며 파라미터로만 가야 합니다.

```python
import pytest

import queries
from app import create_app


@pytest.fixture
def client(tmp_path):
    db = str(tmp_path / "t.db")
    queries.init_db(db)
    queries.SQL_LOG.clear()
    return create_app(db).test_client()


def test_apostrophe_name_is_found(client):
    r = client.get("/members", query_string={"name": "O'Brien"})
    assert r.status_code == 200
    assert r.get_json() == ["O'Brien"]


def test_input_never_enters_sql_text(client):
    client.get("/members", query_string={"name": "O'Brien"})
    sql, params = queries.SQL_LOG[-1]
    assert "Brien" not in sql, "SQL 문장: " + sql
    assert any("Brien" in str(p) for p in params)


def test_login_apostrophe_password(client):
    r = client.post("/login", data={"username": "obrien", "password": "pw'x"})
    assert r.status_code == 401
    sql, params = queries.SQL_LOG[-1]
    assert "pw'x" not in sql, "SQL 문장: " + sql


def test_login_ok(client):
    r = client.post("/login", data={"username": "kim", "password": "pw-kim"})
    assert r.status_code == 200 and r.get_json() == {"user": "kim"}


def test_sort_allowlist(client):
    assert client.get("/members", query_string={"sort": "joined"}).get_json()[0] == "lee"
    assert client.get("/members", query_string={"sort": "nope"}).status_code == 400


def test_like_wildcard_is_literal(client):
    r = client.get("/members", query_string={"name": "_"})
    assert r.get_json() == ["a_b"]
```

```bash
python -m pytest -q --tb=no
python -m pytest -q --tb=short --show-capture=no -k never
```

```
# 기대 출력
FFF.FF                                                                   [100%]
...
5 failed, 1 passed in 0.29s
...
E   AssertionError: SQL 문장: SELECT id, name FROM members WHERE name LIKE '%O'Brien%' ORDER BY name
```

중간의 `...`는 본문에서 줄인 표시이고, 실제 화면에는 실패 목록이 모두 나옵니다.

![여섯 개 중 다섯 개가 실패하고, 실패 메시지에 입력이 SQL 문장 안에 그대로 들어간 모습이 찍힌 것을 확인합니다.](https://copyandrun.com/img/sql-parameter-binding-flask/02-pytest-before-fix.png)

SQL 문장 안의 `'%O'Brien%'`에서 따옴표가 세 개로 갈라진 것이 보입니다. 정상 로그인(`test_login_ok`)만 통과하는 것도 의도한 결과입니다. 문자열로 이어 붙인 코드도 평범한 입력에서는 잘 돌아가기 때문에 기능 시험만으로는 이 문제가 드러나지 않습니다.

## 4. Bandit이 B608로 찾는지 확인하기

```bash
bandit -q -r queries.py | head -24
```

![문자열을 이어 붙인 두 곳이 B608, Severity Medium, Confidence Low로 보고되는 것을 확인합니다.](https://copyandrun.com/img/sql-parameter-binding-flask/03-bandit-b608.png)

Bandit 공식 문서는 B608을 `%` 서식, `+` 연결, `.format()`, f-string으로 만든 SQL 모양의 문자열을 찾는 검사로 설명하고 CWE-89에 연결합니다. 이 글의 코드에서는 SQL을 변수에 만든 뒤 `run()`에 넘기므로 Confidence가 Low로 나왔습니다. 심각도도 High가 아니라 Medium이라서, CI에서 `--severity-level high`로만 막으면 이 항목은 통과합니다. 팀 기준을 정할 때 이 점을 고려해야 합니다.

## 5. 파라미터 바인딩으로 고치기

값 자리에는 `?` 자리표시자를 쓰고 값은 별도 인자로 넘깁니다. Python 공식 문서도 SQL 문장 조립에 문자열 서식을 쓰지 말고 자리표시자로 값을 묶으라고 안내합니다. `queries.py`에서 `def search_members` 줄부터 끝까지를 지우고 다시 씁니다. 지우는 데 `sed -i`를 쓰지 않고 파이썬 한 줄을 쓴 것은, macOS의 `sed`는 `-i` 뒤에 빈 문자열 인자(`-i ''`)가 필요해 명령이 달라지기 때문입니다.

```bash
python -c "p='queries.py'; s=open(p, encoding='utf-8').read(); open(p, 'w', encoding='utf-8', newline='
').write(s[:s.index('def search_members')])"
cat >> queries.py <<'PY'
def search_members(db_path, name, sort):
    sql = "SELECT id, name FROM members WHERE name LIKE ? ORDER BY " + sort
    return run(db_path, sql, ("%" + name + "%",))


def find_user(db_path, username, password):
    rows = run(db_path, "SELECT id, username FROM members WHERE username = ? AND password = ?",
               (username, password))
    return rows[0] if rows else None
PY
python -m pytest -q --tb=no
bandit -q -r queries.py | grep -E "Issue|Location"
```

```
# 기대 출력
....FF                                                                   [100%]
FAILED test_queries.py::test_sort_allowlist - AssertionError: assert 500 == 400
FAILED test_queries.py::test_like_wildcard_is_literal - assert ["O'Brien", '....
2 failed, 4 passed in 0.29s
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
   Location: .\queries.py:35:10
```

![따옴표와 로그인 관련 테스트 네 개는 통과하고 정렬과 LIKE 테스트 두 개만 실패하며 B608이 한 곳 남는 것을 확인합니다.](https://copyandrun.com/img/sql-parameter-binding-flask/04-after-binding.png)

따옴표가 든 입력은 이제 값으로만 처리되어 앞의 네 테스트가 통과합니다. 남은 문제는 두 가지입니다. `ORDER BY` 뒤의 `sort`는 값이 아니라 컬럼 이름이라서 `?`로 묶을 수 없고, 이 자리가 Bandit이 남긴 한 건입니다. 또 `LIKE`의 `%`와 `_`는 값 안에서도 와일드카드로 해석됩니다.

## 6. 식별자는 허용 목록으로, LIKE는 이스케이프로

OWASP SQL Injection Prevention Cheat Sheet는 방어를 우선순위대로 소개합니다. 첫 번째가 바인딩(Prepared Statements), 두 번째가 저장 프로시저, 세 번째가 허용 목록 검증이고, 입력 전체를 이스케이프하는 방식은 가장 마지막에 두고 권하지 않습니다. 테이블 이름, 컬럼 이름, 정렬 방향처럼 바인딩할 수 없는 자리에는 허용 목록 검증을 쓰라고 안내합니다. 이 글은 입력으로 SQL 조각을 고르는 대신 코드에 적어 둔 문장 중에서 고르는 방식으로 작성합니다. 이 앱은 저장 프로시저를 쓰지 않는 SQLite라서 해당 항목은 적용하지 않았습니다.

LIKE 이스케이프는 OWASP가 아니라 SQLite 문서([Expressions](https://www.sqlite.org/lang_expr.html)의 LIKE 항목)에 근거합니다. `ESCAPE` 절 뒤에 한 글자를 적으면, 그 글자 다음의 `%`, `_`, 그 글자 자신이 와일드카드가 아닌 글자 그대로 비교됩니다. 이스케이프 문자는 `!`로 정하고 SQL에 `ESCAPE '!'`로 알립니다. 입력의 `!`, `%`, `_` 앞에 `!`를 붙이면 됩니다. 이스케이프하는 대상은 LIKE 패턴 안의 글자뿐이며, SQL 문장 전체를 이스케이프해서 막는 방식이 아닙니다.

```bash
python -c "p='queries.py'; s=open(p, encoding='utf-8').read(); open(p, 'w', encoding='utf-8', newline='
').write(s[:s.index('def search_members')])"
cat >> queries.py <<'PY'
SEARCH_SQL = {
    "name": "SELECT id, name FROM members WHERE name LIKE ? ESCAPE '!' ORDER BY name",
    "joined": "SELECT id, name FROM members WHERE name LIKE ? ESCAPE '!' ORDER BY joined",
}


def escape_like(text):
    return text.replace("!", "!!").replace("%", "!%").replace("_", "!_")


def search_members(db_path, name, sort):
    if sort not in SEARCH_SQL:
        raise ValueError("허용되지 않는 정렬 기준입니다")
    return run(db_path, SEARCH_SQL[sort], ("%" + escape_like(name) + "%",))


def find_user(db_path, username, password):
    rows = run(db_path, "SELECT id, username FROM members WHERE username = ? AND password = ?",
               (username, password))
    return rows[0] if rows else None
PY
python -m pytest -v --no-header
bandit -q -r queries.py app.py; echo "exit=$?"
```

```
# 기대 출력
test_queries.py::test_apostrophe_name_is_found PASSED                    [ 16%]
...
test_queries.py::test_like_wildcard_is_literal PASSED                    [100%]
============================== 6 passed in 0.29s ==============================
exit=0
```

![여섯 개 테스트가 모두 PASSED이고 Bandit이 아무것도 보고하지 않아 exit=0으로 끝나는 것을 확인합니다.](https://copyandrun.com/img/sql-parameter-binding-flask/05-final-pass.png)

정렬 기준 `nope`는 400으로 거절되고, 검색어 `_`는 `a_b`만 찾습니다. 이 글의 환경에서 Bandit 1.9.4는 이어 붙이기가 없어진 `queries.py`에서 아무것도 보고하지 않았습니다. 정렬 기준이 늘어나면 `SEARCH_SQL`에 문장을 한 줄 더하면 됩니다. 이 앱의 두 쿼리는 코드에 적어 둔 고정 SQL 문장만 쓰므로 요청 값이 SQL 문장 안으로 들어가지 않습니다. 새 쿼리를 이어 붙여 만들면 이 성질은 사라지므로 테스트로 계속 확인해야 합니다.

Bandit이 조용하다는 것을 안전의 증거로 읽으면 안 됩니다. Bandit 공식 문서에 따르면 B608은 SQL 문장처럼 보이는 문자열이 `%` 서식, `+` 연결, `.format()`, f-string으로 만들어지는 곳을 찾는 검사입니다. 문자열 모양을 보는 방식이라 이어 붙이기가 없다는 사실만 알려 줄 뿐입니다. 데이터베이스에 저장된 값을 꺼내 다시 쿼리에 넣는 2차 주입, 다른 데이터베이스 드라이버, ORM의 원문 쿼리 호출처럼 모양이 다른 코드를 잡는다는 보장은 확인하지 못했습니다. 스캐너 통과는 안전의 증거가 아니므로 테스트와 코드 검토를 함께 씁니다.

## 자주 막히는 곳

| 증상 | 원인과 해결 |
|---|---|
| `python -m pytest`가 `No module named pytest`로 끝납니다 | 새 터미널에서 가상환경을 활성화하지 않은 경우입니다. `source .venv/Scripts/activate`를 다시 실행합니다 |
| Bandit 결과 경로가 `.\queries.py`로 나옵니다 | Windows 표기입니다. 다른 운영체제의 표기는 확인하지 못했습니다 |
| Bandit이 5단계에서도 B608 한 건을 보고합니다 | 정상입니다. `ORDER BY` 뒤에 `sort`를 이어 붙인 줄이 남아 있기 때문이며 6단계에서 사라집니다 |

## 이어서 해 볼 것

- **같은 테스트를 다른 조회에 적용**: 회원 삭제나 수정 쿼리를 하나 추가하고, 입력이 SQL 문장에 들어가지 않는지 같은 방식의 테스트를 먼저 작성합니다.
- **비밀번호 저장 방식 바꾸기**: 이 글의 평문 비밀번호는 연습용입니다. 해시 저장으로 바꾸고 `find_user`의 조회 방식을 함께 고쳐 봅니다.
- **CI 게이트에 반영**: Bandit의 `--severity-level medium`과 pytest를 파이프라인에 넣어, 이어 붙인 쿼리가 다시 들어오면 빌드가 실패하는지 확인합니다.

## 확인 범위

직접 실행한 부분은 1단계부터 6단계까지이며 환경은 Windows 11, Git Bash, Python 3.12.10, Flask 3.1.3, pytest 9.1.1, Bandit 1.9.4입니다. 2단계의 요청은 `127.0.0.1`의 개발 서버에 curl로 보냈습니다. 근거로 삼은 문서는 [Python sqlite3 문서](https://docs.python.org/3/library/sqlite3.html)의 자리표시자 설명, [OWASP SQL Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html), [Bandit B608 문서](https://bandit.readthedocs.io/en/latest/plugins/b608_hardcoded_sql_expressions.html)입니다.

SQLite 외의 데이터베이스와 ORM, `?` 외의 자리표시자 방식(`:이름`), Linux와 macOS에서의 실행, 실제 서비스 규모의 쿼리는 확인하지 못했습니다. B608의 판정 기준은 문서 설명과 이 글의 코드 결과만 대조했으며, 다른 형태의 코드에서 어떻게 나오는지는 확인하지 않았습니다. 이 글의 허용 목록과 이스케이프는 이 앱의 쿼리에 맞춘 예이고, 다른 구조의 쿼리에는 따로 검토가 필요합니다.
