나의 안전 가드(safety guard)가 2개의 도구는 보호했지만 나머지 20개는 방치했던 이유
요약
코딩 에이전트가 브라우저를 제어할 때 발생할 수 있는 보안 허점과 안전 가드(safety guard) 설계의 오류를 다룹니다. 특정 래퍼 함수에만 보안 로직을 적용할 경우, 다른 도구들을 통한 우회 공격에 취약해질 수 있음을 경고합니다.
핵심 포인트
- 보안 가드는 특정 래퍼가 아닌 모든 실행 경로에 적용되어야 함
- 배치(Batch) 도구 사용 시 단계별 소유권 검증 누락 주의
- 단일 지점 통제가 아닌 모든 상태 변경 경로의 불변성 확보 필요
나는 코딩 에이전트가 사용자의 실제 로그인된 Safari를 제어할 수 있게 해주는 MCP 서버를 관리하고 있습니다. 이는 사용자의 은행, 이메일, 그리고 작성 중인 Slack 메시지가 들어 있는 바로 그 브라우저입니다. 이 모든 전제는 단 하나의 철칙이 지켜질 때만 유효합니다:
에이전트는 자신이 연 탭만 건드릴 수 있다. 사용자의 탭은 절대 안 된다.
나는 초기에 그 가드(guard)를 작성했습니다. 그것은 작은 함수였습니다. 페이지를 변경하는 모든 동작을 수행하기 전에, 대상 탭이 에이전트 소유인지 확인하는 것이었습니다. 나는 safari_click과 safari_fill이 모두 거쳐 가는 래퍼(wrapper)에 이 함수를 넣었고, 두 도구가 소유하지 않은 탭에 대해 동작을 거부하는 것을 확인한 뒤 책임감을 느끼며 다음 단계로 넘어갔습니다.
하지만 세 번의 별도 감사를 거친 후에야, 그 가드가 대부분의 시간 동안 그저 장식에 불과했다는 사실을 발견했습니다.
1라운드: 가드가 거의 아무것도 통과하지 않는 곳에 있었다
내가 가드를 적용했던 래퍼 — extensionOrFallback라고 부릅시다 — 는 click과 fill을 위한 경로였습니다. 페이지를 변경하는 다른 20여 개의 도구들을 위한 경로는 _아니었습니다.
safari_set_cookie. safari_delete_cookies. 모든 local-storage 및 session-storage 쓰기 도구들. safari_import_storage. safari_drag. safari_upload_file. safari_paste_image. safari_select_option. safari_mock_route. safari_throttle_network. safari_override_geolocation. safari_handle_dialog. safari_resize.
이들 각각은 엔진을 직접(directly) 호출했습니다. 그 중 어느 것도 래퍼를 거치지 않았습니다. 따라서 소유권 확인 — 즉, 이 프로젝트의 전체 안전 이야기 — 은 단 두 개의 도구에만 적용되었고, 나머지 도구들에서는 조용히 누락되어 있었습니다. 어떤 탭이 앞에 있는지 혼동한 에이전트는 사용자의 로그인된 세션에 쿠키를 쓰거나 localStorage를 쏟아부을 수 있었으며, 그 무엇도 이를 막을 수 없었습니다.
해결책은 개념적으로 매우 간단했습니다(_assertTabOwnership()으로 체크 로직을 추출하여 모든 곳에서 가장 먼저 호출하는 것). 그리고 바로 그 단순함이 핵심입니다. 편리한 래퍼(wrapper) 하나에 들어있는 가드는 가드가 아닙니다. 그것은 당신이 테스트한 두 개의 코드 경로에 우연히 놓여 있는, 가드 모양을 한 물체일 뿐입니다. 진정한 불변성(invariant)은 "페이지를 변경(mutate)하는 모든 경로가 체크를 호출한다"는 것이며, 래퍼는 오직 그 래퍼를 통과하는 경로에 대해서만 그 점을 보장할 수 있습니다.
2라운드: 배치 도구(batch tool)에 탈출구가 있었다
safari_run_script는 탐색(navigate), 클릭(click), 채우기(fill), 평가(evaluate)와 같은 일련의 단계들을 한 번의 호출로 실행하는 배치(batch) 방식입니다. 이 도구는 소유권(ownership)을 확인했습니다... 하지만 배치가 시작되기 전, 사전 점검(pre-flight) 단계에서 단 한 번만 확인했을 뿐입니다.
탭 사이를 이동할 수 있는 배치에 대해 단 한 번의 사전 점검을 수행하는 것은 체크가 아닙니다. 두 가지 허점이 있었습니다:
evaluate는 예외였습니다. 단독으로 호출하는 대신 배치를 사용하여 감싸기만 하면, 소유권이 없는 탭에서 임의의 JavaScript를 실행할 수 있었습니다.- 배치 중간의
switchTab또는navigate단계가 당신의 탭으로 이동할 수 있었고, 그 다음 단계(예를 들어click)가 그곳에 도달하게 되었습니다. 유일한 관문이 입구에만 있었을 뿐, 각 단계마다 있지 않았기 때문입니다.
이제 배치가 실행되는 동안 **단계별(per step)**로 소유권이 강제됩니다. navigate 단계는 단독 도구가 하는 것과 정확히 동일하게 목적지의 소유권을 등록하며, 거부된 단계가 발생하면 나머지 단계가 새로 탈취한 영역에서 진행되도록 두는 대신 배치 전체를 중단시킵니다.
이 교훈은 1라운드와 맥을 같이 합니다. 위험한 요소가 단계 사이의 전환이었을 때는 작업의 시작 부분에 체크를 두어야 했습니다. 가드는 입구가 아니라 상태 변화(state change) 시점에 존재해야 합니다.
3라운드: 상태(state) 자체가 거짓말을 하고 있었다
세 번째 감사(audit)는 매우 불안한 것이었습니다. 왜냐하면 이제 가드는 모든 곳에서 호출되고 있었음에도 불구하고, 가드가 참조하는 데이터가 세 가지 방식으로 부패해 있었기 때문에 여전히 틀릴 수 있었기 때문입니다.
/org가 소유한 /org-evil. 소유권이 경로 접두사 테스트와 세그먼트 경계 없이 일치했습니다. 만약 에이전트가 합법적으로 https://site.com/org를 소유했다면, 제 매처는 같은 오리진에서 https://site.com/org-evil도 소유한다고 기쁘게 결론지었습니다. 문자열 접두사는 보안 경계가 아닙니다. 저는 이를 조용히 그렇게 취급했습니다. 이제 일치시키려면 실제 / 경계가 필요하며, — 이것이 바로 조용히 부패하는 종류의 규칙이기 때문에 — 전체 매칭/TTL 의미론을 순수한 ownership-match.js 모듈로 빼내고 유닛 테스트 스위트를 추가하여 /org 대 /org-evil 사례를 영원히 고정했습니다.
TTL이 세션 간 소유권을 누설했습니다. 소유권 파일이 저장될 때마다, 각 항목의 타임스탬프를 _now_로 다시 썼습니다. 따라서 30분 만료는 장기간 유지되는 세션에서는 절대 발생할 수 없었습니다 — 소유된 URL은 무기한 축적되어, 이제 사용자의 것이 된 탭에 오래된 클레임을 전달할 위험이 있었습니다. 원래의 타임스탬프가 이제 보존되며, 만료는 로드 시점이 아니라 사용 시점에 강제됩니다.
워커 재시작이 오픈 실패를 초래했습니다. 브라우저 확장 프로그램은 MV3(Manifest V3)이므로, 서비스 워커가 플랫폼의 변덕에 따라 종료되고 다시 시작됩니다. 재시작될 때, 소유된 탭의 인메모리 맵이 지워졌고 — 코드는
- 병목 지점(chokepoint)을 보호하거나, 모든 호출 지점을 보호하세요. 편리한 래퍼(wrapper)는 절대 안 됩니다. 만약 검사를 모든 경로가 확실히 통과하는 단일 라인에 배치할 수 없다면, 모든 경로에 배치해야 합니다. 안전한 제3의 선택지는 없습니다.
- '소유한다(Owns)'는 보안 경계이며,
startsWith는 그렇지 않습니다. 문자열 비교가 권한 결정 역할을 하는 경우, 그 경계에서는 잘못되었다고 가정하고 이를 증명하는 테스트를 작성하세요. - 상태가 재설정될 때 무엇을 할지 결정하세요. 캐시는 지워지고, 워커(worker)는 종료되며, 파일은 다시 로드됩니다. 유일한 질문은 이러한 재설정이 문을 열게 하는지 닫게 하는지입니다. 의도적으로 선택하세요.
- 보안 관련 로직을 테스트가 고정할 수 있는 무언가로 추출하세요. 매칭 규칙이 인라인에 존재하며 I/O와 뒤섞여 있어 테스트가 불가능했습니다. 이것이 바로 경계 버그(boundary bug)가 생존했던 이유입니다. 순수 모듈과 단위 테스트 스위트(unit suite)는 눈에 보이지 않던 불변성(invariant)을 빨간 빌드(red build)로 만들었습니다.
이것들 중 어느 것도 기발하지 않습니다. 이것들은 흥미로운
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기