Merge remote-tracking branch 'forgejo/qa/https-origin-live' into qa/frontend-qa-batch
This commit is contained in:
commit
45f4d18a4e
4 changed files with 101 additions and 2 deletions
|
|
@ -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}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue