본문 바로가기
C.W.K.
Lesson 05 of 05 · published

부모가 묻는 단 하나의 쿠키

~14 min · 쿠키, 최소 권한, 계약, webkit

Level 0새 탭
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

쿠키를 쥐지 않는 부모

트랙 3에서 Voyager를 둘로 나눴지. 평범한 값으로 따지는 부모 타깃, 그리고 WebKit을 가진 엔진 자식. 쿠키는 WebKit 저장소에 사니까 자식 쪽에 살아. 부모는 쿠키를 보여 달라고 하지 않아. 예외가 딱 하나 있는데, 그 예외를 위한 좁은 동사가 계약 안에 따로 있어.

예외는 가족 카탈로그야. 서비스 호스트 밖에서 Firelink는 짝지은 런처 기기나 허브에 직접 로그인한 세션한테만 카탈로그를 줘. Voyager는 기기를 짝짓지 않아. 그 대신 허브 로그인을 다시 쓰는 2번 안을 아빠가 골랐거든. Voyager가 페이지 밖에서 자기 이름으로 하는 요청은 몇 개 있어. 페이지 아이콘, 피파 PIN 로그인, 그리고 카탈로그. 그중 웹 로그인 쿠키를 실어야 하는 건 카탈로그 가져오기 하나뿐이야. 아빠가 Voyager에서 허브에 로그인하며 남긴 세션 쿠키 말이야. 카탈로그 쪽 이야기는 트랙 6에서 하고, 이 레슨은 쿠키 쪽 이야기야. 부모가 쿠키 통을 쥐지 않고 헤더 하나만 받는 방법이지.

좁게 적은 질문

계약 메서드는 cookieHeader(for:path:named:identity:)고, 주석이 곧 명세야. identity의 저장소가 origin의 path로 보낼 Cookie 헤더를, names에 적힌 쿠키로만 만들라는 거야. 이어서 이렇게 적혀 있어. 부모는 자기가 직접 하는 요청 하나를 위해 묻고, 값은 어디에도 안 둔다. 없으면 nil이고, 시크릿 아이덴티티면 언제나 nil이다.

인자 하나하나가 답을 좁혀.

  • identity: 가족 앱이 열리는 기본 아이덴티티. 시크릿 아이덴티티는 늘 nil.
  • origin: 허브 자기 스킴, 호스트, 포트.
  • path: 카탈로그 경로, /api/v1/launcher/catalog.
  • names: 딱 하나. 허브 스킴에 맞는 Firelink 세션 쿠키야. https면 cwkfirelink_session_tls, http면 cwkfirelink_session. 루프백이면 이름을 아예 안 물어. 허브를 띄운 Mac은 세션이 필요 없거든.

돌아오는 건 바로 헤더가 될 문자열이야. 요청 하나에 읽고 어디에도 안 둬. 허브가 그걸 거절하면 카탈로그 모델은 그 hashValue만 메모리에 기억해. 그래야 쿠키가 바뀔 때마다 같은 낡은 쿠키를 또 보내지 않아.

자식이 답해

WebKit 자식은 그 아이덴티티 저장소의 쿠키를 자식 안에서 전부 읽어서 RequestCookies.header에 넘겨. 이 함수는 순수 함수고 부모 타깃의 계약 파일에 살아. 그래서 가짜 엔진도 진짜 자식과 똑같은 규칙으로 부모한테 답하고, 이 규칙은 브라우저 하나 없이 시험할 수 있어.

브라우저가 쿠키를 고르는 법, 열몇 줄로

규칙왜 있어
쿠키 이름이 요청한 이름 중 하나쿠키는 포트를 안 따져. 피파 세션은 허브와 같은 테일넷 호스트의 다른 포트에 사는데, 그걸 헤더에서 빼 주는 건 이름뿐이야.
쿠키 도메인이 호스트와 정확히 같음일부러 브라우저보다 깐깐해. Firelink는 Domain을 안 정하니까 그 쿠키는 호스트 전용이야. 더 넓은 도메인으로 잡힌 쿠키는 누가 심었든 Firelink가 심은 그 쿠키가 아니야. 그 도메인 아래 호스트면 어디서든 심을 수 있거든. 허브 호스트 자신도.
만료되지 않음만료일 없는 쿠키, 그러니까 세션 쿠키도 쳐 줘.
Secure 쿠키는 https에서만브라우저와 같은 규칙. 평문 http엔 절대 안 실려.
요청 경로가 쿠키 경로 아래RFC 6265 5.1.4절. 경로가 같거나, /로 끝나는 접두사거나, 요청에서 접두사 바로 뒤에 /가 오는 경우. /api는 카탈로그 경로에 맞고 /ap는 안 맞아.
긴 경로 먼저, ; 로 이어 붙임브라우저가 보내는 순서야.

코어 테스트 onlyFirelinksCookieForTheHubsHostIsChosen은 쿠키 여섯 개짜리 통을 만들어. 하나씩 규칙 하나에 걸려. 같은 호스트의 피파 세션, 이름은 맞는데 다른 호스트, 이름은 맞는데 .tail.test 도메인 전체로 잡힌 것, 만료된 것, /admin에 있는 것, 그리고 진짜. 돌아오는 건 cwkfirelink_session_tls=hub 하나뿐이야.

아이덴티티만 알리고, 쿠키는 절대 안 알려

부모는 언제 다시 물을지도 알아야 해. 허브가 거절했어도 아빠가 잠시 뒤 탭에서 로그인할 수 있으니까. 엔진은 영속 저장소마다 CookieWatcher를 달아 둬. 저장소 쿠키가 바뀌면 감시자는 아이덴티티만 알려. 어느 쿠키인지도, 값도 안 알려. 쿠키 저장소는 관찰자를 약하게 쥐니까 감시자는 엔진이 붙잡고, 감시자는 저장소를 안 붙잡아.

부모는 바뀐 아이덴티티가 기본 아이덴티티고 카탈로그가 허브 세션을 기다리는 중일 때만 움직여. 그러면 헤더를 한 번 더 읽고, 허브가 바로 그 세션을 이미 거절한 게 아니면 카탈로그를 가져와. 아빠가 보통 탭에서 허브에 로그인하면 홈이 알아서 채워지는 거지. 시크릿 아이덴티티의 변화는 알리지 않아.

실제 WebKit 테스트 theChildHandsOverOnlyTheCookieAskedFor는 아이덴티티 하나의 저장소에 합성 호스트 하나로 Firelink 쿠키와 pippa_session을 심어. 그리고 감시자가 그 아이덴티티를 부를 때까지 기다렸다가 답 넷을 확인해. 허브는 Firelink 쿠키만 받고, 다른 호스트는 nil, 다른 아이덴티티도 nil, 시크릿 아이덴티티도 nil이고 알림도 한 번 안 나가.

헤더가 가는 곳

FirelinkHubSession.transport는 허브 자기 출처로 가는 요청에만 헤더를 붙여. 스킴 기본 포트를 채워 넣어서 https://hub/와 https://hub:443/를 하나로 쳐. 그리고 키트 전송은 리디렉션을 거부해. 그래서 헤더가 리디렉션을 타고 다른 호스트로 새어 나갈 수 없어.

원칙: 어떤 부품이 자격 증명 하나가 필요하면 그 하나만 돌려주는 동사를 줘. 자격 증명을 모아 둔 곳의 열쇠를 주지 말고.

Code

cwkVoyager, Sources/VoyagerCore/EngineContract.swift: 요청 하나에 실을 쿠키 고르기, 부모의 순수 함수 (원문 그대로, 674-705행)·swift
/// Which of a store's cookies go with one request, the way a browser picks
/// them: the names asked for, unexpired, set for exactly this host (Firelink
/// sets no `Domain`, and a cookie another host on the tailnet scoped wider is
/// not the hub's), on a path the request is under, and a `Secure` cookie only
/// over https. Cookies ignore ports, so the name is what keeps another family
/// app's session on the same tailnet host (Pippa's) out of the header.
public enum RequestCookies {
    public static func header(
        from cookies: [HTTPCookie], for origin: PageOrigin, path: String, named names: Set<String>, now: Date = Date()
    ) -> String? {
        let chosen = cookies.filter { cookie in
            names.contains(cookie.name)
                && cookie.domain.lowercased() == origin.host
                && (cookie.expiresDate.map { $0 > now } ?? true)
                && (!cookie.isSecure || origin.scheme == "https")
                && pathMatches(cookie.path, path)
        }
        // The more specific path first, as browsers order them.
        .sorted { $0.path.count > $1.path.count }
        guard !chosen.isEmpty else { return nil }
        return chosen.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
    }

    /// RFC 6265 §5.1.4: the cookie's path is the request's, or a prefix that
    /// ends at a `/`.
    static func pathMatches(_ cookiePath: String, _ requestPath: String) -> Bool {
        let cookiePath = cookiePath.isEmpty ? "/" : cookiePath
        guard requestPath.hasPrefix(cookiePath) else { return false }
        return requestPath.count == cookiePath.count || cookiePath.hasSuffix("/")
            || requestPath.dropFirst(cookiePath.count).hasPrefix("/")
    }
}
cwkVoyager, Sources/VoyagerWebKit/WebKitEngine.swift: 자식이 저장소를 읽고 헤더만 돌려준다 (원문 그대로, 95-104행)·swift
    public var cookiesDidChange: (@MainActor (IdentityID) -> Void)?
    private var cookieWatchers: [IdentityID: CookieWatcher] = [:]

    /// Reads the identity's cookies here, in the child, and hands the parent
    /// only the header made of the names it asked for.
    public func cookieHeader(for origin: PageOrigin, path: String, named names: Set<String>, identity: IdentityID) async -> String? {
        guard identity != .privateSession, !names.isEmpty else { return nil }
        let cookies = await store(for: identity).httpCookieStore.allCookies()
        return RequestCookies.header(from: cookies, for: origin, path: path, named: names)
    }
cwkVoyager, Tests/VoyagerCoreTests/FamilyCatalogModelTests.swift: 쿠키 여섯, 규칙 다섯, 남는 건 하나 (원문 그대로, 157-175행)·swift
    @Test func onlyFirelinksCookieForTheHubsHostIsChosen() {
        let origin = PageOrigin(url: Self.hub)!
        let path = FirelinkHubSession.catalogPath
        let jar = [
            Self.cookie("pippa_session", "pippa"),   // another app on the same host: cookies ignore ports
            Self.cookie("cwkfirelink_session_tls", "theirs", host: "other.tail.test"),
            Self.cookie("cwkfirelink_session_tls", "wide", host: ".tail.test"),
            Self.cookie("cwkfirelink_session_tls", "old", path: "/api", expires: Date(timeIntervalSinceNow: -60)),
            Self.cookie("cwkfirelink_session_tls", "elsewhere", path: "/admin"),
            Self.cookie("cwkfirelink_session_tls", "hub"),
        ]
        #expect(RequestCookies.header(from: jar, for: origin, path: path, named: ["cwkfirelink_session_tls"]) == "cwkfirelink_session_tls=hub")
        #expect(RequestCookies.header(from: jar, for: origin, path: path, named: ["cwkfirelink_session"]) == nil)
        // A Secure cookie never goes over plain http.
        let plain = PageOrigin(scheme: "http", host: "hub.tail.test", port: 9400)
        #expect(RequestCookies.header(from: jar, for: plain, path: path, named: ["cwkfirelink_session_tls"]) == nil)
        #expect(RequestCookies.pathMatches("/api", "/api/v1/launcher/catalog"))
        #expect(!RequestCookies.pathMatches("/ap", "/api/v1/launcher/catalog"))
        #expect(RequestCookies.pathMatches("/", "/api/v1/launcher/catalog"))

External links

Exercise

순수 함수를 하나 써 봐. 쿠키 목록, 요청 출처(스킴, 호스트, 포트), 경로, 허용할 이름 목록을 받아서, 그 요청에 실을 Cookie 헤더를 돌려주거나 아무것도 안 돌려주는 함수야. Voyager 테스트의 경우들을 다 다뤄. 이름은 맞는데 다른 호스트, 이름은 맞는데 상위 도메인으로 잡힌 것, 만료된 것, http로 가는 Secure 쿠키, 옆 경로의 쿠키, 같은 호스트의 상관없는 쿠키. 다 됐으면 네 코드에서 항목 하나 쓰려고 자격 증명 저장소를 통째로 읽는 곳을 찾아서, 필요한 항목만 돌려주는 호출로 바꿔 봐.
Hint
도메인 규칙을 얼마나 깐깐하게 할지는 일부러 정해. 브라우저는 .example.com으로 잡힌 쿠키를 그 아래 모든 호스트에 보내. Voyager는 원하는 쿠키가 호스트 전용이라서 카탈로그 요청엔 그걸 거부해. 경로 규칙은 RFC의 세 경우를 테스트 셋으로 따로 써. 경로가 같을 때, 접두사가 슬래시로 끝날 때, 요청에서 접두사 바로 뒤가 슬래시일 때.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.