보안 점검 후 Flask 서비스에서 고친 7가지

2026.10.04
현장 데이터 서비스 구축기 15편

사내 보안 점검 지적을 계기로 Flask 서비스의 인증과 세션 처리를 다시 살폈다. 하드코딩된 시크릿 키, 쿠키 폴백 인증, OIDC state·nonce 검증 등 7가지 항목과 수정 코드를 체크리스트로 정리했다.

이 글의 순서

    계기

    사내 보안 점검에서 Flask 서비스에 심각도 높은 지적이 두 건 나왔다. 하나는 소스에 박혀 있던 시크릿 키, 다른 하나는 SSO가 실패했을 때 쿠키 하나만 보고 사용자를 인정하던 예비 인증 경로였다. 둘 다 개발 초기에 "일단 돌아가게" 넣어둔 코드였다.

    지적받은 두 건만 고치고 끝내지 않고 인증과 세션 처리 전체를 다시 봤다. 그렇게 정리한 7가지다.

    손본 위치

    앱 기동시크릿 키
    로그인예비 인증 경로
    state와 nonce
    세션쿠키 속성
    권한관리자 재검증
    진단 경로, 기본 관리자

    시크릿 키를 소스에서 빼고, 없으면 기동하지 않기

    Flask의 SECRET_KEY는 세션 쿠키 서명에 쓰인다. 이 값이 소스에 있으면 저장소를 볼 수 있는 누구나 세션 쿠키를 위조할 수 있다. 환경변수로 옮기는 것만으로는 부족하다. 환경변수를 빠뜨렸을 때 코드 안의 기본값으로 조용히 동작하면 같은 문제가 남는다. 값이 없거나 짧으면 아예 기동하지 않게 했다.

    config.py
    import os
    
    SECRET = os.environ.get("FLASK_SECRET", "")
    if len(SECRET) < 32:
        raise RuntimeError("FLASK_SECRET 환경변수가 없거나 32자 미만이다")
    
    app.config["SECRET_KEY"] = SECRET
    키 만들기
    python -c "import secrets; print(secrets.token_urlsafe(48))"

    로컬 Windows에서 개발할 때는 터미널마다 설정하거나 사용자 환경변수로 한 번 등록해 둔다.

    PowerShell
    $env:FLASK_SECRET = "생성한-긴-값"
    python app.py
    PowerShell
    [Environment]::SetEnvironmentVariable("FLASK_SECRET", "생성한-긴-값", "User")
    # 새 터미널부터 적용된다

    예비 인증 경로 없애기

    SSO 연동 전에 쓰던 방식이 남아 있었다. SSO 세션이 없으면 다른 사내 시스템이 남긴 쿠키에서 사용자 ID를 읽어 로그인 처리하는 코드였다. 쿠키는 브라우저에서 얼마든지 만들 수 있으니 서명 검증이 없는 쿠키를 믿는 순간 누구든 다른 사람으로 로그인할 수 있다.

    "혹시 SSO가 안 될 때를 위해" 남겨둔 경로였지만, 예비 경로는 공격자에게도 예비 경로다. 통째로 지웠다. SSO가 실패하면 로그인 실패로 끝난다.

    이전

    SSO 세션 확인
    없으면 쿠키의 사용자 ID 사용위조 가능

    이후

    SSO 세션 확인
    없으면 SSO 로그인으로 이동다른 경로 없음

    관리자 권한은 매 요청 서버에서 다시 확인

    로그인할 때 세션에 is_admin=True를 넣어두고 이후에는 그 값만 보는 구조였다. 세션은 서명돼 있어 위조는 어렵지만, 관리자 목록에서 빠진 사람도 세션이 살아 있는 동안 계속 관리자다. 관리자 전용 라우트에서는 세션 플래그 대신 현재 사용자 ID를 서버의 관리자 목록과 매번 대조한다.

    auth.py
    import os
    from functools import wraps
    from flask import abort, session
    
    def admin_ids():
        raw = os.environ.get("ADMIN_IDS", "")
        return {x.strip().lower() for x in raw.split(",") if x.strip()}
    
    def admin_required(view):
        @wraps(view)
        def wrapper(*args, **kwargs):
            uid = (session.get("user_id") or "").lower()
            if not uid or uid not in admin_ids():
                abort(403)
            return view(*args, **kwargs)
        return wrapper

    OIDC 콜백에서 state와 nonce 검증

    SSO 로그인 흐름에서 빠져 있던 두 가지다. 표준에 있는 항목인데 "로그인은 되니까" 넘어가기 쉽다.

    값막는 것확인 위치
    state다른 사람이 만든 로그인 응답을 내 브라우저에 밀어 넣는 공격(로그인 CSRF)콜백 요청 파라미터
    nonce가로챈 ID 토큰을 다시 쓰는 공격(재전송)ID 토큰 안의 클레임
    로그인 시작과 콜백
    import secrets
    from flask import redirect, request, session, abort
    
    @app.route("/login")
    def login():
        session["oidc_state"] = secrets.token_urlsafe(32)
        session["oidc_nonce"] = secrets.token_urlsafe(32)
        return redirect(build_authorize_url(state=session["oidc_state"],
                                            nonce=session["oidc_nonce"]))
    
    @app.route("/acs", methods=["POST"])
    def acs():
        expected_state = session.pop("oidc_state", None)
        expected_nonce = session.pop("oidc_nonce", None)
        if not expected_state or request.form.get("state") != expected_state:
            abort(400)
        claims = verify_id_token(request.form["id_token"])  # 서명·만료 검증 포함
        if claims.get("nonce") != expected_nonce:
            abort(400)
        session.clear()                      # 로그인 전 세션 재사용 방지
        session["user_id"] = claims["upn"]
        return redirect("/")

    콜백 방식(POST 폼, 쿼리 파라미터)과 사용자 ID 클레임 이름은 IdP 설정에 따라 다르다.

    세션 쿠키 속성

    config.py
    app.config.update(
        SESSION_COOKIE_SECURE=True,     # https에서만 전송
        SESSION_COOKIE_HTTPONLY=True,   # 스크립트에서 읽기 금지
        SESSION_COOKIE_SAMESITE="Lax",  # 다른 사이트발 요청에 쿠키 제한
    )

    SameSite와 SSOIdP가 콜백을 다른 도메인에서 POST로 보내는 구조라면 Lax에서는 콜백 요청에 세션 쿠키가 실리지 않아 state 비교가 실패할 수 있다. 이때는 콜백 구조를 확인하고, 필요하면 None과 Secure를 함께 쓴다.

    진단 경로와 기본 관리자 ID

    SSO를 붙일 때 토큰 내용을 보려고 만든 진단용 라우트가 있었다. 로그인만 하면 누구나 볼 수 있었다. 관리자 전용으로 막았다. 운영이 안정되면 지우는 게 맞다.

    관리자 목록에는 개발자 본인 ID가 기본값으로 박혀 있었다. 환경변수를 안 넣어도 관리자가 생기는 셈이다. 기본값을 지우고 환경변수로만 받는다. 목록이 비면 관리자는 아무도 없다.

    고쳤다는 말 대신 검색 결과로 확인하기

    이 작업은 AI와 함께 했다. 중간에 AI가 "기본 관리자 ID를 제거했다"고 답했는데, 받은 파일을 열어보니 그대로 남아 있었다. 설명과 실제 파일이 다를 수 있다. 그 뒤로 보안 수정은 항목마다 검색 명령으로 확인했다.

    수정 확인용 검색
    # 하드코딩된 키나 기본값이 남아 있는지
    grep -rnE "SECRET_KEY\s*=\s*['\"]" --include=*.py .
    grep -rnE "ADMIN_IDS.*['\"][a-z0-9._-]+['\"]" --include=*.py .
    
    # 폴백 인증 흔적
    grep -rn "request.cookies.get" --include=*.py .
    
    # 진단 라우트에 권한 데코레이터가 붙었는지
    grep -rn -B2 "def diag" --include=*.py .

    체크리스트

    정리

    • "일단 돌아가게" 넣은 기본값과 예비 경로가 보안 지적의 대부분이었다.
    • 설정이 빠졌을 때 조용히 기본값으로 도는 것보다 기동 실패가 안전하다.
    • OIDC state와 nonce는 로그인이 되더라도 빠져 있을 수 있다. 일부러 확인한다.
    • 보안 수정은 설명이 아니라 파일 검색 결과로 확인한다.
    이전 편14편. 대화형 로그인 라이브러리를 컨테이너에서 자동 로그인시키기
    #Flask#웹보안#시큐어코딩#OIDC#세션#SECRET_KEY#보안점검#Python

    댓글