SQL 값과 질의 구조 분리
85분 안팎
학습 목표
매개변수화된 질의로 입력을 값으로 처리합니다.
개념
문자열이 질의 구조가 되지 않게 합니다
자료를 디스크에 저장하려면 메모리 Map을 SQLite 저장소로 바꿉니다. 이때 제목을 SQL 문자열에 직접 붙이면 제목의 작은따옴표가 SQL의 따옴표로 해석됩니다. 정상 자료인 O'Neil도 오류를 일으킬 수 있고 조건처럼 보이는 문자열은 조회 범위를 바꿀 수 있습니다. 제목 입력에서 따옴표를 지우면 사용자의 자료가 손상되며 질의 작성 결함은 그대로 남습니다. SQL을 코드로 고정하고 외부 값은 별도 매개변수로 전달하는 것이 이 레슨의 수정입니다.
로컬 실습의 실행 진입점은 Node.js입니다. 외부 패키지 설치 없이 Python 표준 sqlite3를 사용하려고 store.cjs가 spawnSync로 store.py를 호출합니다. 명령 문자열이나 셸을 만들지 않고 실행 파일과 인자 배열을 고정하며 데이터는 표준 입력 JSON으로 보냅니다. Python이 JSON을 읽어 실제 SQLite 파일에 질의를 실행합니다. 이는 Node.js에 가상의 내장 SQLite API가 있다는 설명이 아니며 다른 런타임에서도 같은 바인딩 원리를 적용할 수 있습니다.
값의 자리와 구조의 자리를 구분합니다
Python sqlite3의 execute에는 SQL 문자열과 값 튜플을 각각 전달합니다. SELECT의 owner와 title은 물음표 두 개의 값이며 값 튜플도 두 원소입니다. 문자열에 작은따옴표가 포함되어도 엔진은 그 문자열 전체를 한 값으로 취급합니다. 물음표를 따옴표로 감싸지 않습니다. 그렇게 하면 자리표시자가 아니라 물음표 문자 자체를 비교하게 됩니다. 조건절이 코드에서 고정되어 있다는 점과 사용자 입력이 바인딩 배열에만 있다는 점을 함께 리뷰합니다.
테이블 이름·컬럼 이름·정렬 방향은 값이 아니라 SQL 구조입니다. ORDER BY 뒤에 물음표를 놓고 사용자가 보내는 컬럼 이름을 값으로 묶으면 원하는 컬럼 선택이 되지 않습니다. 이 실습은 정렬을 id로 고정합니다. 나중에 제목 정렬을 제공하려면 title 또는 id처럼 코드에 적힌 허용 목록에서 구조를 선택하고 임의 입력을 이어 붙이지 않습니다. 검증의 허용 목록과 값 바인딩이 각각 어떤 자리를 보호하는지 설명할 수 있어야 합니다.
바인딩은 인가를 대신하지 않습니다
질의가 안전하게 작성되어도 타인 자료를 반환하면 인가 결함입니다. search는 서버가 아는 사용자 owner와 제목을 함께 조건에 넣고 update와 delete는 id와 owner를 동시에 제한합니다. 변경된 행 수 0은 소유자가 다르거나 자료가 없다는 결과입니다. secure-app은 먼저 기존 canAccess로 소유자 정책을 확인합니다. admin의 타인 읽기만 허용하고 타인 수정·삭제는 허용하지 않는 앞 규칙을 유지합니다.
create의 owner는 세션 사용자 ID에서만 설정합니다. body.owner를 바인딩하면 SQL 주입은 막아도 소유자 위조는 허용할 수 있습니다. 저장 어댑터는 권한표를 스스로 해석하지 않으므로 get에는 앱의 canAccess 검사가 선행되어야 응답으로 자료를 내보낼 수 있습니다. 저장소를 직접 조작할 권한이 있는 로컬 호출자는 별도 신뢰 경계입니다. 단위 시험이 통과했다고 DB 파일 접근 권한이나 관리자 접근이 완성되었다고 보고하지 않습니다.
앞 자료를 이관하고 원본을 확인합니다
미션의 app.cjs는 이전 Map 모델로 남겨 회귀를 실행하고 새 서버는 secure-app.cjs와 SQLite를 사용합니다. 이관할 자료는 이전 앱에서 소유자가 허가받아 조회한 id·title·content·owner 객체입니다. store의 migrate는 이 배열을 executemany로 한 트랜잭션에 넣습니다. 이관 중 id가 이미 있으면 전체 배치를 롤백합니다. INSERT OR REPLACE로 충돌 자료를 덮으면 기존 자료가 사라질 수 있으므로 이 교재에서는 사용하지 않습니다.
이관 검사는 같은 객체를 새 저장소에서 읽어 id·소유자·본문이 동일한지 비교하고 연결을 다시 열어 디스크 유지도 확인합니다. 중복 id가 있는 배치 앞부분도 저장되지 않았는지 조회합니다. 다음 새 자료의 ID가 이관된 최대 ID 뒤에 배정되는지도 관찰합니다. 원본 삭제와 전체 사용자 이관은 이번 함수 시험의 범위가 아닙니다. 실제 서비스를 이관한다면 중단·동시 쓰기·백업·복구와 접근 제한을 별도로 설계해야 합니다.
실습 파일을 읽고 수정합니다
security-sqlite-queries starter의 create는 이미 값을 묶지만 search는 문자열을 붙여 놓았습니다. query-test.cjs는 작은따옴표 제목의 정확한 검색, 조건 모양 제목의 빈 결과, 타인 변경 거절과 원본 보존을 시험합니다. store.py의 search 한 부분을 owner와 title 자리표시자로 바꾸고 값 튜플을 전달합니다. 테스트와 고정 정렬은 유지합니다. bash check.sh는 이 검사를 실행하고 실패 시 0이 아닌 종료 코드를 반환합니다.
solution 검사에서는 내용 변경과 삭제의 정상 사례도 이어집니다. 검색 결과가 모두 빈 배열이 되는 수정은 특수문자 거절 테스트 하나를 통과해도 정확한 제목 검색에서 실패합니다. 타인 변경 뒤 상태를 보지 않는 시험은 UPDATE를 먼저 실행한 후 거절하는 결함을 놓칩니다. 상태 코드뿐 아니라 행 수와 후속 자료 비교를 함께 쓰는 이유입니다. 일반 제목 한 건의 성공만으로 바인딩이 적용되었다고 결론 내리지 않습니다.
실패 원인과 남은 제약을 기록합니다
바인딩 개수 오류는 SQL의 물음표 수와 튜플의 원소 수가 다른 경우입니다. 한 값의 튜플은 Python에서 (value,)로 씁니다. 작은따옴표 제목으로 storage_failure가 나오면 Node 어댑터가 Python 실패를 요약한 것입니다. 외부 응답에 SQL이나 원문 값을 노출하지 말고 본인 실습 파일에서 질의 구조를 읽습니다. UNIQUE constraint 실패는 이관 충돌의 예상 사례이며 정상 데이터라면 ID 목록을 확인합니다.
이번 어댑터는 호출마다 동기 자식 프로세스를 만들므로 실제 서비스의 성능 설계가 아닙니다. 동시 쓰기·파일 권한·백업 삭제·암호화는 남는 검토 항목입니다. 바인딩 원리는 Python 공식 문서의 sqlite3 매개변수 안내에서 확인할 수 있습니다. 보고서는 특수문자가 값으로 저장·조회되었다는 사실과 인가 검사 유지 사실을 나누어 적습니다. 이 레슨 뒤에는 새 질의에서 값과 구조를 구분하고 소유자 제한을 빠뜨리지 않는 리뷰를 할 수 있어야 합니다.
이 기법의 공식 참고: Python sqlite3 값 바인딩 안내. 서재 더 읽기에서는 같은 원리를 다른 언어와 운영 맥락에서 확장합니다.
따라하기
실제 SQLite에서 작은따옴표를 값으로 저장합니다
Python 3로 실행합니다. 한 값의 튜플에 쉼표가 있다는 점과 SQL의 물음표 위치를 대조합니다. 메모리 DB이므로 디스크 파일을 만들지 않습니다.
import sqlite3
con=sqlite3.connect(':memory:')
con.execute('CREATE TABLE documents(title TEXT)')
con.execute('INSERT INTO documents VALUES(?)', ("O'Neil",))
print(con.execute('SELECT title FROM documents WHERE title=?',("O'Neil",)).fetchone()[0])
con.close()실행 결과
O'Neil
조건 모양 문자열을 정확한 제목으로 비교합니다
다른 사용자 자료를 포함한 두 행을 준비합니다. owner와 제목을 둘 다 값으로 묶어 조건처럼 보이는 제목이 전체 자료를 반환하지 않는지 확인합니다.
import sqlite3
c=sqlite3.connect(':memory:')
c.execute('CREATE TABLE documents(owner TEXT,title TEXT)')
c.executemany('INSERT INTO documents VALUES(?,?)',[('demo-alice','memo'),('demo-bob','other')])
for title in ['memo',"x' OR '1'='1"]:
print(len(c.execute('SELECT * FROM documents WHERE owner=? AND title=?',('demo-alice',title)).fetchall()))
c.close()실행 결과
1 0
타인 변경 뒤 원본을 다시 읽습니다
행 수 0과 원본 보존을 함께 확인합니다. 정상 소유자의 같은 변경은 행 수 1이며 제목이 바뀝니다.
import sqlite3
c=sqlite3.connect(':memory:')
c.execute('CREATE TABLE documents(id INTEGER,owner TEXT,title TEXT)')
c.execute('INSERT INTO documents VALUES(?,?,?)',(1,'demo-alice','before'))
for owner in ['demo-bob','demo-alice']:
n=c.execute('UPDATE documents SET title=? WHERE id=? AND owner=?',('after',1,owner)).rowcount
print(n,c.execute('SELECT title FROM documents WHERE id=?',(1,)).fetchone()[0])
c.close()실행 결과
0 before 1 after
로컬 과제에서 수정 전후를 검사합니다
이 레슨의 starter ZIP을 풀어 ZIP 루트에서 bash check.sh를 실행합니다. starter의 실패를 읽은 뒤 본문에서 지정한 함수나 질의를 수정합니다. solution ZIP에서도 같은 명령을 실행하여 정상 대조를 확인합니다. 출력 시간 등은 실행마다 달라질 수 있으며 마지막 PASS 행과 종료 코드 0을 확인합니다.
확인 문제
실습
store.py의 search에서 문자열 조합을 제거하고 owner·title을 자리표시자로 묶습니다. Node.js 진입점은 유지하며 Python 3 표준 sqlite3를 사용합니다. query-test.cjs를 바꾸지 않습니다. 작은따옴표·조건 모양 제목·타인 변경 후 원본·재연결·중복 이관 롤백·정상 변경과 삭제가 통과해야 합니다.
실행 명령
bash check.sh
기대 결과
PASS: query binding, ownership, persistence, migration rollback
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 취약점 수정 전후의 테스트 내용을 설명합니다.