Skip to content
C.W.K.
Lesson 05 of 05 · published

The One Cookie the Parent Ever Asks About

~14 min · cookies, least-privilege, contracts, webkit

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

A parent that never holds a cookie

Track 3 split Voyager in two: a parent target that reasons on plain values, and an engine child that owns WebKit. Cookies live in WebKit's stores, so they live in the child. The parent never asks to see them, with one exception, and that exception has its own narrow verb in the contract.

The exception is the family catalog. Away from the service host, Firelink answers its catalog only to a paired launcher device or to the hub's own signed-in session. Voyager pairs no device; Dad ruled for reusing the hub sign-in instead ("option 2"). Voyager makes a few requests of its own, outside any page: page icons, Pippa's PIN sign-in, the catalog. Only the catalog fetch has to carry a cookie from a web sign-in, the session cookie that Dad's sign-in to the hub left in Voyager. Track 6 tells the catalog's side of that. This lesson is the cookie's side: how the parent gets one header without ever holding a jar.

The question, worded narrowly

The contract's method is cookieHeader(for:path:named:identity:), and its comment is the specification: "The Cookie header identity's store would send to origin at path, made of the cookies named in names and no other." It goes on: the parent asks for one request it makes itself, "and keeps the value nowhere. Nil when there is none, and always for the private identity."

Every argument narrows the answer:

  • identity: the default one, the identity family apps open in. The private identity always gets nil.
  • origin: the hub's own scheme, host and port.
  • path: the catalog's, /api/v1/launcher/catalog.
  • names: exactly one, Firelink's session cookie for the hub's scheme (cwkfirelink_session_tls over https, cwkfirelink_session over http). On loopback no name is asked for at all, because the Mac that serves the hub needs no session.

What comes back is a string ready to be a header. It is read for one request and kept nowhere. When the hub refuses one, the catalog model remembers only its hashValue, in memory, so the same stale cookie is not sent again at every change of cookies.

The child answers

The WebKit child reads every cookie of that identity's store, there in the child, and passes them to RequestCookies.header. That function is pure and lives in the parent's target, in the contract file, so the fake engine answers the parent with the very same rule the real child uses, and the rule can be tested with no browser at all.

How a browser picks cookies, in a dozen lines

RuleWhy it is there
The cookie's name is one of those asked forCookies ignore ports. Pippa's own session lives on the same tailnet host as the hub, on another port, and only the name keeps it out of the header.
The cookie's domain equals the host exactlyStricter than a browser on purpose. Firelink sets no Domain, so its cookie is host-only. A cookie scoped to a wider domain is not the one Firelink set, whichever host set it: any host under that domain could have, the hub's own host included.
Not expiredA cookie with no expiry date, a session cookie, counts.
A Secure cookie only over httpsThe same rule a browser follows, so plain http never carries it.
The request's path is under the cookie'sRFC 6265 section 5.1.4: the same path, a prefix that ends in a /, or a prefix followed by a / in the request. /api matches the catalog's path; /ap does not.
Longer paths first, joined with ; The order browsers send them in.

The core test onlyFirelinksCookieForTheHubsHostIsChosen builds a jar of six cookies, each failing one rule: Pippa's session on the same host, the right name on another host, the right name scoped to the whole .tail.test domain, an expired one, one on /admin, and the real one. Only cwkfirelink_session_tls=hub comes back.

Told which identity, never which cookie

The parent also needs to know when to ask again: if the hub refused, Dad may sign in to it in a tab a moment later. The engine registers a CookieWatcher on each persistent store. When that store's cookies change, the watcher reports the identity, and nothing else: not which cookie, not its value. The cookie store holds its observers weakly, so the engine keeps the watchers, and a watcher keeps no store.

The parent reacts only when the changed identity is the default one and the catalog is waiting for a hub session. Then it reads the header once more, and if the hub has not already refused that exact session, it fetches the catalog. Dad signs in to the hub in an ordinary tab and Home fills itself in. The private identity's changes are never reported.

The real-WebKit test theChildHandsOverOnlyTheCookieAskedFor plants Firelink's cookie and a pippa_session on one synthetic host in one identity's store, waits for the watcher to name that identity, and then checks four answers: the hub gets only Firelink's cookie, another host gets nil, another identity gets nil, and the private identity gets nil and is never reported.

Where the header goes

FirelinkHubSession.transport adds the header only to requests for the hub's own origin, with the scheme's default port filled in so https://hub/ and https://hub:443/ count as one, and the kit's transport refuses redirects. The header cannot follow a redirect to another host.

Principle: When a component needs one credential, give it a verb that returns exactly that credential, not access to the place credentials are kept.

Code

cwkVoyager, Sources/VoyagerCore/EngineContract.swift: which cookies go with one request, as a pure function in the parent (verbatim, lines 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: the child reads the store and hands back only the header (verbatim, lines 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: six cookies, five rules, one winner (verbatim, lines 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

Write a pure function that takes a list of cookies, a request origin (scheme, host, port), a path and an allowlist of names, and returns the Cookie header for that request or nothing. Cover the cases in Voyager's test: a right name on another host, a right name scoped to a parent domain, an expired cookie, a Secure cookie over http, a cookie on a sibling path, and an unrelated cookie on the same host. Then find one place in your own code where a component reads a whole credential store to use one entry, and replace that with a call that returns only the entry it needs.
Hint
Decide on purpose how strict the domain rule is. A browser sends a cookie scoped to .example.com to every host under it; Voyager refuses that for the catalog request because the cookie it wants is host-only. For the path rule, write the three RFC cases as separate tests: equal paths, a prefix ending in a slash, and a prefix followed by a slash in the request.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.