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:
+43
-6
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user