본문 바로가기
C.W.K.
Lesson 04 of 04 · published

사진 앞의 관문 셋

~13 min · Tailscale, 같은 출처, PIN 인증, 미들웨어

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

소울을 묻기도 전에

앞의 세 레슨은 어느 소울이 어느 사진을 볼 수 있는지 정했어. 이번엔 어느 기기가 스튜디오와 말을 섞을 수나 있는지 정해. app.py의 미들웨어 함수 하나가 모든 요청에서 어떤 라우트보다 먼저 돌고, 검사를 정해진 순서로 해. 네트워크가 먼저고, 이어서 경로별 규칙, 마지막이 관리자 확인이야. 하나에서 떨어진 요청은 다음 관문에 닿지도 못해.

관문 1: 네트워크

require_family_network는 TCP 상대 주소를 소켓에서 읽어. 헤더에선 절대 안 읽어. 허용하는 건 셋이야. 루프백, Tailscale의 IPv4 대역 100.64.0.0/10(RFC 6598이 통신사급 NAT용으로 떼어 둔 공유 주소 공간), Tailscale의 IPv6 접두사 fd7a:115c:a1e0::/48. 나머지는 집 LAN 주소까지 포함해서 전부 403과 함께 Use the family Tailscale connection.을 받아. 가족 Tailscale 연결로 들어오라는 말이야.

백엔드 테스트는 클라이언트 둘로 요점을 보여 줘. 집 LAN에 있는 상대가 x-forwarded-for: 127.0.0.1을 보내도 403을 받아. 이 관문은 어떤 헤더로도 안 열리거든. tailnet 상대, 예를 들어 100.64.0.7은 /api/health에선 200을, /internal/providers에선 403을 받아. 내부 경로엔 따로 규칙이 있어서야.

헤더가 의미를 갖는 곳은 client_ip 딱 한 군데야. TCP 상대가 루프백일 때만 x-forwarded-for를 믿어. 스튜디오의 HTTPS 쌍둥이를 실어 나르는 Tailscale Serve의 로컬 프록시가 바로 그렇게 들어오거든. Serve는 tailnet 안에서만 답해. 그래서 쌍둥이를 거쳐 온 브라우저는 루프백으로 관문 1을 넘고, 진짜 주소는 헤더에 실려 권위 있는 쪽의 판단을 받으러 가.

내부 경로는 이 맥의 엔진 몫이야

/internal/ 아래에선 미들웨어가 require_internal을 불러. 상대는 루프백이어야 하고, 요청엔 x-forwarded-for, origin, sec-fetch-mode가 하나도 없어야 해. Serve는 첫 번째를 붙이니까 HTTPS 쌍둥이로 들어온 건 하나도 못 지나가. 맥에서 바로 연 브라우저는 세 번째에 걸려. 요즘 브라우저는 루프백 주소로 가는 요청마다 sec-fetch-mode를 붙이거든. origin은 그보다 약한 검사야. 같은 출처의 GET이나 HEAD, 그리고 이미지 로드 같은 no-cors GET엔 브라우저가 안 붙여. 그래서 교차 출처 호출과 POST는 잡아도 모든 요청을 잡진 못해. 결국 여기선 관문이 Fetch Metadata에 기대는 거야. 그게 나오기 전의 낡은 브라우저로 맥에서 내부 URL을 바로 열면 셋 중 아무것도 안 붙어. 그 구석만 빼면, 남는 건 같은 기계에서 도는 평범한 HTTP 클라이언트뿐이야. 피파가 그렇고, 루프백에서 끝나는 터널을 타고 오는 사이트의 내보내기 관문이 그래. actor_soul_id를 받는 문이 바로 여기고, 맥 밖에선 아무도 두드릴 수 없어.

관문 2: 같은 출처

/api/ 경로는 전부 그다음 same_origin을 거쳐. sec-fetch-site가 cross-site인 요청도, Origin이 Host 헤더와 다른 호스트를 대는 요청도 거절해. 아빠 브라우저에 열린 다른 페이지가 아빠 세션으로 스튜디오를 조종할 수는 없어. 세션 쿠키도 SameSite=strict라서, 교차 사이트 요청엔 애초에 실리지도 않아. 문 하나에 자물쇠가 따로 두 개 달린 셈이야.

관문 3: 이 사람이 아빠야?

/api/ 경로는 넷만 빼고 전부 관리자 검사를 거쳐야 해. 빠지는 넷은 /api/health, /api/config, /api/auth, /api/auth/status야. 잠금을 풀기 전의 기기가 닿아야 하는 것들이지. 상태 확인, 앱 설정, PIN 입력, 잠금 상태. require_admin은 권위 있는 곳 두 군데에 물어. Avatars는 사용자도, PIN도, 세션도 자기가 따로 들고 있지 않아.

  1. Firelink의 가족 파사드가 기기의 보안 상태를 알려 줘. 차단된 기기거나 깊은 잠금 상태면 403이야. PIN이 켜져 있는데 이 기기가 인증 전이면 401과 함께 Unlock with the family PIN.이 와. 가족 PIN으로 잠금을 풀라는 뜻이야.
  2. 피파의 /api/users가 지금 사용자를 알려 줘. is_admin 능력이 없으면 403과 함께 Avatar administration is available to Dad.가 와. 아바타 관리는 아빠만 할 수 있다는 말이야.

두 호출 모두 찾아온 기기 자신의 헤더를 실어. 풀어낸 주소를 x-forwarded-for로, 그리고 출처, fetch 사이트, 쿠키, 피파 세션 헤더까지. 모듈 docstring이 한 줄로 말해. Avatars는 실제로 찾아온 기기에 대해 가족 권위에 묻는다고. 이걸 다 지나야 요청이 행위자 없이 사진을 읽는 스튜디오 라우트에 닿아. 첫 레슨에서 본, 모든 소울을 보는 관리자 시야가 바로 거기야.

PIN과 쿠키

POST /api/auth는 킷의 verify_pin_verdict로 PIN을 가족 권위에 넘기면서, 기기의 주소를 클라이언트로 지목해. 권위는 발급하는 세션을 그 주소에 묶어. 이게 없으면 엔진 자신의 루프백이 모든 기기를 대신해서 답하게 돼. 그러면 어디서든 틀린 PIN 다섯 번이면 루프백이 블랙리스트에 오르고, 그 뒤로 엔진을 거치는 PIN은 전부 거절돼. 검증자에 닿지 못하면 503이지, 틀린 PIN으로 치지 않아. 성공하면 스튜디오는 pippa_session을 HttpOnly, SameSite=strict로 심어. 요청이 HTTPS였거나, 루프백 프록시를 거쳐 x-forwarded-proto: https로 왔으면 Secure도 붙이고. PIN 층과, 첫 방어선 노릇을 하는 Tailscale은 Solo Auth Quest에서 직접 만들어 봐. 이 레슨은 형제 앱이 그걸 가져다 쓰는 모습만 보여 줘.

연결은 같이, 자격 증명은 따로

Avatars는 업스트림 호출에 풀링된 HTTP 클라이언트 하나를 써. 연결은 최대 16개야. 풀링이 안전한 건 호출마다 받은 헤더만으로 요청을 새로 만들기 때문이야. 주석에도 그렇게 적혀 있어. 연결은 나눠 쓰지만, 기기마다의 자격 증명은 나누지 않는다고. 테스트가 그걸 증명해. 업스트림이 Set-Cookie로 답해도, 쿠키 없이 보낸 다음 요청엔 쿠키가 하나도 없어. 여기 클라이언트 수준의 쿠키 저장소가 있었다면, 한 기기의 세션이 다른 기기의 요청에 조용히 올라탔을 거야.

그리고 라우트까지 간 응답엔 전부 X-Content-Type-Options: nosniff가 붙어. /api/와 /internal/ 아래 응답엔 Cache-Control: private, no-store도 붙어. 보호받는 답의 사본을 공유 캐시가 쥐고 있지 않게 하려는 거야. 관문이 직접 거절한 답엔 둘 다 안 붙어. 관문이 예외를 던지면 except 갈래가 새 JSONResponse를 만들어 바로 돌려주는데, 그건 헤더를 붙이는 두 줄을 안 지나가거든. 그런 답엔 오류 메시지만 있고 사진은 없으니 보호받는 게 새진 않아. 그래도 이 헤더는 모든 응답이 아니라 라우트까지 간 응답의 몫이야.

원칙: 누가 접속할 수 있는지는 소켓으로, 누가 행동할 수 있는지는 권위로 정해. 호출자가 채우는 필드로는 절대 정하지 마.

Code

cwkAvatars, backend/auth.py: 관문 1은 소켓을 읽고, 포워딩은 루프백에서 온 것만 믿어 (원문 그대로, 7-28행)·python
def require_family_network(request: Request):
    try:
        peer = ipaddress.ip_address(request.client.host)
        allowed = peer.is_loopback or peer in ipaddress.ip_network("100.64.0.0/10") or peer in ipaddress.ip_network("fd7a:115c:a1e0::/48")
    except (AttributeError, ValueError):
        allowed = False
    if not allowed:
        raise StudioError("Use the family Tailscale connection.", 403)


def client_ip(request: Request) -> str:
    peer = request.client.host if request.client else ""
    try:
        local = ipaddress.ip_address(peer).is_loopback
    except ValueError:
        local = False
    forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
    value = forwarded if local and forwarded else peer
    try:
        return str(ipaddress.ip_address(value))
    except ValueError as exc:
        raise StudioError("The client address is unavailable.", 403) from exc
cwkAvatars, backend/app.py: 미들웨어, 네트워크 먼저, 그다음 경로 규칙, 마지막에 관리자 (원문 그대로, 124-140행)·python
    @app.middleware("http")
    async def access(request, call_next):
        try:
            require_family_network(request)
            if request.url.path.startswith("/internal/"):
                require_internal(request)
            elif request.url.path.startswith("/api/"):
                same_origin(request)
                if request.url.path not in {"/api/health", "/api/config", "/api/auth", "/api/auth/status"}:
                    await (auth_check or require_admin)(request, upstream)
            result = await call_next(request)
            result.headers["X-Content-Type-Options"] = "nosniff"
            if request.url.path.startswith(("/api/", "/internal/")):
                result.headers["Cache-Control"] = "private, no-store"
            return result
        except StudioError as exc:
            return JSONResponse({"detail": str(exc)}, status_code=exc.status)
cwkAvatars, backend/auth.py: require_admin은 찾아온 기기에 대해 Firelink에, 그다음 피파에 물어 (원문 그대로, 54-64행)·python
async def require_admin(request, upstream):
    same_origin(request)
    headers = caller_headers(request)
    status = await upstream.json(upstream.settings.family, "/api/security/status", headers=headers)
    if status.get("blocked") or status.get("auth_mode") == "deep_lockdown":
        raise StudioError("Family access is locked for this device.", 403)
    if status.get("auth_mode") != "off" and not status.get("authenticated"):
        raise StudioError("Unlock with the family PIN.", 401)
    users = await upstream.json(upstream.settings.pippa, "/api/users", headers=headers)
    if not users.get("active", {}).get("capabilities", {}).get("is_admin"):
        raise StudioError("Avatar administration is available to Dad.", 403)

External links

Exercise

네가 만든 작은 웹 서비스 하나를 골라서 모든 라우트 앞에 미들웨어를 하나 세워 봐. 관문 1은 소켓 상대에서 읽은 주소가 루프백이거나 네 VPN 대역일 때만 받아. 관문 2는 API 경로에서 교차 사이트 요청과 Origin이 안 맞는 요청을 거절해. 관문 3은 로그인을 쥐고 있는 쪽에 이 기기가 인증됐고 허용됐는지 물어. 테스트도 두 개 써. x-forwarded-for를 위조한 LAN 클라이언트도 403을 받는다. 업스트림의 Set-Cookie는 다른 호출자의 다음 요청에 절대 안 나타난다.
Hint
FastAPI의 TestClient는 client=(host, port) 튜플을 받아. 그게 미들웨어가 보는 소켓 상대를 정하니까, 진짜 네트워크 없이 관문 1을 시험할 수 있어. 주소는 문서용 대역이나 네 VPN 대역 안에서 지어낸 걸 써. 쿠키 테스트는 요청을 하나씩 기록하는 httpx.MockTransport면 충분해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.