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 hashlib
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
|
|
||||||
|
|
@ -84,9 +85,24 @@ def _load_history(cell):
|
||||||
return history
|
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:
|
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
|
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}"
|
return f"{proto}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,28 @@ def test_records_payload_shape(client):
|
||||||
assert body["canonical_path"] == "/climate/testville/records"
|
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 --------------------------------------------------------------------
|
# --- 404s --------------------------------------------------------------------
|
||||||
|
|
||||||
def test_unknown_slug_is_404(client, counts):
|
def test_unknown_slug_is_404(client, counts):
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException, Request, Response
|
from fastapi import HTTPException, Request, Response
|
||||||
|
|
@ -56,13 +57,28 @@ _log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# --- helpers -------------------------------------------------------------
|
# --- 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:
|
def _origin(request: Request) -> str:
|
||||||
# x-forwarded-host takes precedence over host: when reached via backend's
|
# x-forwarded-host takes precedence over host: when reached via backend's
|
||||||
# internal proxy fallback (no Caddy in front -- see _proxy_to_frontend in
|
# 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
|
# backend/web/app.py), Host is the internal hop's own address, not the
|
||||||
# browser-facing one.
|
# 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
|
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}"
|
return f"{proto}://{host}"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,51 @@ def test_sitemap_lists_city_urls(client):
|
||||||
assert f"/climate/{SLUG}/records</loc>" in r.text
|
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):
|
def test_city_page_renders_structure(client):
|
||||||
r = client.get(f"{B}/climate/{SLUG}")
|
r = client.get(f"{B}/climate/{SLUG}")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue