관리 화면을 8080에 두고 서비스를 80에 두었는데 관리 화면에 로그인하면 서비스 쪽 로그인이 풀렸다.
Table of contents
Open Table of contents
증상 — 한쪽 로그인이 다른 쪽을 푸는 현상
포트가 다르니 서로 영향이 없을 것으로 보고 두 화면이 각자 PHPSESSID 를 관리한다고 생각했다.
격리하려고 포트를 나눈 것이므로 그 전제가 맞는지는 확인하지 않았고 먼저 볼 것은 쿠키가 포트별로 나뉘는가였다.
브라우저가 8080과 80을 다른 자리로 볼 것이라고 막연히 생각했다. 그 생각의 근거를 대 보라면 댈 것이 없었다.
원인 — 포트를 안 가리는 쿠키
쿠키는 포트로 나뉘지 않고 같은 호스트면 그대로 공유된다.
Set-Cookie: PHPSESSID=abc...; path=/; domain=admin.example.com
같은 호스트면 8080에서 심은 Set-Cookie 가 80으로도 간다.
두 쪽이 같은 이름을 쓰고 있었다.
session.name = PHPSESSID
관리 쪽이 새 세션을 심으면 session.name 이 같으니 서비스 쪽 식별자를 덮어쓴다.
포트로 나눴으니 격리됐다고 여긴 것이 전제부터 틀렸고 격리하려던 두 화면이 실제로는 한 자리를 공유하고 있었다.
로그인이 풀리는 것은 그 공유가 밖으로 드러난 증상일 뿐이었다. 드러나지 않는 공유가 더 있는지도 함께 봐야 했다.
세션 이름을 나눴다
먼저 이름을 갈랐다.
; 서비스
session.name = SVCSESS
; 관리
session.name = ADMSESS
SVCSESS 와 ADMSESS 가 브라우저에 나란히 살면서 서로 안 덮어쓴다.
session.name 을 갈라 덮어쓰는 증상만 사라졌고 공유 자체가 없어진 것은 아니어서 무엇이 남아 있는지를 더 봐야 했다.
증상이 사라지면 거기서 멈추기 쉽다. 이번에는 원인을 이미 알고 있었으므로 같은 원인의 다른 경로를 찾을 수 있었다.
제약 — 함께 쓰던 저장 자리
세션 저장소를 봤다.
session.save_path = "tcp://session-01:6379"
session.save_path 가 같아서 관리와 서비스가 한 저장소를 보고 있었다.
session.name 을 나눠도 식별자만 알면 다른 쪽 세션을 읽을 수 있다. 관리 세션과 서비스 세션이 같은 자리에 있는 것 자체가 위험했다.
; 서비스
session.save_path = "tcp://session-01:6379?database=1"
; 관리
session.save_path = "tcp://session-01:6379?database=2"
session.name 과 save_path 를 둘 다 나눠야 두 세션이 실제로 갈렸다.
쿠키 이름은 브라우저 쪽의 구분이고 저장 자리는 서버 쪽의 구분이다. 양쪽이 다 나뉘어야 격리라고 부를 수 있었다.
설정 — 좁혀 놓은 쿠키 속성
관리 쿠키가 서비스 쪽으로 나가는 것도 막았다.
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_path = /
session.cookie_domain = admin.example.com
cookie_domain 을 상위 도메인으로 두면 www 를 포함한 아래 전부로 나간다.
관리 쪽은 admin.example.com 으로 좁혔고 cookie_secure 로 평문에서는 안 나가게 했는데 관리 화면을 평문으로 열 일이 없으니 잃을 것이 없었다.
범위를 좁히는 것이 이름을 나누는 것보다 근본적인 조치였는데 이름은 같은 자리에 둘을 나란히 두는 것이고 범위는 애초에 안 나가게 하는 것이다.
이름만 나누면 관리 쿠키가 서비스 쪽으로 계속 나간다. 나가지 않게 하는 것과 나가서 안 겹치게 하는 것은 다른 수준의 조치였다.
조치 — 포트가 아니라 호스트로 나눴다
포트로 나누면 쿠키 규칙과 출처 규칙이 서로 어긋난다.
쿠키 포트를 안 가린다. 같은 호스트면 공유된다
동일 출처 정책 포트를 가린다. 8080에서 80으로 요청하면 교차 출처다
쿠키는 안 나뉘는데 Origin 은 나뉘니 같은 구성을 두고 계속 헷갈리게 된다.
서비스 www.example.com :443
관리 admin.example.com :443
www 와 admin 으로 나누니 쿠키도 나뉘고 Origin 판정도 함께 나뉜다.
쿠키와 Origin 이 같은 기준을 쓰게 만드는 것이 헷갈림을 없애는 방법이었다.
포트로 나누는 구성은 설정 한 줄로 끝나서 손이 덜 간다. 호스트로 나누려면 인증서와 DNS가 함께 따라오지만 그 뒤로는 헷갈릴 일이 없었다.
대신 관리 화면이 서비스 API를 부르는 자리가 교차 출처가 됐다.
$allowed = ['https://admin.example.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed, true)) {
header("Access-Control-Allow-Origin: {$origin}");
header('Access-Control-Allow-Credentials: true');
header('Vary: Origin');
}
Access-Control-Allow-Origin 을 * 로 두면 자격 증명과 같이 못 쓰므로 목록 방식이 필수였다.
Vary: Origin 을 빠뜨리면 캐시가 한 출처의 응답을 다른 출처에 그대로 준다. 설정을 넣은 뒤에는 실제 응답 헤더를 받아 확인하고 두 쪽에 번갈아 로그인해 봤다.
$ curl -sI https://admin.example.com/login | grep -i set-cookie
Set-Cookie: ADMSESS=...; path=/; domain=admin.example.com; secure; HttpOnly
$ curl -sI https://www.example.com/ | grep -i set-cookie
Set-Cookie: SVCSESS=...; path=/; secure; HttpOnly
설정이 맞는 것과 응답이 맞는 것은 확인하기 전까지 다른 문제였다.
정리
- 쿠키는 포트를 안 가리고 같은 호스트면 공유된다
- 포트로 나눴다고 격리됐다고 보면 전제부터 틀린다
- 이름이 같으면 나중 쿠키가 앞의 것을 덮어쓴다
- 용도가 다르면 세션 이름을 나눈다
- 이름을 나눠도 저장 자리가 같으면 서로 읽는다
- 이름과 저장 자리를 둘 다 나눈다
- 도메인을 상위로 두면 하위 전부로 나간다
- 범위를 좁히는 것이 이름을 나누는 것보다 근본적이다
- 쿠키는 포트를 안 가리는데 동일 출처 정책은 가린다
- 두 규칙이 같은 기준을 쓰도록 호스트로 나눈다
- 설정이 아니라 실제 응답 헤더를 보고 확인한다