Add security headers, external CSS, HTML escaping

- Middleware sets CSP (default-src 'none'; style-src 'self'), HSTS,
  X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
  Permissions-Policy on every response
- Inline <style> moved to /styles.css so CSP needs no unsafe-inline
- /favicon.ico returns a 1x1 GIF (kills 404 noise)
- Escape LLM jokes, article title, and URL before HTML rendering (XSS fix)
This commit is contained in:
2026-09-29 20:41:16 +00:00
parent b8571b29cd
commit 7e60343ed5
2 changed files with 77 additions and 38 deletions
+43 -6
View File
@@ -1,3 +1,4 @@
import html
import logging
from contextlib import asynccontextmanager
from datetime import date
@@ -5,14 +6,14 @@ from datetime import date
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from zoneinfo import ZoneInfo
from . import db
from .config import settings
from .generator import generation_exhausted, run_generation, today_local
from .templates import PAGE_TEMPLATE
from .templates import CSS, PAGE_TEMPLATE
logging.basicConfig(
level=logging.INFO,
@@ -64,6 +65,42 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Wiki Jokes", lifespan=lifespan)
SECURITY_HEADERS = {
"Content-Security-Policy": (
"default-src 'none'; style-src 'self'; img-src 'self' data:; "
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
),
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
for key, value in SECURITY_HEADERS.items():
response.headers[key] = value
return response
@app.get("/styles.css")
def styles():
return Response(content=CSS, media_type="text/css")
@app.get("/favicon.ico")
def favicon():
# Tiny 1x1 transparent GIF; stops 404 noise and scanner probing.
import base64
return Response(
content=base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
media_type="image/gif",
)
@app.get("/", response_class=HTMLResponse)
def index():
@@ -80,7 +117,7 @@ def index():
status_code=503,
)
items = "\n".join(
f' <li class="joke">{j}</li>' for j in batch["jokes"]
f' <li class="joke">{html.escape(j)}</li>' for j in batch["jokes"]
)
stale_note = (
'<p class="stale">⚠️ Today\'s jokes are still being prepared — '
@@ -92,8 +129,8 @@ def index():
day=batch["day"],
stale_note=stale_note,
jokes=items,
article_title=batch["article_title"],
article_url=batch["article_url"],
article_title=html.escape(batch["article_title"]),
article_url=html.escape(batch["article_url"], quote=True),
)
+34 -32
View File
@@ -1,40 +1,42 @@
CSS = """
:root { color-scheme: light dark; }
body {
font-family: Georgia, 'Times New Roman', serif;
max-width: 720px; margin: 0 auto; padding: 2rem 1rem;
background: #fdfcf8; color: #222;
}
@media (prefers-color-scheme: dark) {
body { background: #1a1a1e; color: #e8e6e1; }
a { color: #8ab4f8; }
.joke { background: #26262c; color: #eceae4; border-color: #3a3a42; }
header h1 { color: #f0ede6; }
}
header h1 { font-size: 2rem; margin-bottom: .25rem; }
.date { color: #777; margin-top: 0; }
.stale {
background: #fff3cd; color: #665100; padding: .5rem .75rem;
border-radius: 6px; font-size: .9rem;
}
@media (prefers-color-scheme: dark) {
.stale { background: #4a3c0a; color: #ffd970; }
}
ul.jokes { list-style: none; padding: 0; }
li.joke {
background: #fff; color: #222; border: 1px solid #e2ddd2; border-radius: 10px;
padding: 1rem 1.25rem; margin: .9rem 0; font-size: 1.15rem;
line-height: 1.5; box-shadow: 0 1px 3px rgba(0,0,0,.06);
}
li.joke::before { content: "\\1F604 "; }
footer { margin-top: 2rem; font-size: .9rem; color: #888; }
"""
PAGE_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wiki Jokes — {day}</title>
<style>
:root {{ color-scheme: light dark; }}
body {{
font-family: Georgia, 'Times New Roman', serif;
max-width: 720px; margin: 0 auto; padding: 2rem 1rem;
background: #fdfcf8; color: #222;
}}
@media (prefers-color-scheme: dark) {{
body {{ background: #1a1a1e; color: #e8e6e1; }}
a {{ color: #8ab4f8; }}
.joke {{ background: #26262c; color: #eceae4; border-color: #3a3a42; }}
header h1 {{ color: #f0ede6; }}
}}
header h1 {{ font-size: 2rem; margin-bottom: .25rem; }}
.date {{ color: #777; margin-top: 0; }}
.stale {{
background: #fff3cd; color: #665100; padding: .5rem .75rem;
border-radius: 6px; font-size: .9rem;
}}
@media (prefers-color-scheme: dark) {{
.stale {{ background: #4a3c0a; color: #ffd970; }}
}}
ul.jokes {{ list-style: none; padding: 0; }}
li.joke {{
background: #fff; color: #222; border: 1px solid #e2ddd2; border-radius: 10px;
padding: 1rem 1.25rem; margin: .9rem 0; font-size: 1.15rem;
line-height: 1.5; box-shadow: 0 1px 3px rgba(0,0,0,.06);
}}
li.joke::before {{ content: "😄 "; }}
footer {{ margin-top: 2rem; font-size: .9rem; color: #888; }}
</style>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header>
@@ -47,7 +49,7 @@ PAGE_TEMPLATE = """<!DOCTYPE html>
</ul>
<footer>
Inspired by today's Wikipedia featured article:
<a href="{article_url}" rel="noopener">{article_title}</a>
<a href="{article_url}" rel="noopener noreferrer">{article_title}</a>
</footer>
</body>
</html>