Merge remote-tracking branch 'forgejo/qa/https-origin-live' into qa/frontend-qa-batch

This commit is contained in:
Emi Griffith 2026-07-24 16:09:44 -07:00
commit 45f4d18a4e
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