엔지니어링 노트보안과 감사

커넥터 승인 콜백은 승인을 시작한 브라우저에서만 바로 마친다

OwlAI의 사용자별 커넥터(MCP 서버처럼 회사가 OwlAI에 연결한 외부 서비스)는 OAuth로 붙습니다. 사용자가 연결을 누르면 OwlAI가 state를 실은 승인 주소를 만들고, 사용자는 GitHub 같은 서비스에서 로그인해 권한을 허용합니다. 서비스는 브라우저를 인가 코드(authorization code)와 state를 실은 콜백으로 OwlAI에 돌려보냅니다. 이 콜백은 다른 사이트에서 넘어오는 최상위 이동이라 OwlAI 로그인 정보를 싣지 않습니다. 그래서 서버는 승인을 시작할 때 state와 함께 적어 둔 사람 앞으로 연결을 저장합니다.

여기서 공격 하나가 생깁니다. 동료가 자기 OwlAI 계정으로 GitHub 연결을 시작하고, 승인 주소를 열지 않은 채 복사해 피해자에게 보냅니다. 피해자가 GitHub에 로그인해 있고 이 앱을 이미 승인한 적이 있으면, GitHub는 승인 화면 없이 곧바로 콜백으로 돌려보냅니다. 피해자는 링크 하나를 열었을 뿐인데 피해자의 GitHub 토큰이 동료의 커넥터 연결로 저장되고, 그 뒤로 동료가 채팅에서 부르는 GitHub 도구는 피해자의 권한으로 돕니다. 피해자만 볼 수 있는 비공개 저장소를 읽고, 커넥터에 쓰기 도구가 있으면 피해자 이름으로 이슈에 댓글을 답니다. state는 진짜이고 한 번만 쓰였으며, PKCE 교환도 정상입니다.

이 글은 콜백을 승인을 시작한 브라우저에 묶는 방법과, 다른 브라우저로 돌아온 콜백을 거절하는 대신 확인 페이지에서 멈추는 이유를 다룹니다. 확인 페이지는 연결될 OwlAI 계정을 보여 주고, 그 페이지에만 실린 1회용 토큰이 있어야 연결을 마칩니다. 결속 쿠키의 기본 원리는 Supabase 가져오기(「Supabase 연결 주소는 옮겨 적는 대신 OAuth 승인으로 채운다」)와 같아서, 여기서는 커넥터에서 달라지는 점을 씁니다.

콜백은 연결을 state에 적힌 사람 앞으로 저장한다

OAuth 문서에서 흔히 보는 CSRF는 방향이 반대입니다. RFC 6749 10.12절과 RFC 9700 4.7절이 다루는 공격에서는 공격자가 자기 인가 코드를 피해자 브라우저의 콜백에 끼워 넣고, 피해자의 클라이언트 세션이 공격자의 외부 계정에 붙습니다. 그러면 피해자가 저장한 파일이 공격자의 저장소로 갑니다. OwlAI 커넥터에서는 이 방향이 성립하지 않습니다. 콜백은 브라우저에 누가 로그인해 있는지 보지 않고, 승인을 시작할 때 state와 함께 적어 둔 사람 앞으로 연결을 저장합니다. 공격자가 자기 콜백 링크를 피해자에게 열게 해도, 공격자의 외부 계정이 공격자의 OwlAI 계정에 붙을 뿐입니다.

걸리는 쪽은 첫 문단의 방향입니다. 공격자가 승인을 시작하고, 승인은 피해자가 하고, 연결은 공격자 앞으로 남습니다. OAuth 1.0 시절 세션 고정(session fixation) 공격(2009)과 같은 모양입니다. 피해자는 OwlAI 사용자가 아니어도 됩니다. 그 서비스에 로그인해 있기만 하면 됩니다.

state와 PKCE는 이 방향을 막지 못합니다. state는 추측할 수 없는 값이지만 승인을 시작한 공격자는 그 값을 압니다. PKCE의 code verifier는 OwlAI 서버가 들고 있다가 교환할 때 직접 보내므로, 누구의 브라우저가 콜백을 열었든 교환은 성공합니다. RFC 9700도 state를 그 요청을 시작한 사용자 에이전트에 묶으라고 요구합니다. 남는 질문은 콜백을 연 브라우저가 승인을 시작한 브라우저인지입니다.

승인을 시작한 브라우저에는 1회용 쿠키가 있다

사용자가 연결을 누르면 서버는 승인 주소를 만들면서 세 가지를 남깁니다.

  • state와 PKCE code verifier를 든 행. 10분 뒤 만료되고, 콜백이 한 번 읽으면 지워집니다.
  • 그 브라우저에 심는 1회용 난수 쿠키. HttpOnly이고 SameSite=Lax라서, 서비스에서 돌아오는 최상위 이동에도 실립니다.
  • 행에는 쿠키 값의 해시만 적습니다.

콜백이 실은 쿠키가 행의 해시와 맞으면 서버는 바로 인가 코드를 교환하고 연결을 저장합니다. 사용자가 브라우저에서 연결을 누른 보통의 경우가 여기에 듭니다. 결과 페이지를 돌려줄 때는 쿠키를 지웁니다.

콜백 주소도 고정합니다. 승인 주소에 싣는 리다이렉트 URI는 언제나 이 설치의 공개 주소로 만든 콜백이고, 요청 본문으로 바꿀 수 없습니다. 바꿀 수 있다면 인가 코드가 요청한 쪽이 고른 주소로 갑니다. 서비스의 OAuth 앱에 등록한 주소와 같아야 하는 값이기도 합니다.

쿠키가 없는 콜백은 연결될 계정을 보여 주고 멈춘다

Supabase 가져오기(「Supabase 연결 주소는 옮겨 적는 대신 OAuth 승인으로 채운다」)는 쿠키가 없는 콜백을 오류로 끝냅니다. 커넥터는 그렇게 할 수 없습니다. 데스크톱 앱과 모바일 앱에서는 연결을 앱에서 누르지만 로그인 페이지는 시스템 브라우저로 열리고, 콜백도 시스템 브라우저로 돌아옵니다. 시스템 브라우저에는 그 쿠키가 없으므로, 본인이 정상으로 시작한 연결도 쿠키 없이 도착합니다. 이를 거절하면 앱 사용자는 OAuth 커넥터를 하나도 연결하지 못합니다.

그래서 쿠키가 없거나 다른 콜백은 인가 코드를 교환하지 않고 확인 페이지를 냅니다. 이 단계에서는 아무것도 저장하지 않습니다. 페이지에는 이렇게 적힙니다.

이 연결을 마치시겠습니까? GitHub 계정을 OwlAI 계정 a***@example.com 에 연결합니다. 직접 시작한 연결이 아니거나 위 OwlAI 계정이 본인의 것이 아니면 이 창을 닫으세요.

이메일은 앞 한 글자와 도메인만 보입니다. 이 페이지는 로그인 없이 열리므로, 링크를 연 누구에게나 연결을 시작한 사람의 주소 전체를 보여 줄 수는 없습니다. 앱에서 연결한 본인은 자기 계정을 확인하고 연결을 누르면 됩니다. 남이 건넨 링크를 연 피해자는 자기 것이 아닌 계정과, 직접 시작하지 않았으면 닫으라는 문장을 봅니다.

콜백 결속: 시작한 브라우저는 바로 연결, 쿠키 없는 콜백은 확인 페이지를 거친다
그림 1. 콜백 결속: 시작한 브라우저는 바로 연결, 쿠키 없는 콜백은 확인 페이지를 거친다

「연결」은 그 페이지에만 실린 1회용 토큰을 보낸다

확인 페이지의 연결은 같은 콜백 주소로 인가 코드, state, 확인 토큰 세 칸을 POST로 보냅니다. 확인 토큰은 서버가 페이지를 낼 때 새로 만든 1회용 난수이고, 서버의 state 행에는 그 해시만 적힙니다. 페이지를 다시 열면 새 토큰으로 바뀌고 앞의 토큰은 통하지 않습니다.

이 토큰은 폼에 숨긴 CSRF 토큰과 같은 일을 합니다. 공격자는 state와 콜백 주소를 알고 있으므로, 자기 사이트에서 같은 POST를 자동으로 보내게 만들 수 있습니다. 하지만 확인 토큰은 피해자 브라우저에 뜬 OwlAI 페이지 안에만 있고, 다른 출처의 사이트는 동일 출처 정책 때문에 그 페이지를 읽지 못합니다. 토큰이 없거나 맞지 않는 POST는 연결을 마치지 않습니다. 확인도 state가 살아 있는 10분 안에만 할 수 있습니다.

확인 페이지와 콜백 결과 페이지에는 응답 헤더 세 개를 붙입니다.

헤더 막는 것
X-Frame-Options: DENY 다른 사이트가 이 페이지를 투명한 프레임으로 띄워 연결을 누르게 하는 클릭재킹
Referrer-Policy: no-referrer 주소에 실린 인가 코드가 다음 요청의 Referer로 새는 것
Cache-Control: no-store 인가 코드가 든 페이지가 브라우저 캐시에 남는 것

연결이 저장되면 감사 기록에는 승인을 시작한 OwlAI 사용자가 행위자로 남습니다(「감사 기록에는 대화 대신 AI가 실행한 SQL과 도구 호출을 남긴다」). 콜백 요청 자체에는 로그인한 사람이 없기 때문에, state에 적힌 사람이 그 연결의 주인이자 행위자입니다.

설계에서 고른 것

쿠키가 없는 콜백을 거절하는 대신 확인 페이지를 골랐습니다. 거절은 판정이 단순합니다. 그 대신 시스템 브라우저로 로그인하는 데스크톱·모바일 앱에서는 OAuth 커넥터를 연결할 수 없습니다. 대가로 앱 사용자는 연결할 때마다 확인 페이지에서 한 번 더 누릅니다.

결속을 state 대신 별도 쿠키에 두었습니다. 흔한 웹 프레임워크는 state를 로그인 세션에 넣어 두고 콜백에서 같은 세션인지 봅니다. OwlAI 콜백 요청에는 로그인 정보가 실리지 않으므로, 연결 한 번에만 쓰는 쿠키를 따로 심습니다. 쿠키는 승인 한 번에 값 하나라서, 같은 브라우저에서 두 연결을 연달아 시작하면 먼저 시작한 쪽은 확인 페이지를 거쳐 끝납니다.

쿠키 값과 확인 토큰은 해시만 저장합니다. DB 행을 읽을 수 있는 사람도 그 값으로 콜백을 마칠 수 없습니다.

로그인 없이 돌아오는 콜백이 연결을 state에 적힌 사람 앞으로 저장하는 구조라면 같은 조건이 걸립니다. 공격자가 시작하고 피해자가 승인하는 방향을 먼저 막아야 하고, 그 방법은 콜백을 연 브라우저가 승인을 시작한 브라우저인지 확인하는 것입니다. 앱처럼 브라우저가 바뀌는 정상 경로가 있으면, 거절 대신 연결될 계정을 보여 주는 확인 단계를 두고 그 단계에도 1회용 토큰을 겁니다.