theme.css loaded Fraunces, Inter and JetBrains Mono from Google Fonts. That sent every visitor's address to Google on every page load, and an instance with no internet drew in fallback type. All three families are now vendored under frontend/src/assets/fonts with every subset Google publishes for them, plus their OFL licences. The Content-Security-Policy no longer lists fonts.googleapis.com or fonts.gstatic.com. A new guard test fails if any stylesheet, page or component links a remote asset or preconnects to one, or if the CSP names another host. The theme.css header now describes the sheet as it is instead of how it used to read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
66 lines
2.9 KiB
Python
66 lines
2.9 KiB
Python
"""The app draws itself only from its own origin.
|
|
|
|
A font, stylesheet or script linked from another host hands that host every
|
|
visitor's address on every page load, sign-in page included, and when the host
|
|
is slow or blocked the page quietly renders in fallback type with nothing in the
|
|
console. Everything the frontend needs ships in the image instead, and the
|
|
Content-Security-Policy names no other host, so a remote link that slips back in
|
|
is refused by the browser as well as by this check.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pathlib
|
|
import re
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
FRONTEND = ROOT / "frontend"
|
|
|
|
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
|
HTML_COMMENT = re.compile(r"<!--.*?-->", re.S)
|
|
REMOTE = r"""(?:https?:)?//"""
|
|
REMOTE_REFS = [
|
|
re.compile(rf"""@import\s+(?:url\(\s*)?['"]?{REMOTE}""", re.I),
|
|
re.compile(rf"""url\(\s*['"]?{REMOTE}""", re.I),
|
|
re.compile(rf"""<link\b[^>]*\bhref\s*=\s*['"]{REMOTE}""", re.I),
|
|
re.compile(rf"""<script\b[^>]*\bsrc\s*=\s*['"]{REMOTE}""", re.I),
|
|
re.compile(r"""\brel\s*=\s*['"](?:preconnect|dns-prefetch)""", re.I),
|
|
]
|
|
|
|
|
|
def remote_refs(text: str) -> list[str]:
|
|
"""The remote asset references in a stylesheet, page or component."""
|
|
text = HTML_COMMENT.sub("", CSS_COMMENT.sub("", text))
|
|
return [m.group(0) for pattern in REMOTE_REFS for m in pattern.finditer(text)]
|
|
|
|
|
|
def frontend_sources() -> dict[str, str]:
|
|
paths = [FRONTEND / "index.html"]
|
|
for suffix in ("*.css", "*.vue", "*.html"):
|
|
paths += (FRONTEND / "src").rglob(suffix)
|
|
return {str(p.relative_to(ROOT)): p.read_text() for p in paths}
|
|
|
|
|
|
def test_no_frontend_source_links_a_remote_asset():
|
|
sources = frontend_sources()
|
|
assert len(sources) > 1, f"no frontend sources under {FRONTEND}"
|
|
found = {name: refs for name, text in sources.items() if (refs := remote_refs(text))}
|
|
assert found == {}, "vendor the asset and serve it from the app's own origin"
|
|
|
|
|
|
def test_the_content_security_policy_names_no_other_host():
|
|
app = (ROOT / "src" / "grimoire" / "app.py").read_text()
|
|
policy = re.search(r'"Content-Security-Policy",\s*((?:"[^"]*"\s*)+)\)', app)
|
|
assert policy, "the Content-Security-Policy header is no longer set in app.py"
|
|
assert "//" not in policy.group(1)
|
|
|
|
|
|
def test_the_guard_can_fail():
|
|
assert remote_refs("@import url('https://fonts.example.com/css2?family=X');")
|
|
assert remote_refs("@font-face { src: url(//cdn.example.com/x.woff2); }")
|
|
assert remote_refs('<link rel="stylesheet" href="https://cdn.example.com/x.css">')
|
|
assert remote_refs('<script src="https://cdn.example.com/x.js"></script>')
|
|
assert remote_refs('<link rel="preconnect" href="/">')
|
|
assert not remote_refs("/* no @import url('https://x.example.com') here */")
|
|
assert not remote_refs("@font-face { src: url(./inter-normal-latin.woff2); }")
|
|
assert not remote_refs('<a href="https://example.com">docs</a>')
|