빌드노트
Set-Cookie 두 줄을 쉼표로 합치면 쿠키 하나가 빠질 수 있다
프록시가 Set-Cookie 두 필드를 쉼표 문자열로 합치면 쿠키가 빠질 수 있다. 원시 응답, Node getSetCookie, 새 Chrome 프로필의 저장·전송 결과를 비교하고 Expires의 쉼표를 자르지 않고 필드별로 전달하는 방법을 설명한다.
서버가 쿠키 두 개를 설정했는데 프록시를 거친 응답에서는 하나만 저장된다면, 헤더를 한 문자열로 합치는 코드를 확인한다. Set-Cookie는 같은 이름의 필드를 여러 번 보내는 헤더다. 두 필드를 쉼표로 이어도 같은 뜻이 되지는 않는다. 이번 loopback 재현에서는 두 필드를 유지하면 alpha, beta가 모두 저장됐고, 한 필드로 합치면 beta가 빠졌다.
Node의 HTTP 응답 처리 함수에서 두 쿠키를 설정할 때는 배열을 그대로 넘긴다. 다음 코드는 res가 http.ServerResponse인 기존 처리 함수 안에 넣는 부분이다.
res.setHeader('Set-Cookie', [
'alpha=one; Path=/',
'beta=two; Path=/',
]);
배열에 join(', ')을 적용해 넘기는 코드를 제거한다. setHeader를 같은 이름으로 두 번 호출하는 것도 해결책은 아니다. Node에서는 뒤의 호출이 앞의 값을 교체한다. 문자열 배열로 한 번 설정하면 여러 필드를 보낼 수 있다. Node 22.23.2 response.setHeader 문서 (새 탭에서 열림)
문자열 조회만으로는 합쳐진 지점을 알 수 없다
HTTP/1.1에서 두 쿠키가 독립된 필드로 도착한 모습은 다음과 같다. 재현 서버의 원시 응답에서 Set-Cookie 두 줄만 발췌했다.
Set-Cookie: alpha=one; Path=/
Set-Cookie: beta=two; Path=/
문제 조건에서는 같은 배열을 쉼표와 공백으로 합쳐 보냈다. 필드 이름은 한 번만 나온다.
Set-Cookie: alpha=one; Path=/, beta=two; Path=/
이 둘을 Node의 response.headers.get('set-cookie')로 읽었더니 모두 alpha=one; Path=/, beta=two; Path=/가 나왔다. 로그에 쉼표 문자열이 찍혔다는 사실만으로 프록시가 헤더를 손상했다고 결론 낼 수는 없다. 원시 필드와 필드별 배열을 함께 봐야 한다.
PowerShell에서 아래 명령은 응답의 실제 헤더 줄과 Node가 읽은 값을 차례로 비교한다. 준비물은 curl.exe와 내장 fetch·getSetCookie()를 지원하는 Node다. 여기서는 Node 22.23.2로 실행했다. 3000/cookie-check는 예시이므로 자기 로컬 서버에서 진단용 쿠키를 설정하는 실제 GET 주소로 바꾼다. 서버에 직접 접근하는 주소와 프록시를 통과하는 주소에서 각각 확인한다.
$cookieUrl = 'http://127.0.0.1:3000/cookie-check'
curl.exe --http1.1 --silent --show-error --max-time 5 -D - -o NUL "$cookieUrl"
@'
const r = await fetch(process.argv[2]);
console.log(r.headers.get('set-cookie'));
console.log(r.headers.getSetCookie());
'@ | node --input-type=module - "$cookieUrl"
두 필드를 유지한 응답에서는 마지막 출력이 [ 'alpha=one; Path=/', 'beta=two; Path=/' ]였다. 합친 응답은 [ 'alpha=one; Path=/, beta=two; Path=/' ]였다. 두 번째는 쿠키 두 개를 복원한 배열이 아니라, 이미 합쳐져 도착한 값 하나를 담은 배열이다. 이 명령은 GET을 두 번 보내므로 같은 진단용 응답이 나오는 주소에서 비교한다. 요청이 실패하거나 쿠키를 설정하지 않는 응답이 오면 그 출력으로 필드 보존 여부를 판단하지 않는다.
서버 쪽에서는 두 필드인데 프록시 통과 뒤 한 필드가 됐다면, 두 지점 사이의 헤더 수집·전달 코드를 좁혀 볼 수 있다. 처음부터 한 필드라면 쿠키를 만드는 쪽부터 확인한다. 필드가 둘 다 보존됐다면 이번 합치기 문제만으로 저장 실패를 설명할 수 없다.
새 프로필에서는 저장 결과도 달랐다
Windows 11, Node 22.23.2, Chrome 156.0.8078.47에서 별도로 재현했다. 서버는 127.0.0.1에만 열었고, 여섯 조건마다 빈 Chrome 프로필을 새로 만들어 같은 이름의 가짜 쿠키 두 개만 보냈다. 브라우저의 저장 목록과 뒤이은 동일 origin /echo 요청에서 서버가 받은 Cookie를 대조했다.
alpha=one; Path=/,beta=two; Path=/를 두 필드로 보내면 둘 다 저장됐다. 서버 수신 값은alpha=one; beta=two였다. 쉼표로 합쳐 한 필드로 보내면alpha만 저장됐고 수신 값도alpha=one이었다.- 두 값에 각각
Expires=Wed, 21 Oct 2037 07:28:00 GMT를 붙여도 결과는 같았다. 두 필드는 두 쿠키, 합친 한 필드는alpha하나였다. - 로컬 전달 코드가 상류의 Expires 포함 두 필드를 배열로 넘기면 두 쿠키가 유지됐다. 같은 배열을
join(', ')으로 바꾸면 다시beta가 빠졌다.
이때 브라우저 페이지의 fetch 결과에서 headers.get('set-cookie')는 모든 조건에서 null, headers.getSetCookie()는 모두 []였다. 정상 조건에서도 쿠키는 저장되고 서버에 전송됐다. 프런트엔드 JavaScript에 노출되는 네트워크 응답에서는 Set-Cookie가 필터링되기 때문이다. 브라우저에서 빈 배열이 나왔다는 것과 쿠키가 저장되지 않았다는 것은 다른 관찰이다. MDN getSetCookie 설명 (새 탭에서 열림), Fetch의 응답 헤더 필터 규칙 (새 탭에서 열림)
Expires의 쉼표를 잘라서 복원하지 않는다
쉼표 목록을 허용하는 헤더의 처리 규칙을 Set-Cookie에도 일괄 적용하면 안 된다. RFC 9110은 Set-Cookie를 별도로 처리해야 하는 예외로 설명한다. RFC 6265도 여러 Set-Cookie 필드의 결합이 의미를 바꿀 수 있다고 명시한다. RFC 9110 §5.3 (새 탭에서 열림), RFC 6265 §3 (새 탭에서 열림)
특히 Expires=Wed, 21 Oct 2037 07:28:00 GMT의 쉼표는 날짜의 일부다. 재현에서 Expires를 넣은 정상 응답을 문자열로 조회한 뒤 split(',')을 실행하자 조각이 네 개 나왔다. 첫 두 조각은 alpha=one; Expires=Wed와 21 Oct 2037 07:28:00 GMT; Path=/였다. 쿠키 경계를 복원하기 전에 날짜부터 잘린 것이다.
Node에서 상류 응답을 전달한다면, 기존 비동기 HTTP 처리 함수의 Set-Cookie 복사 부분을 다음처럼 둔다. upstream은 서버의 fetch로 받은 응답이고 res는 아직 헤더를 전송하지 않은 http.ServerResponse다.
const cookies = upstream.headers.getSetCookie();
if (cookies.length) res.setHeader('Set-Cookie', cookies);
이 코드는 상류의 필드 경계를 유지하는 부분이며 전체 프록시 구현은 아니다. 앞선 일반 헤더 복사 코드가 Set-Cookie를 먼저 문자열로 처리하지 않도록 제외하고, 뒤에서 다시 덮어쓰지 않는지도 확인한다. 상류가 이미 한 필드로 합쳐 보냈다면 getSetCookie()도 그 값 하나를 반환한다. 수신 쪽에서 무작정 쉼표를 쪼개기보다, 필드가 합쳐진 지점을 고쳐야 한다.
이번 관찰은 loopback HTTP/1.1과 명시한 Chrome 버전의 결과다. 다른 브라우저나 HTTP/2·HTTP/3, 실제 프록시 제품은 실행하지 않았다. 잘못 합친 값에서 항상 첫 쿠키만 남는다고 보장하지 않으며, Expires의 실제 보관 기간도 검증하지 않았다. 수정 뒤에는 프록시 앞뒤의 필드 수와 새 브라우저의 저장 결과를 다시 비교한다.