최규선.com(Flask·Fly.io·SQLite)에 OWASP API Security Top 10(2023)을 항목별로 대입해 점검하고, 취약 6건·위험 3건을 조치한 뒤 읽기 전용 점검 도구 api-sec-check로 충족 24·주의 2·미충족 0을 확인한 기록임.

GitHub: kh20134/owasp-api-top10-kyusun

개요

  • 대상: 본인 블로그 최규선.com. Flask 앱, Fly.io 머신 1대, SQLite /data/blog.db
  • 기준: OWASP API Security Top 10 (2023)
  • 범위: 공개 페이지와 관리자 라우트. 게시글 본문과 DB 데이터는 변경하지 않음
  • 판정: 양호 / 위험 / 취약 3단계
  • 원칙: 일반 방문과 정상 관리 흐름은 그대로 유지한 상태에서 조치함
  • 실제 반영: 블로그 저장소 PR #37(병합 커밋 a62efc8)에서 항목별 조치, PR #38에서 CSP img-src 보완

왜 API 기준으로 봤나

  • 블로그도 결국 HTTP 엔드포인트 집합임. 공개 GET 7개, 관리자 라우트 11개, JSON 엔드포인트 1개(카테고리 정렬)
  • 웹 Top 10은 지난 글에서 실습 저장소로 다룸. 이번에는 운영 중인 본인 사이트에 API 기준을 직접 대입함
  • 관련 글: OWASP Top 10 공격기법 및 대응방안

점검 결과 요약

  • 조치 전: 취약 6건(API2·4·5·6·8·10), 위험 3건(API1·3·9), 양호 1건(API7)
  • 조치 후: 양호 5건(API1·3·5·7·9), 조치 완료 5건(API2·4·6·8·10)
  • 방문자 영향: HTML 본문, 글 주소, 사이트맵은 동일. 바뀐 것은 응답 헤더, 스크립트 출처, 관리 폼의 CSRF 숨은 필드뿐임
  • 관리자 영향: 배포 직후 쿠키 이름이 blog_session으로 바뀌어 한 번 재로그인. 로그인 유지 8시간

구조도

최규선.com 요청 경로와 신뢰 경계: 브라우저, Fly edge, Flask 보안 미들웨어, SQLite 볼륨, 정적 vendor 자산

요청은 브라우저 → Fly 가장자리(TLS 종료) → Flask 보안 미들웨어(헤더·CSP, CSRF, 속도 제한, 인가, 입력 허용 목록) 순으로 들어가 SQLite 볼륨과 /static/vendor/에 닿음. 파선은 신뢰 경계임. (출처: 본인 깃허브)

  • 신뢰 경계 1: 브라우저. 공개 GET과 관리 화면
  • 신뢰 경계 2: Fly edge. FLY_APP_NAME이 있을 때만 프록시 한 홉 신뢰
  • 신뢰 경계 3: Flask 프로세스. 미들웨어를 통과해야 DB와 정적 자산에 접근

API1. Broken Object Level Authorization

  • 개념: 객체 식별자로 데이터에 닿는 기능마다 그 객체에 대한 인가 확인 필요. 식별자만으로 다른 객체에 닿으면 BOLA임
  • 위험성: 비공개 객체나 타인 소유 객체가 번호 변경만으로 열릴 수 있음
  • 적용 전 상태: 위험(낮음)
    • 공개 조회 get_post는 published=1만 조회. 비공개 글은 404
    • 관리 객체 접근은 session["admin_ok"] 불리언만 확인. 플래그만 있으면 모든 글·카테고리 ID 도달
    • 계정이 하나라 가로 월권은 없으나, ID가 순번이라 존재 여부는 추측 가능
  • 조치 내용
    • admin_session_ok()가 admin_ok와 admin_user == ADMIN_USERNAME을 함께 확인
    • 비교는 hmac.compare_digest
    • 없는 글 수정은 404. 비로그인 삭제는 302로 로그인에 보내고 DB는 그대로 둠
  • 검증: test_​api1_​unpublished_​object_​is_​hidden_​and_​admin_​can_​open_​it. 조치 후 양호

API2. Broken Authentication

  • 개념: 인증이 잘못 붙으면 세션·토큰 탈취나 다른 자격으로의 접속이 가능해짐
  • 위험성: 실패 횟수 제한 없는 로그인, 공개된 서명 키, 고정된 세션은 관리자 세션 탈취로 이어짐
  • 적용 전 상태: 취약
    • FLASK_SECRET_KEY가 없으면 코드에 적힌 공개 기본값으로 쿠키 서명
    • 로그인 실패 횟수 제한 없음
    • 로그인 성공 시 세션을 비우지 않음(세션 고정 가능)
    • 쿠키 Secure가 기동 시점 환경변수에만 의존
  • 조치 내용
    • IP당 실패 8회/10분 초과 시 429, Retry-After: 60
    • 아이디·비밀번호 모두 상수 시간 비교
    • 성공 시 session.clear() 후 재발급
    • Fly에서 키가 없거나 예전 공개 기본값이면 그 값으로 서명하지 않음
    • 쿠키 HttpOnly, SameSite=Lax, HTTPS면 Secure, 수명 8시간
    • next 파라미터는 외부 호스트·/admin 밖 경로를 버림(오픈 리다이렉트 차단)
    • 로그인 POST에도 CSRF 토큰 요구
  • 검증: test_​api2_​login_​locks_​rotates_​session_​and_​rejects_​open_​redirect, test_​api2_​fly_​cookie_​is_​secure_​and_​secret_​is_​not_​the_​public_​default. 조치 완료

API3. Broken Object Property Level Authorization

  • 개념: 객체 속성 단위로 읽기·쓰기를 구분하지 않으면 과다 노출이나 대량 할당(Mass Assignment) 발생
  • 위험성: 요청에 끼워 넣은 추가 속성이 클라이언트가 정하면 안 되는 값을 바꿀 수 있음
  • 적용 전 상태: 위험
    • 정렬 함수는 id, parent_id, sort_order만 읽어 이름·슬러그 변경은 불가했음
    • 다만 알 수 없는 필드를 거부하지 않고 무시함. 폼도 동일
  • 조치 내용: 필드 허용 목록 밖은 400 또는 저장 거부
    • 정렬 JSON 최상위: items, csrf_token
    • 정렬 항목: id, parent_id, sort_order
    • 글 폼: title, category_id, body_html, published, csrf_token
    • 카테고리 폼: name, parent_id, csrf_token
    • 업로드: 파일 image와 csrf_token
  • 검증: test_​api3_​and_​api5_​reject_​extra_​fields_​and_​missing_​csrf. 조치 후 양호

API4. Unrestricted Resource Consumption

  • 개념: 요청 크기·횟수·연산에 상한이 없으면 자원을 무제한 소모함
  • 위험성: 로그인·정렬·업로드 반복만으로 프로세스와 저장 공간이 묶일 수 있음
  • 적용 전 상태: 취약
    • 상한은 MAX_CONTENT_LENGTH 10MB 하나뿐
    • 로그인·정렬·업로드 속도 제한 없음
    • 제목·카테고리 이름·본문·정렬 건수 서버 상한 없음
  • 조치 내용
    • 정렬 JSON 64KB 초과 413, 항목 500건 초과 400
    • 관리 JSON·업로드 IP당 60회/분, 초과 429
    • 제목 160자, 카테고리 이름 40자, 본문 1,000,000자
    • 폼 비파일 필드 1MB, 파트 20개. 전체 요청 10MB는 유지
    • gunicorn: 요청 줄 4094, 헤더 40개, 타임아웃 30초, 워커 1·스레드 2
    • 공개 HTML GET은 제한하지 않음. 검색 로봇과 일반 방문 보호 목적
  • 검증: test_​api4_​limits_​login_​json_​and_​body_​size. 조치 완료

API5. Broken Function Level Authorization

  • 개념: 기능 단위 인가가 없으면 일반 요청이 관리 기능을 실행함
  • 위험성: 관리 변경이 세션 플래그만으로 열리면, 로그인된 브라우저가 의도하지 않은 변경을 실행할 수 있음(CSRF)
  • 적용 전 상태: 취약
    • @required 데코레이터가 세션 플래그만 확인
    • CSRF는 카테고리 정렬에만 있음
    • 삭제·게시·업로드·로그아웃·카테고리 변경을 타 사이트에서 호출 가능
  • 조치 내용
    • admin_required가 계정 일치와 POST/PUT/PATCH/DELETE의 CSRF를 함께 확인
    • 비로그인 요청은 302로 /admin/login, 데이터 불변
    • CSRF 실패 시 JSON·업로드는 400, 폼은 대시보드로 복귀
  • 검증: test_​api3_​and_​api5_​reject_​extra_​fields_​and_​missing_​csrf, tests/test_categories.py 정렬 인증 테스트. 조치 후 양호

API6. Unrestricted Access to Sensitive Business Flows

  • 개념: 로그인·공개·삭제처럼 업무상 민감한 흐름은 자동화·남용 방지 후 개방해야 함
  • 위험성: 잠금 없는 로그인, 세션만으로 되는 게시·삭제가 관리 상태를 바꿈
  • 적용 전 상태: 취약
    • 로그인 잠금 없음
    • 게시·삭제가 다른 사이트의 폼 제출로 실행 가능
  • 조치 내용
    • API2 로그인 잠금 + API5 CSRF를 이 흐름에 적용
    • 대시보드·편집기·로그인 폼이 토큰을 자동 포함해 기존 버튼 동작 유지
    • 삭제 확인창 confirm을 data-confirm과 페이지 스크립트로 옮겨 CSP와 양립
    • 재인증 비밀번호 창은 미도입. 관리 단계를 늘리지 않기 위함
  • 검증: test_​api6_​publish_​flow_​requires_​csrf_​and_​still_​publishes. 조치 완료

API7. Server Side Request Forgery

  • 개념: 서버가 사용자 제공 URL을 검증 없이 요청하면 SSRF임
  • 위험성: 서버가 내부 주소나 클라우드 메타데이터 주소를 대신 열 수 있음
  • 적용 전 상태: 양호
    • urlopen, requests, httpx 호출 없음
    • 글 HTML의 a href, img src는 브라우저에서만 쓰임
    • 서버가 글 안의 URL을 가져가지 않음
  • 조치 내용: 향후 대비용 반출 허용 목록 추가
    • hardening.outbound_​url_​allowed는 Google Fonts 두 호스트만 허용
    • http, 사용자 정보 포함 URL, 메타데이터 주소, 기타 호스트 거부
    • require_outbound_url을 서버 반출의 유일한 통과점으로 지정
  • 검증: test_​api7_​outbound_​allowlist_​blocks_​metadata_​and_​private_​urls. 양호 유지

API8. Security Misconfiguration

  • 개념: 응답 헤더, 디버그, 서버 표기, 배포 액션 고정이 빠지면 설정 오류임
  • 위험성: 프레임 삽입, 콘텐츠 스니핑, 디버그 출력, 버전 노출, 고정되지 않은 액션이 공격면을 넓힘
  • 적용 전 상태: 취약
    • 관리 경로 X-Robots-Tag 외 보안 헤더 없음
    • debug=True를 코드에서 강제로 끄지 않음
    • 개발 서버가 Werkzeug/<버전> 표기
    • 배포 액션 setup-flyctl@master
  • 조치 내용
    • CSP(스크립트는 'self'+요청별 nonce), nosniff, X-Frame-Options: DENY, Referrer·Permissions·COOP 정책 추가
    • HTTPS에서 HSTS 1년+includeSubDomains, CSP upgrade-insecure-requests
    • 관리 응답 Cache-Control: no-store, Referrer-Policy: no-referrer
    • app.debug 매 요청 거짓 고정. 500은 일반 문구만 반환
    • gunicorn Server 헤더 제거
    • 업로드는 확장자와 이미지 시그니처가 둘 다 맞을 때만 저장
    • PR #38: 글 8–13의 GitHub 이미지가 막혀 img-src에 raw.githubusercontent.com 한 곳만 추가
img-src 'self' data: blob:
  https://raw.githubusercontent.com
  • 검증: test_​api8_​security_​headers_​debug_​and_​image_​signature, api-sec-check 보안 헤더 항목 충족. 조치 완료

API9. Improper Inventory Management

  • 개념: 실제 떠 있는 경로와 문서·버전이 어긋나면 목록 밖 API(그림자 API)가 남음
  • 위험성: 문서화되지 않은 관리 JSON이나 옛 화면이 호출 대상으로 남을 수 있음
  • 적용 전 상태: 위험(낮음)
    • 관리 JSON 1건이 목록으로 고정되어 있지 않음
    • /security, /admin/fly-billing 템플릿은 남아 있으나 라우트가 없어 운영 404
    • /api/v1 같은 옛 버전 경로는 없음
  • 조치 내용
    • 라우트·메서드 집합을 테스트로 잠금. 새 경로가 생기면 CI 실패
    • 버전 경로를 새로 열지 않음
    • /health는 버전 정보 없이 {"status":"ok"}만 반환
  • 검증: test_​api9_​route_​inventory_​matches_​the_​blog. 조치 후 양호

API10. Unsafe Consumption of APIs

  • 개념: 제3자 스크립트·글꼴·빌드 액션·패키지를 검증 없이 받으면 그 공급망을 그대로 실행함
  • 위험성: SRI 없는 CDN 스크립트나 고정되지 않은 액션·의존성이 바뀌면 페이지와 배포가 함께 바뀜
  • 적용 전 상태: 취약
    • 홈·관리 화면이 jsDelivr의 Sortable 1.15.6, Quill 2.0.3, Three 0.170.0을 SRI 없이 로드
    • setup-flyctl이 @master
    • requirements.txt는 Flask와 gunicorn만 고정, 전이 의존성 미고정
  • 조치 내용
    • 세 라이브러리를 같은 버전으로 /static/vendor/에 두고 자체 제공
    • CSP script-src에서 CDN 호스트 제거
    • 글꼴은 Google Fonts 두 호스트만 CSP 허용
    • actions/checkout, setup-python, setup-flyctl을 커밋 SHA로 고정
    • Flask 3.1.1, gunicorn 23.0.0과 전이 의존성 == 고정
  • 검증: test_​api10_​dependencies_​and_​workflows_​are_​pinned. 로컬 헤드리스 확인에서 홈 교량 장면 scene-ready 도달. 조치 완료

api-sec-check 사용법

  • 저장소에 포함한 읽기 전용 점검 도구. 대상 URL의 응답 헤더·쿠키·CSRF 거부만 확인함
  • 기본 실행은 로그인 요청을 보내지 않음
  • 기대값은 위 조치 후 기준임
python -m pip install \
  -e ".[test]"
api-sec-check \
  https://xn--9h0bj97ao2i.com
python -m pytest

점검 항목

  • 보안 헤더와 CSP, HTTPS일 때 HSTS·upgrade-insecure-requests
  • script-src에 'self'와 nonce, 'unsafe-inline'·'unsafe-eval'·외부 출처 없음
  • img-src, 글꼴 호스트 범위
  • /admin/login의 no-store, no-referrer, noindex
  • 쿠키 blog_session의 HttpOnly·SameSite=Lax·Secure
  • Server 헤더에 Werkzeug·gunicorn·버전 숫자 없음
  • /health 본문이 {"status":"ok"}
  • CSRF 토큰 없는 로그아웃·삭제·정렬 POST가 거부되는지
  • --login-test 지정 시에만 429·Retry-After: 60 확인. 기본 9회, 상한 12회, 429 나오면 중단

종료 코드

  • 0: 미충족 없음
  • 1: 미충족 있음
  • 2: 인자 오류
  • 주의·건너뜀은 종료 코드를 올리지 않음. CI 게이트로 바로 사용 가능

운영 사이트 실행 결과

  • 명령: api-sec-check https://xn--9h0bj97ao2i.com (--login-test 미사용)
  • 결과: 충족 24, 주의 2, 미충족 0, 건너뜀 1(로그인 429)
  • 주의 1: style-src의 'unsafe-inline'
  • 주의 2: Fly 가장자리 Server: Fly/<빌드> 헤더
api-sec-check 실행 화면: 보안 헤더, 관리 응답, 쿠키, 버전 노출 항목

보안 헤더·관리 응답·쿠키·버전 노출 항목. style-src와 Server 두 건만 주의임. (출처: 본인 깃허브)

api-sec-check 실행 화면: /health, CSRF 거부, 요약 충족 24 주의 2 미충족 0

/health, CSRF 거부(302 로그인 이동), 로그인 속도 제한 건너뜀과 요약 줄. (출처: 본인 깃허브)

api-sec-check JSON 출력 요약: 충족 24, 주의 2, 미충족 0, 건너뜀 1

JSON 출력 끝부분의 요약: 충족 24, 주의 2, 미충족 0, 건너뜀 1. (출처: 본인 깃허브)

잔여 리스크

  • style-src 'unsafe-inline': 관리 편집기 Quill이 요소 스타일을 써서 남김. 스크립트 쪽에는 'unsafe-inline'·'unsafe-eval' 없음
  • Server: Fly 헤더: Fly 가장자리가 붙이는 빌드 표기. 애플리케이션 버전 노출은 아님
  • 분산 대입: 로그인 잠금이 IP 기준이라 IP를 나눠 시도하는 대입은 남음
  • 메모리 기반 제한: 잠금·속도 제한이 프로세스 메모리 기준. 머신이 늘면 버킷이 나뉨. 현재 머신 1·워커 1
  • Google Fonts: 브라우저가 계속 요청함. 사용자 본문은 보내지 않음
  • 미사용 템플릿: /security 등 라우트 없는 템플릿 파일은 삭제하지 않고 404 유지

결론

  • 작은 개인 블로그도 관리자 라우트와 JSON 1개만으로 API Top 10 중 6개 항목이 취약 판정이었음
  • 핵심 조치는 인증 강화, CSRF, 필드 허용 목록, 요청 상한, 보안 헤더, 공급망 고정임
  • 조치는 방문자 화면과 관리 흐름을 바꾸지 않는 선에서 적용함
  • 항목마다 테스트를 남겨 CI에서 회귀를 막고, api-sec-check로 운영 응답을 외부에서 재확인함
  • 남은 주의 2건(style-src, Fly Server)은 원인과 영향을 검토 문서에 기록해 둠

관련 글: OWASP Top 10 공격기법 및 대응방안

GitHub: https://github.com/kh20134/owasp-api-top10-kyusun