Skip to content

syshttp.cookies

syshttp.cookies

Netscape cookie-file I/O and curl's cookie rules: CookieSpec, load_netscape, save_netscape, cookie_applies, set_cookie_specs and the host groups of host_aliases. The client keeps its cookies as these rows; a curl front end's -b / -c run on them too.

CookieLike

Bases: Protocol

What :func:save_netscape reads off a cookie: a CookieSpec, an http.cookiejar.Cookie or any object with these attributes.

cookie_applies(cookie, url, now=None)

Does cookie go with a request to url, as curl decides: the domain (the host itself, or a subdomain for a leading dot or the include-subdomains flag -- never for an IP address, which only matches itself), the path (RFC 6265 path-match), Secure only over https or to localhost / a loopback address, and not expired (0 / None is a session cookie).

Source code in src/syshttp/cookies.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def cookie_applies(cookie: CookieSpec, url: str, now: Optional[float] = None) -> bool:
    """Does ``cookie`` go with a request to ``url``, as curl decides: the
    domain (the host itself, or a subdomain for a leading dot or the
    include-subdomains flag -- never for an IP address, which only matches
    itself), the path (RFC 6265 path-match), ``Secure`` only over https or to
    localhost / a loopback address, and not expired (``0`` / ``None`` is a
    session cookie)."""
    parts = urlparse(url)
    host = (parts.hostname or "").lower()
    domain = cookie.domain
    try:
        ipaddress.ip_address(host)
        if domain.lstrip(".").lower() != host:
            return False
    except ValueError:
        if cookie.subdomains and not domain.startswith("."):
            domain = "." + domain
        if not domain_covers(domain, host):
            return False
    if not _path_matches(cookie.path or "/", parts.path or "/"):
        return False
    if cookie.secure and parts.scheme != "https" and host not in _SECURE_HOSTS:
        return False
    if cookie.expires and cookie.expires <= (time.time() if now is None else now):
        return False
    return True

domain_covers(domain, host)

Does a cookie domain apply to host: the host itself, or a subdomain when the domain has a leading dot (the Netscape flag).

Source code in src/syshttp/cookies.py
361
362
363
364
365
366
367
368
369
def domain_covers(domain: str, host: str) -> bool:
    """Does a cookie ``domain`` apply to ``host``: the host itself, or a
    subdomain when the domain has a leading dot (the Netscape flag)."""
    d = (domain or "").lower()
    h = (host or "").lower()
    if not d or not h:
        return False
    bare = d.lstrip(".")
    return h == bare or (d.startswith(".") and h.endswith("." + bare))

host_aliases(groups)

Each host of groups mapped to its group's first host: hosts a user declares to be one for cookies. A group of one host is no alias. A host that is not a name or an IP address (a port, a path, a blank), or that appears in two groups, raises ValueError.

Source code in src/syshttp/cookies.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
def host_aliases(groups: Iterable[Iterable[str]]) -> Dict[str, str]:
    """Each host of ``groups`` mapped to its group's first host: hosts a user
    declares to be one for cookies. A group of one host is no alias. A host
    that is not a name or an IP address (a port, a path, a blank), or that
    appears in two groups, raises ``ValueError``."""
    aliases: Dict[str, str] = {}
    for group in groups:
        if isinstance(group, str):
            raise TypeError("a cookie host group is a list of hosts, not the string %r" % group)
        hosts = []
        for raw in group:
            host = _normal_host(raw)
            if not (_is_ip(host) or _HOST_NAME.fullmatch(host)):
                raise ValueError("%r is not a host name or an IP address" % raw)
            if host in aliases:
                raise ValueError("%r is in two cookie host groups" % raw)
            if host not in hosts:
                hosts.append(host)
        if len(hosts) > 1:
            aliases.update((host, hosts[0]) for host in hosts)
    return aliases

load_netscape(path)

The rows of the Netscape cookie file path ([] when it is missing). Lines end at \n only, one trailing \r taken off, and a row whose name or value holds a control character is skipped: other characters str.splitlines honours (\x0b, \x0c, \x85) are part of a value, not the end of a row.

Source code in src/syshttp/cookies.py
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
def load_netscape(path: Path) -> List[CookieSpec]:
    """The rows of the Netscape cookie file ``path`` (``[]`` when it is
    missing). Lines end at ``\\n`` only, one trailing ``\\r`` taken off, and a
    row whose name or value holds a control character is skipped: other
    characters ``str.splitlines`` honours (``\\x0b``, ``\\x0c``, ``\\x85``) are
    part of a value, not the end of a row."""
    try:
        text = read_text(path)
    except FileNotFoundError:
        return []
    cookies: List[CookieSpec] = []
    for line in text.split("\n"):
        if line.endswith("\r"):
            line = line[:-1]
        # Only the leading blanks go: a trailing tab separates an empty value.
        line = line.lstrip(" ")
        # curl writes an HttpOnly cookie's domain as `#HttpOnly_<domain>`; any
        # other `#` line is a comment.
        if not line.strip() or (line.startswith("#") and not line.startswith(HTTP_ONLY_PREFIX)):
            continue
        parts = line.split("\t")
        if len(parts) == 6:  # an editor trimmed the empty value's tab
            parts.append("")
        if len(parts) < 7:
            continue
        domain, flag, cpath, secure, expires, name, value = parts[:7]
        if _CONTROL.search(name) or _CONTROL.search(value):
            continue
        http_only = domain.startswith(HTTP_ONLY_PREFIX)
        if http_only:
            domain = domain[len(HTTP_ONLY_PREFIX) :]
        cookies.append(
            CookieSpec(
                domain=domain,
                path=cpath or "/",
                secure=secure.strip().upper() == "TRUE",
                # `0` is how the format writes a session cookie: no expiry.
                expires=ascii_int(expires) or None,
                name=name,
                value=value,
                http_only=http_only,
                subdomains=flag.strip().upper() == "TRUE",
            )
        )
    return cookies

save_netscape(session_cookies, path)

Writes session_cookies to path as a Netscape cookie file, owner only and replaced whole. A cookie with no name, or with a control character or a tab in a field, is not written: it would split its row.

Source code in src/syshttp/cookies.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def save_netscape(session_cookies: "Iterable[CookieLike]", path: Path) -> None:
    """Writes ``session_cookies`` to ``path`` as a Netscape cookie file, owner
    only and replaced whole. A cookie with no name, or with a control character
    or a tab in a field, is not written: it would split its row."""
    lines = ["# Netscape HTTP Cookie File\n"]
    for cookie in session_cookies:
        domain = getattr(cookie, "domain", "") or ""
        flag = "TRUE" if domain.startswith(".") or getattr(cookie, "subdomains", False) else "FALSE"
        if getattr(cookie, "http_only", False):
            domain = HTTP_ONLY_PREFIX + domain
        cpath = getattr(cookie, "path", "/") or "/"
        secure = "TRUE" if bool(getattr(cookie, "secure", False)) else "FALSE"
        expires_val = getattr(cookie, "expires", None)
        expires = str(int(expires_val)) if expires_val else "0"
        name = getattr(cookie, "name", "")
        value = getattr(cookie, "value", "")
        if not name or any(_UNSAFE_FIELD.search(str(field)) for field in (domain, cpath, name, value)):
            continue
        lines.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\n" % (domain, flag, cpath, secure, expires, name, value))
    write_private(path, "".join(lines).encode("utf-8"))

(cookie, gone) for every cookie the Set-Cookie header values carry, read as RFC 6265 section 5.2 does.

default_domain is the host asked. A Domain attribute is kept only when that host domain-matches it and it is not a public suffix (judged without the Public Suffix List: a top level domain or a co.uk-style registry); it then covers subdomains (subdomains=True). Without one the cookie is host-only. A cookie with no Path gets the directory of request_path. gone marks a cookie the server expires: an empty value, Max-Age of zero or less, a past date. A header that is not a cookie, or names a foreign domain, is skipped. An expiry beyond 400 days is cut to 400 days. aliases (:func:host_aliases) makes a group of hosts one: a cookie a member sets with no Domain, or with a Domain naming a member, belongs to the group's first host.

Source code in src/syshttp/cookies.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def set_cookie_specs(
    values: Iterable[str],
    default_domain: str,
    now: Optional[float] = None,
    *,
    request_path: str = "/",
    aliases: Optional[Mapping[str, str]] = None,
) -> List[Tuple[CookieSpec, bool]]:
    """``(cookie, gone)`` for every cookie the ``Set-Cookie`` header
    ``values`` carry, read as RFC 6265 section 5.2 does.

    ``default_domain`` is the host asked. A ``Domain`` attribute is kept only
    when that host domain-matches it and it is not a public suffix (judged
    without the Public Suffix List: a top level domain or a ``co.uk``-style
    registry); it then covers subdomains (``subdomains=True``). Without one
    the cookie is host-only. A cookie with no ``Path`` gets the directory of
    ``request_path``. ``gone`` marks a cookie the server expires: an empty
    value, ``Max-Age`` of zero or less, a past date. A header that is not a
    cookie, or names a foreign domain, is skipped. An expiry beyond 400 days
    is cut to 400 days. ``aliases`` (:func:`host_aliases`) makes a group of
    hosts one: a cookie a member sets with no Domain, or with a Domain naming
    a member, belongs to the group's first host."""
    now = time.time() if now is None else now
    out: List[Tuple[CookieSpec, bool]] = []
    for header in values:
        parsed = _parse_set_cookie(header, now)
        if parsed is None:
            continue
        name, value, attrs = parsed
        placed = _placed_for(default_domain, attrs.get("domain"), aliases)
        if placed is None:
            continue
        domain, subdomains = placed
        path = attrs.get("path", "")
        expires = int(attrs["expires"]) if "expires" in attrs else None
        spec = CookieSpec(
            domain=domain,
            path=path if path.startswith("/") else _default_path(request_path),
            secure="secure" in attrs,
            expires=expires,
            name=name,
            value=value,
            http_only="httponly" in attrs,
            subdomains=subdomains,
        )
        out.append((spec, not value or (expires is not None and expires <= int(now))))
    return out