A Certificate Is Not HTTPS: Debugging a Subdomain Reported as “Uncertified”
A Certificate Is Not HTTPS: Debugging a Subdomain Reported as “Uncertified”
Search
Ask the AI

A Certificate Is Not HTTPS: Debugging a Subdomain Reported as “Uncertified”

Someone told me one of my subdomains “has no SSL certificate”. I checked. The certificate was fine — a wildcard covering it, with two months left to run.

But that investigation did surface a problem, and a worse one than a missing certificate: that subdomain’s login page was reachable over plain HTTP, with a password field on it and no redirect anywhere.

Having a certificate and enforcing HTTPS are two different things. This is about checking them separately, and about two things that sent me the wrong way while doing it.

Why a wildcard certificate looks absent

The confusion has a simple source. Run certbot certificates on the server, or look for a domain in a control panel, and a wildcard certificate does not appear once per subdomain. There is one record, with *.example.com in its SAN list.

The more subdomains you have, the more misleading that is — eight subdomains and a single row in the list reads intuitively as “seven are unconfigured”.

The correct check is to ask the server for its certificate and read the SAN list:

echo | openssl s_client -servername sub.example.com \
  -connect sub.example.com:443 2>/dev/null \
  | openssl x509 -noout -subject -dates -ext subjectAltName

-servername is the part that matters. Modern servers host many sites on one IP and choose which certificate to send based on the SNI extension in the handshake. Omit it and you may get the default site’s certificate, then draw a wrong conclusion from it.

What I got back was DNS:*.example.com, DNS:example.com — a wildcard plus the bare domain, covering every first-level subdomain. The one that supposedly had no certificate had been in there all along.

One boundary worth knowing: a wildcard covers exactly one level. *.example.com matches a.example.com but not a.b.example.com. Deeper names need their own certificate.

Trap 1: when openssl cannot connect, distrust the result

My first run of that command reported handshake failure for all four hostnames.

I came close to writing “certificates are broken everywhere”. What stopped me was one of those hostnames — a site I had been using all evening. It could not possibly have no certificate.

Retrying with curl -v returned certificate details for all four. The problem was that my environment restricted raw socket connections, so openssl s_client could not get out while curl’s path could.

When a diagnostic tool reports the same failure for every target, suspect the tool first. Real faults are rarely that tidy. That rule saved me again later in the same investigation.

Trap 2: DNS enumeration is useless behind a transparent proxy

With certificates cleared, I wanted to check every subdomain. The plan was to probe which ones existed, then examine each.

So I resolved a list of common prefixes: www, admin, mail, api, cdn, test, git, and so on. Every single one resolved. Including several I had invented on the spot.

The addresses came back in 198.18.0.x. That range is reserved for network device benchmarking and has no business appearing in public DNS answers.

The explanation is a transparent proxy on my route doing fake-IP: it intercepts every DNS query, synthesises an address in a reserved range regardless of whether the name exists, and routes by hostname when the connection is actually made. On such a network, a successful DNS lookup says nothing about whether a name exists.

New criterion: ignore DNS, look at the HTTPS response. Real services answer 200 or 302; nonexistent ones give response code 000 because nothing connects. Four real sites surfaced that way, and the dozen other “successful” lookups were phantoms.

The actual problem

Having examined all four properly, the problem turned up in a dimension I had not set out to check.

Two of them do not redirect http:// to https://:

http://www...      301 -> https://www...     correct
http://admin...    301 -> https://admin...   correct
http://pivot...    200                       serves content directly
http://prairie...  302 -> http://prairie/... redirects, still http

The one returning 200 is webmail. Fetching the body:

curl -s http://pivot.example.com/ | grep -c 'type="password"'
# 1

A mail login page served over plaintext HTTP, with a password field. Anyone who types http://, follows an old link, or opens a bookmark saved with the http scheme submits their password in the clear.

The other case is subtler: it does redirect, but to another http address, landing on a two-factor page with two credential fields. The presence of a redirect makes it easy to assume the configuration is right.

How both were missed

Because the forced redirect on this setup is done by the origin’s nginx, and the rule lives only in the main site’s server block. Webmail and the file service were added later as separate services with their own configs, and nobody thought to add it there.

This class of gap has a signature: it accumulates as services are added, and it never produces an error. Each service works correctly when examined alone; only a side-by-side comparison reveals the inconsistency.

And side-by-side comparison is precisely the step that gets skipped, because investigations start from “something is broken” and attention naturally narrows to that one thing.

Fixing it

If the sites sit behind a CDN, the least effort is that CDN’s “always use HTTPS” switch. It applies at the edge, covers the whole zone including subdomains added later, requires no origin changes, and carries no redirect-loop risk.

Doing it at the origin works too, with one caveat: do not test $scheme. Behind a CDN or a tunnel, the origin always sees the internal connection’s scheme; the visitor’s scheme is in X-Forwarded-Proto:

if ($http_x_forwarded_proto = "http") {
    return 301 https://$host$request_uri;
}

Testing $scheme instead gives you either a rule that never fires or an infinite redirect, depending on what the origin itself listens on.

Even with the redirect, the first request is still plaintext

Redirecting HTTP to HTTPS with a 301 did remove that plaintext password field. One thing it did not solve: when a user types the domain in the address bar, the browser still sends an HTTP request by default, and the redirect only happens after the server receives it. So the first request of every visit is plaintext, and an attacker in the middle can intervene before the redirect ever occurs.

What closes that gap is HSTS — a response header telling the browser “from now on, always use HTTPS for this domain; do not try HTTP first”:

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

The browser remembers this for max-age seconds. During that window, even if the user explicitly types http://, the browser rewrites it to HTTPS before issuing the request, so no plaintext request is ever produced.

Three things to be careful about:

  • It only takes effect on HTTPS responses. Browsers ignore this header on HTTP responses — otherwise an attacker could forge it. So it belongs in the port 443 server block; adding it to the port 80 redirect block does nothing at all.
  • includeSubDomains covers every subdomain, including ones that do not exist yet. If some subdomain is currently HTTP-only (an internal tool, a legacy service), adding this parameter makes it completely unreachable — and since the client has already memorised the rule, reverting the server configuration does not restore access immediately. Confirm every subdomain supports HTTPS before enabling it.
  • preload is effectively irreversible. Once submitted to the browser preload list, the rule is compiled into the browser itself, and removal takes several release cycles. Do not add it unless you are certain the domain will never serve anything but HTTPS.

The safe rollout is to start with a short max-age=300, watch for a few days to confirm no subdomain is cut off, then raise it toward a year. This parameter is “how long the user’s browser remembers,” not “how long the server keeps it” — set it wrong and you cannot unilaterally undo it.

Mixed content: the page is HTTPS, its resources are not

One more layer sits beyond what an edge redirect can fix. The page itself loads over HTTPS, but if the HTML hardcodes images, scripts or stylesheets with http:// URLs, the browser either refuses to load them (scripts and styles) or marks the page as not secure (images).

This is especially common on systems like WordPress where content lives in a database: image URLs inserted while the site was still on HTTP remain in article bodies as absolute URLs, entirely untouched by any nginx change.

Find them through the browser console’s Mixed Content warnings, or by grepping the served content directly:

curl -s https://example.com/ | grep -o 'http://[^"'"'"']*' | sort -u | head

Note that only links pointing at your own site need converting. An ordinary hyperlink to an external site (<a href="http://...">) is not mixed content — it is a link waiting to be clicked, not something loaded into the current page. What needs fixing are subresource references: src attributes and href on stylesheets.

And a reminder sharing a root with the two traps above: verify with a randomised query parameter. A CDN caches old HTTP responses and old 301s alike, so testing with a plain URL shows pre-change behaviour and readily produces the false conclusion that nothing took effect.

A minimal checklist

The lesson here is that “does it have a certificate” is one question out of four. For every public hostname, confirm separately:

Does the certificate cover this hostname. Fetch it with -servername and read the SAN list, rather than counting rows in a panel.

Does http:// force a redirect to https://. This is independent of the certificate and it is the only one of the four that leads to credentials crossing the network in plaintext. One line: curl -sI http://host/, and check for a 301 or 308 pointing at https.

Is the redirect target actually https. I hit a 302 pointing at another http URL. Checking only “is there a redirect” misses it.

Is HSTS set. Forced redirects only protect what happens after the first request; that first plaintext request still happened. HSTS makes the browser remember the domain is HTTPS-only — but it belongs after forced redirects are working, not before.

Of the four, the second is the one most likely to be skipped, because it is not a feature of any service and nobody tests for it during acceptance.

Leave a Reply

Scroll down