Emit https origin for the public host in canonical/og/sitemap URLs

The SSR frontend's _origin() (canonical, og:url, og:image, robots, sitemap
<loc>) and the backend content API's _origin() (the jsonld url folded into
each payload) both trusted x-forwarded-proto / request.url.scheme. Behind
Caddy — which terminates TLS and reverse-proxies plain HTTP — those read
"http", so on the HTTPS-only public site every absolute URL emitted http://,
which 308-redirects to https://: canonicals self-conflict and the sitemap
lists redirecting URLs.

Take the scheme from the configured public origin (THERMOGRAPH_BASE_URL, set
per-host in the deploy env) when the request arrives on that host, so prod and
beta emit https. localhost and LAN dev never match the public host and keep the
observed scheme, so plain-HTTP development is unchanged; the frontend keeps its
X-Forwarded-Host precedence for the proxy-fallback path.

Tests assert canonical/og:url/og:image, robots Sitemap and every sitemap <loc>
are https for the public host (and via X-Forwarded-Host), that a LAN host stays
http, and that the backend payload's jsonld url is likewise https.
This commit is contained in:
Emi Griffith 2026-07-24 16:04:42 -07:00
parent 248eeee110
commit 72e141afd3
4 changed files with 101 additions and 2 deletions

View file

@ -6,6 +6,7 @@ pattern web/app.py's own endpoints use.
"""
import hashlib
import os
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request, Response
@ -84,9 +85,24 @@ def _load_history(cell):
return history
# The configured public origin is the source of truth for the scheme. Caddy
# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and
# request.url.scheme) read "http" even though the public site is HTTPS-only --
# and this origin is folded into the jsonld "url" the payloads carry. Trusting
# the forwarded scheme makes that url (and the canonical/og the frontend builds
# from the same origin it forwards here) emit http://, which 308-redirects to
# https://. So a request that arrives on the configured public host is answered
# with the configured public scheme; localhost/LAN dev never matches that host
# and keeps the observed scheme, leaving plain-HTTP development unaffected.
_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org"))
def _origin(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc:
proto = _PUBLIC.scheme
else:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{proto}://{host}"

View file

@ -118,6 +118,28 @@ def test_records_payload_shape(client):
assert body["canonical_path"] == "/climate/testville/records"
# --- https origin (jsonld url) -----------------------------------------------
def test_public_host_jsonld_url_is_https(client):
"""The public site is HTTPS-only behind Caddy, which proxies plain HTTP, so
x-forwarded-proto reads "http". A request on the configured public host
(THERMOGRAPH_BASE_URL defaults to https://thermograph.org) must still fold an
https:// origin into the payload's jsonld url; otherwise the frontend renders
an http:// canonical/JSON-LD that 308-redirects."""
import json as _json
hdr = {"host": "thermograph.org", "x-forwarded-proto": "http"}
for path in ("city/testville", "city/testville/records"):
s = _json.dumps(client.get(f"{BASE}/{path}", headers=hdr).json())
assert "https://thermograph.org/thermograph/climate/testville" in s
assert "http://thermograph.org" not in s
def test_non_public_host_jsonld_keeps_forwarded_scheme(client):
hdr = {"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}
body = client.get(f"{BASE}/city/testville", headers=hdr).json()
assert body["jsonld"]["@graph"][0]["url"].startswith("http://192.168.1.10:8000/")
# --- 404s --------------------------------------------------------------------
def test_unknown_slug_is_404(client, counts):

View file

@ -13,6 +13,7 @@ import hashlib
import json
import logging
import os
from urllib.parse import urlsplit
import httpx
from fastapi import HTTPException, Request, Response
@ -56,13 +57,28 @@ _log = logging.getLogger(__name__)
# --- helpers -------------------------------------------------------------
# The configured public origin is the source of truth for the scheme. Caddy
# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and
# request.url.scheme) read "http" even though the public site is HTTPS-only.
# Trusting that scheme makes every canonical / og:url / og:image / sitemap <loc>
# emit http://, which 308-redirects to https:// -- self-conflicting canonicals
# and a sitemap full of redirecting URLs. So a request that arrives on the
# configured public host is answered with the configured public scheme (https on
# prod/beta, both of which set THERMOGRAPH_BASE_URL per host); localhost/LAN dev
# never matches it and keeps the observed scheme, so plain-HTTP dev is unchanged.
_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org"))
def _origin(request: Request) -> str:
# x-forwarded-host takes precedence over host: when reached via backend's
# internal proxy fallback (no Caddy in front -- see _proxy_to_frontend in
# backend/web/app.py), Host is the internal hop's own address, not the
# browser-facing one.
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc:
proto = _PUBLIC.scheme
else:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
return f"{proto}://{host}"

View file

@ -29,6 +29,51 @@ def test_sitemap_lists_city_urls(client):
assert f"/climate/{SLUG}/records</loc>" in r.text
# The public site is HTTPS-only behind Caddy, which terminates TLS and proxies
# plain HTTP -- so x-forwarded-proto / request.url.scheme read "http". A request
# arriving on the configured public host (THERMOGRAPH_BASE_URL defaults to
# https://thermograph.org in the test env) must still emit https:// in every
# frontend-built absolute URL, or canonicals self-conflict and the sitemap lists
# redirecting URLs.
_PUBLIC = {"host": "thermograph.org", "x-forwarded-proto": "http"}
def test_public_host_canonical_and_og_are_https(client):
b = client.get(f"{B}/climate/{SLUG}", headers=_PUBLIC).text
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
assert f'<meta property="og:url" content="https://thermograph.org{B}/climate/{SLUG}"' in b
assert 'property="og:image" content="https://thermograph.org' in b
def test_public_host_sitemap_locs_are_https(client):
body = client.get(f"{B}/sitemap.xml", headers=_PUBLIC).text
assert f"<loc>https://thermograph.org{B}/climate/{SLUG}</loc>" in body
assert "<loc>http://thermograph.org" not in body
def test_public_host_robots_sitemap_is_https(client):
body = client.get(f"{B}/robots.txt", headers=_PUBLIC).text
assert f"Sitemap: https://thermograph.org{B}/sitemap.xml" in body
def test_non_public_host_keeps_forwarded_scheme(client):
# A LAN/dev host never matches the configured public host, so the observed
# (plain-http) scheme is preserved -- http development is unaffected.
b = client.get(f"{B}/climate/{SLUG}",
headers={"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}).text
assert f'<link rel="canonical" href="http://192.168.1.10:8000{B}/climate/{SLUG}"' in b
def test_proxied_forwarded_host_still_https(client):
# Reached via backend's proxy fallback: Host is the internal hop, the real
# browser host arrives in X-Forwarded-Host -- which still resolves to the
# public host and must go https.
b = client.get(f"{B}/climate/{SLUG}",
headers={"host": "frontend:8080", "x-forwarded-host": "thermograph.org",
"x-forwarded-proto": "http"}).text
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
def test_city_page_renders_structure(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200