skip to content

A browser stored Strict-Transport-Security with includeSubDomains for a parent domain — which later host names does it rewrite, and how?

level: middleimportance: should knowfreq 50%

answer

  1. two match kinds, one directive
  2. congruent always, superdomain conditionally
  3. labels from the right, not characters
  4. scheme rewritten, port 80 mapped
  5. other explicit ports survive untouched

basics

~20 s

With includeSubDomains, the stored entry matches the parent name itself and any name whose rightmost labels are that parent — a superdomain match, compared label by label. Matching URLs get http rewritten to https and an explicit port 80 mapped to 443.

solid answer

~40 s

A stored entry is consulted two ways. A **congruent match** is the request host being the stored name exactly, and that always applies. A **superdomain match** is the stored name being a suffix of the request host in whole labels, compared from the right, and that applies **only** if `includeSubDomains` was asserted on the entry. Comparison is by label, not by characters, so an entry for `example.org` covers `portal.example.org` and `old.hs.example.org` but never `myexample.org`, which merely ends with the same text. On a match the browser rewrites the URL before building a request: `http` becomes `https`, an explicit port `80` becomes `443`, any other explicit port is kept as written, and where no port was written none is added.

code

pseudocode · 16 lines
pseudocode
function on_load(url):
    for each entry in stored_hsts_entries:
        if entry.name equals url.host:
            return upgrade(url)                  # congruent match
        if entry.include_subdomains
           and labels(entry.name) is the rightmost run of labels(url.host):
            return upgrade(url)                  # superdomain match
    return url                                    # unknown host, use as written

function upgrade(url):
    if url.scheme is "http":
        set url.scheme to "https"
    if url.port is 80:
        set url.port to 443
    # any other explicit port is kept; none is added if none was written
    return url

go deeper

for a junior

Recall that the optional valueless directive widens a stored policy from one name to every name beneath it, and that the browser rewrites http to https for all of them.

for a middle

Explain congruent versus superdomain matching in labels from the right, and state exactly what the rewrite does to the scheme and to an explicit port.

for a senior

Show the operational consequence: asserting the directive is an inventory commitment over every name in the tree, including ones you neither built nor maintain.

for a principal

Weigh a tree-wide commitment against the cost it imposes on name owners who were never consulted, and decide what evidence is needed before widening the scope.

## Two ways an entry can match When a browser is about to load a URL it looks for a stored HSTS entry whose name matches the URL's host. RFC 6797 defines two match kinds, and the distinction is the whole subject of this question. - **Congruent match** — the request host is the stored name itself. This always applies to a Known HSTS Host, whether or not `includeSubDomains` was asserted. - **Superdomain match** — the stored name is a *superdomain* of the request host: the request host has more labels, and its rightmost labels are exactly the stored name. This applies **only** when the stored entry asserted `includeSubDomains`. So an entry for a district's parent domain without the directive covers exactly one name. With the directive it covers the whole tree beneath it. ## Matching is by label, from the right The comparison walks labels right to left; it is not a string suffix test. That difference is the one worth being precise about, because a suffix test is both too generous and too easy to write: | stored name, with includeSubDomains | request host | match? | why | |---|---|---|---| | `example.org` | `example.org` | yes | congruent — the same name | | `example.org` | `portal.example.org` | yes | superdomain — one extra label on the left | | `example.org` | `old.hs.example.org` | yes | superdomain — depth is not limited | | `example.org` | `myexample.org` | no | `myexample` is one label; it is not `example` | | `example.org` | `example.org.test` | no | the stored labels are not the rightmost ones | | `example.org` | `example.com` | no | the rightmost label already differs | The fourth and fifth rows are the ones people get wrong, and they are wrong in opposite directions: one is a name an attacker can register that a naive suffix test would treat as covered, and the other is a name an attacker can put the covered labels *inside*. ## What the rewrite actually changes On a match, the browser modifies the URL before a request is constructed: 1. If the scheme is `http`, replace it with `https`. 2. If the port was explicitly `80`, replace it with `443`. 3. If any other port was explicitly written, **keep it unchanged**. 4. If no port was written at all, **do not add one**. So `http://portal.example.org/login` becomes `https://portal.example.org/login`, and `http://portal.example.org:8080/login` becomes `https://portal.example.org:8080/login` — same port, different scheme. The policy is about the scheme, not about moving traffic to a default port. The path, query and fragment are untouched. ## Why the directive exists at all It would be easy to read `includeSubDomains` as a convenience for sites that do not want to set the header on each name. The argument in its favour is sharper than that, and it is about scope mismatch. Cookie scoping follows the domain, not the origin: a cookie set for a parent domain accompanies requests to names beneath it. A name the browser has never reached has no stored entry of its own, so its first request is plaintext — and that is precisely the position an attacker wants, adjacent to a session that is scoped to the whole domain. Asserting `includeSubDomains` on the parent closes that position for every name in the tree at once, including names nobody has visited yet. (The attribute semantics of cookies themselves are a separate subject; what matters here is only that their scope is wider than one host.) ## The cost of the same property The directive commits names you may not operate. In a school district, the parent domain typically has a name per school, and some of them are old, plaintext-only, and maintained by nobody currently employed. Asserting `includeSubDomains` on the parent makes every one of those names unreachable to any browser holding the entry unless it serves TLS correctly — and the policy has no exception list. There is no way to cover the portal and carve out one school site; the tree is covered or it is not. That is why the directive is the point at which an HSTS rollout stops being a header change and becomes an inventory exercise: enumerate every name under the domain, confirm each serves TLS, and only then widen the entry. The reverse direction is much slower, because a browser that already holds the wide entry can only be told otherwise by a secure response it comes back to fetch. ## One boundary worth stating The stored entry governs which scheme the browser uses when it makes a request to a matching host. It says nothing about what a document returned from such a host then references, and it is not a statement about any name outside the matched tree.

  • Why does an entry for example.org not cover myexample.org?
    Because matching compares whole labels from the right, not characters. `myexample` is a single label and it is not `example`, so the stored name is not a superdomain of the request host. A character-level suffix test would treat it as covered, which is why the specification defines the comparison in labels — an attacker can register a name that merely ends with your text.
  • A covered URL names an explicit port 8443. What does the browser request?
    The same port over `https`. The rewrite replaces an `http` scheme with `https` and maps an explicit port `80` to `443`; any other explicit port is preserved exactly as written, and no port is introduced where the URL carried none. The policy is about which scheme is used, not about relocating traffic to a default port.
  • Can a parent domain assert includeSubDomains but exempt one subdomain?
    No. The directive is valueless and takes no exception list, so the whole tree beneath the name is covered or none of it is. A name that cannot serve TLS correctly must be moved, fixed, or placed outside the covered domain before the directive is asserted — there is no per-name carve-out at any point afterwards.

saying these in an interview costs you the question

  • Treats the match as a character suffix test on the host name.
  • Thinks includeSubDomains accepts a list of names to cover.
  • Believes the rewrite forces every covered URL onto port 443.
  • Says only one level of subdomain is covered.
  • Assumes a subdomain can opt out by sending its own header.