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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user