admin_init 은 관리자를 뜻하지 않았습니다 — 워드프레스 훅 이름을 권한으로 읽은 대가
지난 글에서는 워드프레스가 제 코드를 부르지 않는 순간에 대해 썼습니다. 플러그인은 스스로 실행되는 프로그램이 아니라 정해진 순간에 불려가는 콜백 묶음이고, 그 순간이 오지 않으면 코드는 조용히 아무 일도 하지 않습니다. 이번 글은 그 반대편 이야기입니다.
admin_init 에 걸어둔 제 콜백은 불리지 않아서가 아니라, 제가 생각한 것보다 훨씬 많이 불려서 문제가 되었습니다. 그리고 그때 조용히 실행될 수 있었던 함수 안에는 DROP TABLE 이 있었습니다.
먼저 적어둡니다. 이 결함은 배포 전 자체 리뷰에서 발견해 고쳤고, 운영에서 악용된 적은 없습니다. 그래서 이 글은 사고 수습기가 아니라, 왜 그 배선을 그토록 오래 의심하지 않았는지에 대한 기록에 가깝습니다.
처음에는 안전했고, 그 안전은 코드에 없었습니다
이 플러그인은 자기 테이블을 직접 관리하고 있었습니다. 그래서 테이블을 최신 스키마로 맞추는 함수가 필요했고, 저는 그 함수를 admin_init 에 걸었습니다.
add_action('admin_init', array('Catalog_Schema', 'maybe_upgrade'));
이유는 있었습니다. 멀티사이트에서 네트워크 활성화를 하면 활성화 훅이 사이트마다 돌지 않습니다. 그래서 "관리자가 그 사이트의 관리 화면에 들어오면 그때 테이블을 보장한다"는 지연 방식을 택했습니다. 활성화 시점에 한 번에 처리하려던 일을 첫 접근 시점으로 미룬 셈입니다.
그리고 이 배선은 오랫동안 아무 문제도 일으키지 않았습니다. 초기 버전의 maybe_upgrade() 가 하는 일이 CREATE TABLE IF NOT EXISTS 뿐이었기 때문입니다. 누가 몇 번을 부르든 결과가 같았고, 잘못 불려도 잃을 것이 없었습니다.
돌이켜 생각해보면 이 지점이 전부였습니다. 그 코드가 안전했던 이유는 권한 검사가 있어서가 아니라, 함수가 무해했기 때문입니다. 다만 그때는 둘을 구분하지 않았습니다. 문제가 생기지 않으니 배선이 옳다고 여겼습니다.
DROP TABLE 한 줄이 함수의 성질을 바꿨습니다
스키마를 3판으로 올리면서 구버전 정의로 만들어진 테이블을 걷어내야 했습니다. CREATE TABLE IF NOT EXISTS 로는 절대 반영되지 않는 종류의 변경이었기 때문입니다. 그래서 maybe_upgrade() 안에 이 줄이 들어갔습니다.
$wpdb->query("DROP TABLE IF EXISTS `{$table}`");
코드 한 줄이 늘었을 뿐인데 그 함수의 성질이 바뀌었습니다. 멱등하고 무해했던 함수가 파괴적인 함수가 되었습니다.
그런데 그 함수를 부르는 배선은 그대로였습니다. add_action('admin_init', ...) 은 손대지 않았고, 손댈 이유도 없어 보였습니다. 안전했던 이유가 사라졌는데 코드는 그 사실을 어디에서도 알려주지 않습니다. 변경 이력에 남는 것은 추가된 줄이고, 정작 바뀐 것은 줄이 아니라 함수의 성질이기 때문입니다.
admin_init 은 인증보다 먼저 발화합니다
admin_init 이라는 이름은 관리 화면의 초기화를 뜻합니다. 관리 화면은 로그인해야 들어갑니다. 그러니 이 훅에 걸린 코드는 로그인한 사용자만 실행시킵니다. 이것이 제 머릿속에 있던 삼단 논법이었고, 저는 그것을 한 번도 확인해본 적이 없었습니다.
스키마 코드를 다시 읽다가 문득 이 훅이 정말 관리자만 타는지 궁금해졌습니다. 워드프레스 5.8.15 코어를 열어보니 순서가 반대였습니다.
wp-admin/admin-post.php
17 require_once ABSPATH . 'wp-load.php';
25 require_once ABSPATH . 'wp-admin/includes/admin.php';
30 do_action( 'admin_init' ); ← 제 콜백이 여기서 돕니다
34 if ( ! is_user_logged_in() ) { ← 인증은 그 다음입니다
41 do_action( 'admin_post_nopriv' );
AJAX 엔드포인트도 마찬가지였습니다.
wp-admin/admin-ajax.php
45 do_action( 'admin_init' );
173 if ( is_user_logged_in() ) {
두 파일을 나란히 놓으면 이렇습니다.
| 엔드포인트 | admin_init 발화 | 인증 검사 |
|---|---|---|
wp-admin/admin-post.php |
30번 줄 | 34번 줄 |
wp-admin/admin-ajax.php |
45번 줄 | 173번 줄 |
제가 확인한 버전은 5.8.15 이고, 줄 번호는 버전에 따라 달라집니다. 다만 확인해야 할 것은 줄 번호가 아니라 두 줄의 순서이니, 쓰고 계신 버전에서 직접 열어보시길 권합니다.
정리하면 이렇습니다. 로그인하지 않은 상태로 /wp-admin/admin-post.php 를 한 번 요청하는 것만으로 admin_init 이 발화합니다. 그 훅에 걸린 제 콜백은 DROP TABLE 에 도달할 수 있었습니다.
여기서 코어를 탓하고 싶어지지만, 이 순서에는 정당한 이유가 있습니다. 41번 줄의 admin_post_nopriv 가 그 근거입니다. 비로그인 전용 액션이 애초에 존재합니다. 이 엔드포인트는 설계상 로그인하지 않은 요청을 받는 곳이고, admin_init 은 로그인 여부와 무관하게 돌아야 하는 공통 준비 단계입니다. 코어는 자기가 하는 일을 정확히 하고 있었습니다.
잘못은 이름을 문맥 보장으로 읽은 쪽에 있었습니다. admin_ 이라는 접두사는 이 코드가 어디서 불리는지를 알려줄 뿐, 누가 불렀는지는 알려주지 않습니다. 저는 전자를 후자로 읽었습니다.
요청의 모양으로 권한을 판정하려 했습니다
처음 든 생각은 "그러면 관리자 화면 요청만 걸러내면 되지 않나"였습니다. 그래서 wp_doing_ajax() 로 AJAX 요청을 제외해봤습니다.
안 됩니다. admin-ajax.php 는 막히지만 admin-post.php 는 애초에 AJAX 가 아닙니다. 그 경로는 그대로 열려 있습니다.
조건을 더 붙일 궁리를 한동안 하다가, 방향 자체가 틀렸다는 생각이 들었습니다. 요청의 모양으로 권한을 판정하려는 시도는 결국 열거의 문제가 됩니다. 경로를 하나 막으면 다음 경로가 남고, 제가 모르는 경로는 셀 수조차 없습니다.
보안 설계에는 이 상황을 정확히 가리키는 오래된 원칙이 있습니다. Saltzer 와 Schroeder 가 1975년에 정리한 여덟 가지 원칙 중 하나인 완전한 중재(complete mediation) 입니다. 요지는 단순합니다. 보호된 자원에 대한 모든 접근은 그 접근 지점에서 매번 권한을 확인해야 한다는 것입니다.
이 원칙이 금지하는 것이 정확히 제가 하려던 일이었습니다. 저는 검사를 접근 지점이 아니라 경로에 얹으려 했습니다. "이 훅을 거쳐 왔으니 검사한 것으로 친다"는 식입니다. 경로는 조건이 바뀔 때마다 늘어나지만, 접근 지점은 하나뿐입니다.
게이트는 current_user_can 하나뿐입니다
결국 수정은 단순했습니다.
function catalog_admin_init_maybe_upgrade() {
if (!is_user_logged_in() || !current_user_can('manage_options')) {
return;
}
Catalog_Schema::maybe_upgrade();
}
add_action('admin_init', 'catalog_admin_init_maybe_upgrade');
두 가지를 덧붙여 둡니다.
current_user_can() 은 로그인하지 않은 사용자에게도 정상 동작합니다. 전부 false 를 돌려주니 앞의 is_user_logged_in() 은 엄밀히 말하면 중복입니다.
그럼에도 남겨 뒀습니다. 이 함수가 왜 여기 있는지를 다음에 읽는 사람에게 알려주는 값이 있다고 생각했기 때문입니다. 중복을 제거하면 조건은 짧아지지만, 이 줄이 막고 있는 것이 무엇인지도 함께 흐려집니다.
그리고 워드프레스의 역할(role)이 아니라 능력(capability)으로 검사했습니다. manage_options 는 "관리자"가 아니라 "설정을 바꿀 수 있는 사람"이라는 뜻입니다. 사이트마다 역할 구성이 다르고 플러그인이 역할을 새로 만들기도 하지만, 능력이 가리키는 의미는 그대로 유지됩니다.
워드프레스 플러그인의 안전이 설정값에 얹혀 있을 때
같은 사이트에서 쓰던 게시판 플러그인에서 같은 종류의 구조를 봤습니다. 쓰기 권한을 판정하는 부분이 대략 이런 모양이었습니다. 실제 코드가 아니라 구조만 옮긴 것입니다.
쓰기 권한 판정:
if 게시판 설정의 쓰기권한 == '전체 허용':
return 허용 ← 로그인 검사보다 먼저 반환됩니다
if 로그인하지 않았다면:
return 거부
허용 분기가 로그인 검사보다 앞에 있습니다. 게시판 설정을 "전체 허용"으로 두면 비로그인 방문자에게도 쓰기 권한이 열립니다.
이 사이트에서 실제 노출은 없었습니다. 모든 게시판이 "작성자 한정"으로 설정돼 있었기 때문입니다. 다만 그 문장을 다시 읽어보면 뜻이 분명해집니다. 안전을 지탱하고 있던 것은 코드가 아니라 설정값이었습니다. 운영자가 관리 화면에서 드롭다운 하나를 바꾸면 그 성질이 바뀝니다.
이런 상태를 부르는 이름도 있습니다. 주변 권한(ambient authority) 입니다. 권한이 요청한 주체에 명시적으로 붙어 있지 않고, 실행 환경이나 주변 설정이 대신 그 자리를 채우고 있는 상태를 말합니다.
여기에 허용이 거부보다 먼저 반환되는 순서까지 겹쳐 있습니다. 안전한 기본값(fail-safe defaults) 원칙이 요구하는 것과 정확히 반대입니다. 기본은 거부여야 하고, 허용은 그 뒤에 명시적으로 얹혀야 합니다.
이 코드를 보고 남을 흉볼 수 없었던 이유는 분명합니다. 제 결함이 정확히 같은 형태였기 때문입니다. 제 쪽은 설정값 대신 훅 이름이 그 자리를 채우고 있었을 뿐입니다.
add_option() 이 원자적이라고 믿었습니다
같은 작업에서 결함이 하나 더 나왔습니다. 스키마 갱신이 DROP → CREATE → ALTER 순서라, 동시 요청 두 개가 겹치면 한쪽이 지운 테이블을 다른 쪽이 고치는 상황이 생깁니다. 그래서 락을 걸었습니다.
처음에는 add_option() 이 원자적일 것이라고 생각했습니다. 이름이 "추가"이고, 이미 있으면 실패하는 함수처럼 보였기 때문입니다. 아니었습니다.
워드프레스 5.8 의 wp-includes/option.php 에서 add_option() 이 실제로 실행하는 것은 INSERT ... ON DUPLICATE KEY UPDATE 입니다. 이 쿼리는 중복을 거부하지 않고 갱신합니다.
"이미 있으면 추가하지 않는다"를 판정하는 것은 그 앞에 있는 PHP 쪽 get_option() 사전 검사이고, 검사와 쓰기 사이에는 틈이 있습니다. 두 요청이 사전 검사를 나란히 통과하면 둘 다 성공하고, 뒤엣것이 앞엣것의 락을 덮어씁니다.
이런 형태의 결함에도 이름이 있습니다. TOCTOU(time-of-check to time-of-use) 입니다. 확인한 시점과 사용하는 시점이 다르면 그 사이에 세상이 바뀔 수 있다는 뜻입니다. 락은 바로 그 틈을 없애라고 있는 장치인데, 제가 만든 락은 그 틈 위에 서 있었습니다.
가장 빠른 확인은 코어 자신이었습니다. WP_Upgrader::create_lock() 은 정확히 같은 목적에 add_option() 을 쓰지 않고 INSERT IGNORE 원시 쿼리를 씁니다. 코어는 같은 문제를 이미 알고 다르게 풀어두었습니다.
여기서도 결국 이름이 문제였다는 생각이 들었습니다. 어떤 함수가 원자적인지는 이름으로 알 수 없습니다. 그리고 그 답을 가장 빨리 얻는 방법은 같은 일을 하는 코어 코드를 열어보는 것이었습니다.
남은 것 — 훅 이름은 권한의 답이 아닙니다
두 결함 모두 배포 전에 스스로 발견해 고쳤습니다. 운영에서 악용된 적은 없습니다.
안도할 만한 이야기이긴 한데, 정작 오래 남은 것은 발견 경로가 우연이었다는 사실입니다. 저를 여기까지 데려온 것은 점검 절차나 도구가 아니라, 스키마 코드를 다시 읽다가 문득 든 "이 훅 정말 관리자만 타나?"라는 질문 하나였습니다. 그날 그 질문을 하지 않았다면 그 배선은 그대로 나갔을 것입니다.
곰곰이 생각해보니 진짜 문제는 결함 자체가 아니었습니다. 무해한 함수에 파괴적인 한 줄을 넣으면서 그 함수의 호출 경로를 다시 보지 않았다는 것이 문제였습니다. 변경을 검토할 때 우리는 추가된 줄을 봅니다. 그런데 이 경우 바뀐 것은 줄이 아니라 성질이었고, 성질이 바뀌면 원래 안전했던 이유가 아직 유효한지 다시 물어야 합니다.
그래서 지금은 파괴적인 동작을 코드에 넣을 때 이렇게 자문합니다.
이 함수를 부를 수 있는 사람의 범위가, 이 함수가 지금 하는 일과 맞는가?
훅 이름은 그 질문의 답이 아닙니다. 답은 그 함수의 첫 줄에 직접 적혀 있어야 합니다.
다음 편에서는 이 글에 계속 등장한 그 테이블 이야기를 하려고 합니다. 워드프레스에는 마이그레이션 도구가 없어서, 플러그인이 자기 테이블을 가지면 스키마 버전 관리를 직접 발명해야 합니다. DROP TABLE 이 애초에 왜 필요했는지도 그때 이어 쓰겠습니다.
관련 글
워드프레스 훅이 불리지 않는 순간 — add_action 을 걸었는데 아무 일도 없었습니다
워드프레스 훅은 add_action 으로 걸어두면 알아서 불린다고 생각했습니다. 그런데 표준 훅 네 개가 하나도 발화하지 않았습니다. 훅 기반 통합의 전제는 상대도 워드프레스를 통과한다는 것이고, 그 전제가 깨지면 오류가 아니라 무음 실패로 나타났습니다.
워드프레스 플러그인 개발, 왜 functions.php 가 아니었나 — 남의 코드 위에 기능을 얹는 경계
워드프레스에 기능을 넣을 자리는 네 군데였고, 선택 기준은 기술 취향이 아니라 남의 코드가 업데이트될 때 내 코드가 살아남는가였습니다. 플러그인으로 PHP 는 분리했지만 CSS 는 격리되지 않았고, 경계는 제가 그은 곳이 아니라 언어마다 다르게 그어져 있었습니다.
메뉴를 감췄으니 권한이라고 믿었습니다 — 검사는 처음부터 없었습니다
화면에서 메뉴를 감춰두고 그것을 권한이라고 불렀습니다. 백엔드를 열어보니 메서드 데코레이터를 안 붙인 엔드포인트가 자동으로 admin 전체에게 열리는 구조였고, 누락은 조용히 통과하고 있었습니다.