11 — The connection and the domain
Every fix so far has been inside the app: what it accepts, what it renders, what it fetches, who it lets through. Chapter 10 ended with the server deciding who may touch what. All of it rests on an assumption the app can’t check by itself: that the bytes reaching the browser came from the app, and that the bytes reaching the app came from the browser.
On 127.0.0.1 that holds, because nothing sits in between. Once the app is on a domain, three other systems are involved. TLS protects the connection. The certificate system decides who can prove they are the domain. DNS decides where the domain points. Each of them has its own failure modes, and none of them are visible in the Flask code.
This chapter uses two kinds of evidence. Read-only lookups (openssl s_client, dig, curl -I) against well-known public sites show what real deployments look like. Everything that involves an attack runs locally, with headless Chrome 157 told to resolve made-up .test names to 127.0.0.1 through --host-resolver-rules, and a self-signed certificate whose key Chrome was told to accept (--ignore-certificate-errors-spki-list). The browser treats those pages as real HTTPS on a real-looking domain, which is what the demos need.
What TLS gives the app
The handshake, key exchange and record layer are covered in depth in How TLS works, and when it can be decrypted. From the app’s point of view TLS provides three things: nobody on the path can read the traffic, nobody can change it without detection, and the browser is talking to a server that holds a private key for a certificate naming the domain. The third is the one this chapter is about, because it is only as strong as the process that decides who gets such a certificate.
A connection to letsencrypt.org shows the parts:
$ openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org </dev/null
depth=3 C=US, O=Internet Security Research Group, CN=ISRG Root X2
depth=2 C=US, O=ISRG, CN=Root YE
depth=1 C=US, O=Let's Encrypt, CN=YE2
depth=0 CN=letsencrypt.org
0 s:CN=letsencrypt.org
i:C=US, O=Let's Encrypt, CN=YE2
v:NotBefore: Nov 3 13:44:34 2026 GMT; NotAfter: Feb 1 13:44:33 2027 GMT
1 s:C=US, O=Let's Encrypt, CN=YE2
i:C=US, O=ISRG, CN=Root YE
2 s:C=US, O=ISRG, CN=Root YE
i:C=US, O=Internet Security Research Group, CN=ISRG Root X2
3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
Protocol: TLSv1.3
Cipher : TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)
(OpenSSL 3.6.5, output trimmed.) The server sends its own certificate (s: is the subject, i: the issuer) and the intermediates above it. The leaf is signed by the intermediate YE2, which is signed by Root YE, which the server also presents as cross-signed by ISRG Root X2, itself cross-signed by ISRG Root X1. The client walks up until it reaches a certificate in its own trust store, so a newer client that already trusts one of the newer roots stops early, and an older one that only knows ISRG Root X1 can still build a path. Let’s Encrypt’s certificates page lists the current hierarchy. The leaf is valid for 90 days.
The leaf’s subject alternative names list every hostname it covers:
X509v3 Subject Alternative Name:
DNS:cp.letsencrypt.org, DNS:cp.root-x1.letsencrypt.org, DNS:cps.letsencrypt.org, ...
DNS:lencr.org, DNS:letsencrypt.com, DNS:letsencrypt.org, DNS:www.lencr.org, ...
How a certificate gets issued
Short-lived certificates are practical only because issuance is automated. ACME (RFC 8555) is the protocol: a client asks the CA for a certificate for a name, the CA issues a challenge, and the client proves control of the name by completing it. The two challenge types defined in the RFC are http-01 (serve a given token at http://<name>/.well-known/acme-challenge/<token>) and dns-01 (publish a TXT record at _acme-challenge.<name>).
That proof is worth reading closely, because it says what a certificate actually certifies. A CA that validates with http-01 has confirmed that whoever answered HTTP requests for the name at that moment could serve a file. It hasn’t confirmed that this is the organization that owns the domain. Anyone who can make the name resolve to their server, or who controls the server it already resolves to, can get a valid certificate. That will matter at the end of this chapter.
For the notes app, TLS typically terminates at a reverse proxy or load balancer, which forwards plain HTTP to Flask. The app then sees http as the scheme and the proxy as the client address, which is why chapter 10 needed ProxyFix with care: x_proto=1 makes request.scheme report https (and Secure cookies and external URLs come out right), but only if a proxy is really setting that header.
HSTS and preload
A user who types notes.example gets http:// first, and the redirect to HTTPS travels over a connection anyone on the path can change. HTTP Strict Transport Security (RFC 6797) closes that after the first visit: a Strict-Transport-Security header received over HTTPS tells the browser to use HTTPS for that host, and to refuse to click through certificate errors, for max-age seconds. Chapter 7 set it on the app. letsencrypt.org sends:
$ curl -sI https://letsencrypt.org | grep -i strict
strict-transport-security: max-age=31536000
The first visit is still unprotected. The preload list fixes that by shipping a list of HSTS hosts inside browsers. hstspreload.org states the requirements: redirect HTTP to HTTPS on the same host, a max-age of at least 31536000 (one year), includeSubDomains, and the preload directive. It also warns that inclusion “cannot easily be undone”, since removal takes months to reach users through browser updates. includeSubDomains is the hard part in practice: every subdomain, including the forgotten internal ones, has to work over HTTPS before the parent can be preloaded.
One plain-HTTP resource
HTTPS on the page doesn’t help if the page pulls in something over HTTP. A script fetched over plain HTTP can be replaced in transit, and a replaced script runs with the page’s full authority, so it undoes everything TLS protects. This is mixed content, and browsers handle it in two ways, described in the W3C Mixed Content spec (a Candidate Recommendation Draft): images, audio and video are “upgradeable”, meaning the browser rewrites http: to https: and tries that; everything else, including scripts and fetch(), is blocked.
The demo page is served over HTTPS as https://notes.test:5402/mixed.html and refers to a CDN by plain http: URLs. One script comes from 127.0.0.1 instead of a name:
<script src="http://cdn.test:5404/a.js"></script>
<script src="http://127.0.0.1:5403/b.js"></script>
<img src="http://cdn.test:5404/i.png" onload="console.log('image loaded from', this.currentSrc)">
<script>
fetch("http://cdn.test:5404/data.json").then(r => r.json()).then(j => console.log("fetch ok", JSON.stringify(j)))
.catch(e => console.log("fetch failed: " + e));
</script>
The CDN on port 5404 actually speaks HTTPS. Upgrading changes the scheme but keeps a non-default port, so this lets the upgraded requests succeed and shows exactly which requests the browser upgraded and which it refused. (On default ports, an upgraded http://host/ goes to port 443.) Chrome started with --host-resolver-rules="MAP notes.test 127.0.0.1, MAP cdn.test 127.0.0.1, ...", and the DevTools Protocol recorded:
-> GET https://notes.test:5402/mixed.html
log[error/security]: Mixed Content: The page at 'https://notes.test:5402/mixed.html' was loaded over
HTTPS, but requested an insecure script 'http://cdn.test:5404/a.js'. This request has been blocked;
the content must be served over HTTPS.
xx failed: blockedReason=mixed-content
-> GET http://127.0.0.1:5403/b.js
console.log: b.js ran, loaded from http://127.0.0.1:5403/b.js
log[warning/security]: Mixed Content: ... requested an insecure element 'http://cdn.test:5404/i.png'.
This request was automatically upgraded to HTTPS
-> GET https://cdn.test:5404/i.png
console.log: image loaded from https://cdn.test:5404/i.png
log[error/security]: Mixed Content: ... requested an insecure resource 'http://cdn.test:5404/data.json'.
This request has been blocked; the content must be served over HTTPS.
console.log: fetch failed: TypeError: Failed to fetch
The server logs agree: the CDN received the image request over HTTPS and never saw a request for a.js or data.json. The browser refused them before connecting.
Three behaviors are visible. The script and the fetch() were blocked outright. The image was rewritten to https:// and loaded; the page’s markup still says http:, which is why the browser only warns. And b.js from http://127.0.0.1 loaded and ran with no warning at all. That isn’t a gap. The Secure Contexts spec (section 3.1) treats any host in 127.0.0.0/8 or ::1 as “potentially trustworthy”, because loopback traffic never leaves the machine and can’t be changed in transit. It’s also why everything in earlier chapters behaved as a secure context on http://127.0.0.1 but would not have on a plain-HTTP non-loopback name.
Blocking keeps the page safe and breaks it. The cleaner fix is to stop writing http: URLs, and the safety net is a CSP directive that upgrades every subresource request before the mixed-content check runs. The same page, served with one extra header:
Content-Security-Policy: upgrade-insecure-requests
-> GET https://notes.test:5402/mixed-fixed.html
-> GET https://cdn.test:5404/a.js
-> GET http://127.0.0.1:5403/b.js
-> GET https://cdn.test:5404/i.png
console.log: a.js ran, loaded from http://cdn.test:5404/a.js
console.log: b.js ran, loaded from http://127.0.0.1:5403/b.js
-> GET https://cdn.test:5404/data.json
console.log: image loaded from https://cdn.test:5404/i.png
console.log: fetch ok {"ok":true}
Every request to cdn.test went out as HTTPS and succeeded. (document.currentScript.src still reports the URL as written in the markup; the network log shows what was actually requested.) The loopback script was left on http:, consistent with it never being mixed content in the first place. upgrade-insecure-requests only works if the other side serves the same thing over HTTPS; if it doesn’t, the upgraded request fails instead of the insecure one being blocked, which is the same outcome with a clearer error.
Who may issue for the domain
Any of the CAs in a browser’s trust store can issue a certificate for any name. The defense against the wrong one doing so is a DNS record. CAA (RFC 8659) lists the CAs the domain owner authorizes, and CAs are required to check it before issuing:
$ dig +short CAA google.com @1.1.1.1
0 issue "pki.goog"
$ dig +short CAA letsencrypt.org @1.1.1.1
0 issue "amazon.com"
0 issue "letsencrypt.org"
0 issue "pki.goog"
0 issue "sectigo.com"
0 issue "ssl.com"
0 issue "www.digicert.com"
0 issuewild "amazon.com"
0 issuewild "letsencrypt.org"
...
issue covers ordinary names and issuewild covers wildcard certificates. Google allows only its own CA; Let’s Encrypt allows six, presumably for redundancy. (In the first run, the machine’s default resolver returned no CAA answers at all, while 1.1.1.1 did. Some resolvers and networks handle less common record types poorly, so CAA checks are best done against a known resolver.)
CAA has limits that are easy to overstate. It is checked only by CAs at issuance; browsers ignore it. It stops a mistaken or tricked CA that isn’t on the list. It doesn’t stop an attacker who can pass validation at a CA that is on the list, which is the scenario at the end of this chapter.
Seeing what was issued
Certificate transparency makes issuance public. CAs submit certificates to append-only logs (RFC 6962, with version 2 in RFC 9162), and the logs return signed certificate timestamps (SCTs) that are embedded in the certificate. The letsencrypt.org leaf carries them:
CT Precertificate SCTs:
Signed Certificate Timestamp:
Version : v1 (0x0)
Log ID : 4C:63:DC:98:E5:9C:1D:AB:88:F6:1E:8A:3D:DE:AE:8F:...
This isn’t optional for public sites. Chrome’s CT policy requires “all publicly-trusted TLS certificates” to be CT compliant, and a certificate with too few SCTs (two from distinct logs for a certificate valid 180 days or less, three for longer) “will simply fail to validate”.
The value for a domain owner is that every certificate for their names is searchable. crt.sh indexes the logs; https://crt.sh/?q=letsencrypt.org&output=json returned 200 with a long list of logged certificates, going back to entries such as one for radiantlock.letsencrypt.org from 2019, issued by the long-retired Let’s Encrypt Authority X3. Watching these logs for one’s own domain turns a misissued certificate into an alert. It also has a side effect: every hostname that ever got a public certificate, including staging and internal ones, is public, which is one of the ways attackers find forgotten subdomains.
DNSSEC, briefly
Everything above trusts DNS answers. DNSSEC (RFC 4033) signs DNS records so a validating resolver can tell they haven’t been forged, with a chain of DS records from the root down. A validating resolver sets the ad (authenticated data) flag:
$ dig +dnssec cloudflare.com A @1.1.1.1
;; flags: qr rd ra ad; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
cloudflare.com. 300 IN RRSIG A 13 2 300 20261111055831 20261109035831 34505 cloudflare.com. pb8l...
$ dig +dnssec google.com A @1.1.1.1
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
cloudflare.com is signed (algorithm 13 is ECDSA P-256 with SHA-256), and google.com isn’t. Browsers don’t validate DNSSEC themselves; the protection depends on the resolver. For a web app, its main practical value is protecting the records that other checks rely on (CAA, and the DNS that ACME validation uses) against forgery. It does nothing about records that are genuine but wrong, which is the next problem.
A record that outlived its target
The notes team once hosted their documentation on a static-site provider. The setup was ordinary: create a project on the provider, attach the custom domain docs.notes.test to it, and add a DNS record pointing the name at the provider:
docs.notes.test. 3600 IN CNAME notes-team.pages.test.
Later the docs moved, and someone deleted the provider project. Nobody removed the CNAME. The name still resolves to the provider, and the provider no longer has a site for it. Anyone with an account on the provider can now attach docs.notes.test to their own project. That’s a subdomain takeover, and MDN’s page on the attack describes the same two windows: a DNS record created before the provider has claimed the name, or a provider site removed before the DNS record.
The local simulation keeps every part of this off the real internet. Chrome’s resolver rules map docs.notes.test and *.pages.test to 127.0.0.1, standing in for the CNAME chain. A small server on port 5405 plays the provider: it serves whichever tenant has claimed the requested Host, has a control panel to add and remove custom domains, and answers anything unclaimed with a 404 page. The notes app on port 5408 (also https://notes.test) sets cookies the way many real apps do: a host-only session cookie, a second session cookie with the __Host- prefix from chapter 2, and two cookies scoped to the whole domain.
Set-Cookie: sid=real-session-for-alice; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: __Host-sid2=real-session-for-alice; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: prefs=theme:dark; Domain=notes.test; Secure; Path=/
Set-Cookie: csrf=Rk2n8sQ; Domain=notes.test; Secure; Path=/
The takeover, driven through Chrome:
=== 1. alice logs in to https://notes.test:5408
=== 2. docs.notes.test while the project exists
-> GET https://docs.notes.test:5405/
cookie: prefs=theme:dark; csrf=Rk2n8sQ
body: Notes docs How to use the notes app.
=== 3. the team deletes the hosting project, DNS record stays: {}
body: 404: There isn't a site here. If you own this domain, add it to a project.
=== 4. someone else claims the name on the provider: {"docs.notes.test": "attacker"}
-> GET https://docs.notes.test:5405/
cookie: prefs=theme:dark; csrf=Rk2n8sQ
console.log: origin: https://docs.notes.test:5405
console.log: document.cookie here: "prefs=theme:dark; csrf=Rk2n8sQ"
Step 3 is the fingerprint attackers scan for: a name that resolves to a hosting provider and returns that provider’s “no such site” page. Step 4 is the claim. From then on, the attacker’s page is served on the app’s own subdomain, over HTTPS. On a real provider the certificate isn’t an obstacle either: the provider requests one through ACME for the custom domain, and the http-01 challenge passes because the name really does point at the provider. CAA doesn’t help if the provider’s CA is on the allowed list, and DNSSEC doesn’t help because the record is genuine.
What the attacker’s page could do, from what was observed:
-
Read domain-wide cookies.
document.cookieondocs.notes.testreturnedprefsandcsrf, the two cookies set withDomain=notes.test. The host-onlysidand__Host-sid2weren’t sent to the subdomain at all. A CSRF token in a domain-wide cookie is exactly the double-submit pattern from chapter 3, and here it leaks. -
Plant cookies for the parent. The page wrote three cookies:
document.cookie = "sid=attacker-chosen; Domain=notes.test; Path=/; Secure"; document.cookie = "__Host-sid2=attacker-chosen; Domain=notes.test; Path=/; Secure"; document.cookie = "sid=attacker-longer-path; Domain=notes.test; Path=/whoami; Secure";Back on the app, the endpoint that echoes the received
Cookieheader showed:{"cookie_header_received": "sid=attacker-longer-path; sid=real-session-for-alice; __Host-sid2=real-session-for-alice; prefs=theme:dark; csrf=Rk2n8sQ; sid=attacker-chosen"}Two
sidcookies from the attacker arrived alongside the real one. The browser can’t tell them apart by name, and it sorts cookies with longer paths first (RFC 6265, section 5.4), so on/whoamithe attacker’s cookie came first. Werkzeug’s cookie parser returns the first value for a repeated name:parse_cookie("sid=attacker-longer-path; sid=real-session-for-alice").get("sid")givesattacker-longer-path. That’s cookie tossing, and it is the session fixation of chapter 2 delivered from a sibling subdomain. The__Host-sid2attempt was rejected by Chrome: a__Host-cookie may not carry aDomainattribute, so a subdomain can’t plant one for the parent. -
Act same-site.
docs.notes.testandnotes.testare the same site, soSameSite=LaxandStrictcookies go along on requests from the attacker’s page to the app, andSec-Fetch-Sitereportssame-site, notcross-site. Defenses from chapter 3 that relied on the attacker being cross-site no longer apply. A CSP that allowlists*.notes.test(chapter 5) or a CORS policy that trusts subdomains (chapter 6) would let this page in directly. -
Phish convincingly, on a real subdomain with a valid certificate.
Fixing it
The direct fix is operational, and MDN states it as an order of operations: when provisioning, claim the name on the provider first and create the DNS record last; when deprovisioning, remove the DNS record first. Both require an inventory of every DNS record and what it points to, checked regularly for names that resolve to a provider and return its “no such site” page. Watching certificate transparency logs for new certificates on one’s own subdomains catches the takeover when the provider issues its certificate.
Providers can close the gap on their side by requiring proof of ownership (a TXT record with a per-account token) before attaching a custom domain, and the ones that do make this attack much harder. It’s worth checking how a provider handles it before pointing a name at it.
The app can limit the damage when a takeover does happen, and the demo shows which choices mattered. Host-only cookies (no Domain attribute) aren’t sent to subdomains. The __Host- prefix guarantees host-only, Secure and Path=/, so a sibling can’t overwrite or shadow it; in the run, it was the one session cookie the attacker couldn’t plant. CSRF tokens tied to the server-side session, rather than in a domain-wide cookie, don’t leak. And CORS, CSP and postMessage checks that name exact origins rather than *.notes.test don’t automatically trust whatever ends up on a subdomain.
Where this leaves things
The connection is now encrypted end to end, the browser is told to never use anything else (HSTS, with preload as the next step), and pages can’t silently fall back to plain HTTP for scripts (upgrade-insecure-requests). CAA restricts who may issue certificates for the domain, certificate transparency makes every issuance visible, and the domain’s DNS records are inventoried so none of them point at something the team no longer controls. Session cookies use the __Host- prefix, so even a subdomain lost to someone else can’t plant or read them.
The mixed-content demo assumed the CDN serves the right file and only the transport needed protecting. TLS proves the script came from cdn.test; it says nothing about whether cdn.test is serving what it served yesterday. The same goes for the Python packages the app installs and the JavaScript its pages load. Chapter 12 turns to the code the app runs but didn’t write.