MV3에서 응답 헤더를 다음 요청으로 중계하는 Chrome 확장 「Header Relay」를 공개했다
API 개발 및 검증을 위한 Chrome 확장 Header Relay를 공개했습니다.
- Chrome Web Store: https://chromewebstore.google.com/detail/olmfkdclaloaacojbgaabokehlbphilf
- 공식 사이트: https://hsblabs.github.io/header-relay/
응답 헤더에서 값을 캡처해 이후 대상 origin으로의 요청에 자동으로 부여하는 확장입니다. 구조만 들으면 간단해 보이지만, Manifest V3에는 이를 구현하기 위한 직접적인 수단이 처음부터 존재하지 않습니다. 이 글에서는 어떤 문제를 해결하는 것인지, 그 제약 아래에서 어떻게 설계했는지를 씁니다.
해결하고 싶은 문제
서버가 세션 토큰이나 트레이스 ID, 게이트웨이용 헤더를 응답 헤더로 반환하고, 다음 요청에 다시 보내오기를 기대하는 API가 있습니다. 브라우저에서 검증할 때 선택지는 사실상 세 가지였습니다.
- DevTools에서 수동으로 복사/붙여넣기. 토큰이 로테이션될 때마다 다시 해야 합니다.
- ModHeader 계열의 정적 헤더 확장. 고정 값만 부여할 수 있어, 매번 다른 값을 반환하는 서버에는 대응할 수 없습니다.
- mitmproxy나 Charles 같은 프록시. 대응은 가능하지만, TLS 개입이 포함된 설정은 「브라우저에 토큰을 돌려보내게만 하고 싶다」는 용도에는 과합니다.
2와 3 사이에 공백이 있습니다. Header Relay는 그 자리를 채웁니다. 프록시 없이, 복사/붙여넣기 없이, 브라우저 자신이 헤더를 중계합니다.
기능 개요
- Captured Header: 대상 origin의 응답에서 지정한 헤더 값을 보존하고, 이후 요청에 부여한다
- Fixed Header: 항상 부여하는 정적 헤더
- Profile: origin 단위의 설정 모듈. 여러 개를 동시에 활성화할 수 있다
- Excluded Path: glob 패턴으로 제외한다 (저장 전에 테스트할 수 있는 테스터 내장)
- URL Probe: 미저장 드래프트 설정에도 「이 URL에 무엇이 부여되는지」를 미리 볼 수 있다
- Audit Logs: 로컬에만, 최근 1,000건 및 7일 이내로 자동 정리. 요청 URL은 영속화하지 않는다
UI는 영어, 일본어, 한국어를 지원합니다.
설계
관찰과 재작성의 분리
브라우저 자신에게 중계시킨다는 방침은 자연스럽게 결정됩니다. 문제는 그 다음입니다. 예전이라면 blocking webRequest 리스너에서 요청 직전에 헤더를 삽입하면 끝이었지만, Manifest V3는 바로 그 재작성을 폐지했습니다. 가장 직접적인 방법이 처음부터 선택지에 없는 것입니다.
그래서 역할을 분리했습니다.
- 관찰: non-blocking인
webRequest.onHeadersReceived가 유효 origin의 응답 헤더를 보고, 설정된 Captured Header 값을storage.session(메모리 전용)에 저장한다 - 재작성: 유효 Profile 목록과 Profile별 세션 상태를 컴파일해, 단일 declarativeNetRequest session ruleset에 동기화한다. 부여는 attach rule, Excluded Path는 해당 Profile이 부여하는 헤더만 제거하는 remove rule
요청 시에 JavaScript는 개입하지 않고, 부여는 Chrome의 DNR 엔진이 네이티브로 처리합니다. Service Worker가 잠들어 있어도 session rule은 계속 적용되므로, MV3의 라이프사이클 문제와도 잘 맞는 구성입니다.
단일 컴파일 패스
origin 일치 판정이나 제외 경로 판정은 DNR rule 생성에도, URL Probe에도, Runtime Status 표시에도 필요합니다. 직접적으로 작성하면 동일한 매칭 로직이 세 곳에 생깁니다. 세 곳은 언젠가 어긋납니다. 「Probe에서는 부여된다고 표시됐는데, 실제 요청에는 부여되지 않는다」는 버그의 원인이 바로 이것입니다.
그래서 모든 판정을 compileConfig() 의 출력에 집약했습니다. 유효 Profile 목록과 세션 상태를 중간 표현 CompiledConfig으로 변환하고, DNR rule 생성도 URL Probe도 Runtime Status 표시도 이 결과만을 입력으로 사용합니다. 매칭 로직이 한 곳에만 있으므로, 표시와 실제 동작의 불일치는 구조적으로 발생하기 어렵습니다. 헤더 해석 우선순위(같은 이름이면 fixed 우선 등)도 같은 모듈에 집약하고 있습니다.
충돌은 에러로 처리한다, priority로 자동 해결하지 않는다
여러 유효 Profile이 같은 origin의 같은 헤더 이름을 부여하려 한다면 어떻게 해야 할까요. priority로 자동 해결하는 방법은 있습니다. DNR의 rule은 원래 priority 필드를 가지고 있어, 구현도 쉽습니다. 하지만 검증 도구에서 「어느 쪽 값이 부여됐는지 모르는」 상태는, 조용히 망가진 것과 같습니다. 아무것도 부여되지 않는 편이 낫습니다.
그래서 Header Relay는 이를 컴파일 에러로 처리하고, 소유 rule을 전부 삭제해 아무것도 부여하지 않는 상태로 내립니다. UI와 Runtime Status에 에러가 표시되므로, 어느 한 쪽 Profile을 비활성화하면 복구됩니다.
권한 모델
「통신 헤더를 읽는 확장」은 구조적으로 의심받아 마땅한 카테고리입니다. 그래서 권한은 fail-closed로 설계했습니다.
required host permission은 http://localhost/* 뿐입니다(Chrome의 match pattern은 포트를 지정할 수 없어, 전 포트가 대상이 됩니다). 그 외는 모두 optional_host_permissions로 하고, Target Origin 저장이나 Profile 활성화 같은 사용자 조작을 계기로 permissions.request()로 대상 호스트 분만 요청합니다.
- 미승인 origin에는 DNR rule을 생성하지 않는다. webRequest 이벤트도 전달되지 않는다.
- Chrome 설정에서 권한이 취소되면
permissions.onRemoved로 감지하고, 해당 Profile의 캡처 값을 삭제해 rule을 재동기화한다.
헤더 이름 정책
Set-Cookie(응답 전용), Host, Content-Length(message framing), Transfer-Encoding 등 connection이나 proxy의 semantics를 가진 이름은 저장 자체를 거부합니다. 반면 Authorization이나 Cookie까지 거부하면 개발 용도에서 불편하므로, UI에서 경고를 표시하고 허용하고 있습니다(Cookie는 브라우저의 Cookie 스토어와 충돌할 수 있음을 명시).
프라이버시
- 캡처 값은
storage.session에만 저장. 브라우저 재시작, 확장 리로드·업데이트, Profile 비활성화, 권한 취소, 수동 삭제 중 어느 것으로도 사라진다. UI는 기본적으로 마스크 표시. - 외부 전송은 일절 없음. production build에서는 분석 이벤트 전송과 콘솔 출력도 비활성화.
- Audit Logs에 요청 URL을 영속화하지 않는다(origin 일치 판정은 메모리 상에서만).
확장 전체를 headless로 구동하는 테스트
브라우저 확장 테스트는 방치하면 chrome.* API의 module mock 투성이가 됩니다. 그렇게 되지 않도록, 실행 시에 브라우저와 닿는 부분을 StoragePort / DnrPort / AuditPort 3개의 포트로 한정했습니다.
테스트에서는 in-memory adapter를 삽입한 createTestRuntime()으로 확장 전체를 구성하고,
- 설정을 투입한다
- 가짜 응답을 흘린다
- 실제로 적용된 ruleset을 assert한다
는 일련의 흐름을, module mock 없이 Vitest로 작성할 수 있습니다. 단 in-memory DnrPort는 Chrome DNR의 semantics까지 재현하지는 않습니다. 그 부분은 ruleset의 semantics를 parity 테스트로, 실제 동작을 Playwright의 e2e로 커버하는 다층 구조를 취하고 있습니다.
스택
- WXT: MV3 확장의 빌드, HMR, zip 생성까지 담당
- SolidJS + Tailwind CSS: popup과 관리 화면
- TypeScript + Zod: 설정 스키마
- changesets + GitHub Actions: main 머지만으로 태그, Release, 스토어 제출용 zip까지 자동 생성
마치며
설치 직후부터, localhost:3000을 대상으로 한 기본 Profile(x-session-token 캡처 및 고정 x-client-id: header-relay)로 바로 시험할 수 있습니다. DevTools에서의 복사/붙여넣기에 지치신 분은 꼭 써보세요. 정적 헤더 용도라면, 스토어에서 삭제된 ModHeader의 대체로도 그대로 사용할 수 있습니다.
소스코드는 비공개이지만, 버그 보고 및 지원했으면 하는 유스케이스 요청은 GitHub Issues에서 받고 있습니다.
- Chrome Web Store: https://chromewebstore.google.com/detail/olmfkdclaloaacojbgaabokehlbphilf
- 공식 사이트: https://hsblabs.github.io/header-relay/
- Issues / 피드백: https://github.com/hsblabs/header-relay/issues
hsb.horse