제 이전 응답은 안전 분류기에 의해 중단되었습니다. 그래서 로컬 서버를 띄워 실제로 재현하는 단계는 진행하지 않았습니다. 아래 보고서는 저장소의 모든 파일(233줄)을 직접 읽고 분석한 결과입니다. 코드는 수정하지 않았습니다.
대상: app/ 전체, tests/, receipts/, 설정 파일 · 방식: 코드 정적 분석 (실행해서 재현하지는 않음)
| # | 심각도 | 취약점 | 위치 | 로그인 필요 |
|---|---|---|---|---|
| 1 | 🔴 치명 | SQL 인젝션 (예약 검색) | app/db.py:69 |
불필요 |
| 2 | 🔴 치명 | 경로 조작으로 서버의 임의 파일 읽기 (영수증) | app/server.py:52 |
필요 (누구나 가입 가능) |
| 3 | 🟠 높음 | 저장형 XSS (사장님 화면의 요청 사항) | app/views.py:22 |
필요 |
| 4 | 🟠 높음 | 다른 손님의 예약·영수증 열람 (IDOR) | app/server.py:40, :52 |
필요 |
| 5 | 🟠 높음 | 관리자 비밀번호가 소스에 박혀 있고 URL로 전달됨 | app/config.py:10, app/server.py:60 |
— |
| 6 | 🟡 중간 | 비밀번호를 솔트 없는 MD5로 저장 | app/db.py:29 |
— |
| 7 | 🟡 중간 | 토큰이 만료·교체되지 않고, 로그인 시도 제한 없음 | app/db.py:33, :39 |
— |
| 8 | 🔵 낮음 | 전화번호 가입 여부 노출, 입력 검증 부족, HTTPS 없음 | app/server.py:24-26, :71 |
— |
/search위치: app/db.py:66-70, 호출부 app/server.py:45-47
f"SELECT ... FROM reservations WHERE guest_name LIKE '%{name}%'"
name 값을 f-string으로 SQL 문에 그대로 끼워 넣습니다./search는 로그인 없이 누구나 호출할 수 있습니다.guests 테이블의 전화번호, 비밀번호 해시, 로그인 토큰까지 읽힐 수 있습니다. 토큰이 새면 모든 손님 계정을 바로 탈취할 수 있습니다(7번 참고)."... LIKE ?", (f"%{name}%",)./receipt위치: app/server.py:49-57
path = os.path.join(config.RECEIPT_DIR, q.get("file", ""))
file 값에 ../나 절대 경로가 들어와도 막지 않습니다. os.path.join은 절대 경로를 받으면 앞의 기준 디렉터리를 버립니다./register로 누구나 가입할 수 있어 사실상 막히지 않습니다.cafe.db 전체(모든 개인정보와 토큰), app/config.py(관리자 비밀번호)가 노출됩니다.os.path.realpath로 경로를 정규화한 뒤 RECEIPT_DIR 안에 있는지 확인하세요. 더 나은 방법은 파일명 대신 예약 ID를 받아서, 서버가 소유권을 확인한 뒤 경로를 만드는 것입니다.위치: app/views.py:22
f"<td>{int(r['people'])}</td><td>{r['note']}</td></tr>"
html.escape를 거치지만 note(요청 사항)만 빠져 있습니다. 테스트(tests/test_views.py)도 reservation_card만 확인하고 admin_table은 확인하지 않습니다./admin을 열 때 그 스크립트가 실행됩니다. 관리자 비밀번호가 URL에 들어 있으므로(5번), 스크립트가 현재 주소를 외부로 보내기만 해도 관리자 비밀번호가 유출됩니다.html.escape(r['note'])를 적용하고, CSP 헤더를 추가하고, admin_table 테스트를 추가하세요.위치: app/server.py:37-43, :49-57
- /reservation?id=N은 로그인 여부만 확인하고, 그 예약이 요청한 손님의 것인지는 확인하지 않습니다. ID가 1부터 순서대로 매겨지므로 모든 예약(이름, 방문 시간, 인원)을 차례로 조회할 수 있습니다.
- /receipt도 경로 조작이 없더라도 누구 영수증인지 확인하지 않습니다.
- 권고: 조회 조건에 guest_id = 현재 손님을 넣으세요.
위치: app/config.py:10, app/server.py:60
- 비밀번호 moonlight2026!가 소스 코드에 그대로 들어 있고 git에 커밋되어 있습니다. 저장소 접근 권한이 있는 사람이나 2번 취약점을 쓰는 사람이면 누구나 알 수 있습니다.
- 비밀번호가 ?pw= 쿼리 문자열로 전달되므로 브라우저 기록, 서버·프록시 로그, Referer 헤더에 남습니다.
- !=로 비교하므로 타이밍 공격에 약하고, 시도 횟수 제한도 없습니다.
- 권고: 환경 변수나 비밀 저장소로 옮기고, 현재 비밀번호는 이미 노출된 것으로 보고 교체하세요. POST 로그인과 세션 쿠키(HttpOnly, Secure, SameSite)를 쓰고, 비교에는 hmac.compare_digest를 쓰세요.
위치: app/db.py:28-29
- 솔트 없는 MD5를 씁니다. 1번이나 2번으로 DB가 새면 대부분의 비밀번호가 레인보우 테이블이나 GPU로 금방 풀립니다. 손님들이 다른 서비스에서도 같은 비밀번호를 쓴다면 피해가 그쪽으로 번집니다.
- 권고: hashlib.scrypt나 pbkdf2_hmac(표준 라이브러리), 또는 argon2/bcrypt를 쓰세요.
위치: app/db.py:32-49
- 토큰은 가입할 때 한 번 만들어지고, 만료·교체·로그아웃이 없습니다. 한 번 새면 영구적으로 유효합니다.
- 토큰을 DB에 평문으로 저장합니다. 1번이나 2번이 일어나면 그대로 사용할 수 있습니다.
- /login에 시도 횟수 제한이 없어서, 전화번호를 알면 비밀번호를 무작위로 대입할 수 있습니다.
- 권고: 로그인할 때마다 토큰을 새로 발급하고, 만료 시간을 두고, DB에는 해시로 저장하세요. 로그인 실패 횟수도 제한하세요.
/register를 호출하면 IntegrityError가 처리되지 않아 연결이 끊깁니다. 응답이 달라지므로 특정 번호가 가입되어 있는지 알 수 있습니다 (app/server.py:71).KeyError, 숫자가 아니면 ValueError가 납니다. Content-Length에 크기 제한이 없고 음수 값도 막지 않아, 요청 처리 스레드가 붙잡히거나 메모리를 과하게 쓸 수 있습니다 (app/server.py:24-26).X-Content-Type-Options·CSP 같은 보안 헤더도 없습니다. 지금은 127.0.0.1에만 바인딩되어 있지만, 손님이 쓰려면 외부에 공개해야 하므로 리버스 프록시와 TLS가 필요합니다./health의 subprocess.run: 인자가 고정된 리스트이고 셸을 거치지 않아 명령 주입 위험이 없습니다. 다만 단순히 시각을 얻는 용도라면 datetime으로 충분합니다.list_today의 ORDER BY 문자열 결합: SORT_COLUMNS 허용 목록을 거치므로 안전합니다.reservation_card, page: 이스케이프가 제대로 되어 있습니다.tests/fixtures/sample_guests.json: 예시용 가짜 값입니다. git 이력은 커밋 1개뿐이라 추가로 노출된 비밀은 없습니다(5번은 제외).원하시면 이 보고서를 공유하기 쉬운 웹 페이지로도 만들어 드리겠습니다.