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
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
@@ -5,14 +6,14 @@ from datetime import date
|
|||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .generator import generation_exhausted, run_generation, today_local
|
from .generator import generation_exhausted, run_generation, today_local
|
||||||
from .templates import PAGE_TEMPLATE
|
from .templates import CSS, PAGE_TEMPLATE
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -64,6 +65,42 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="Wiki Jokes", lifespan=lifespan)
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index():
|
def index():
|
||||||
@@ -80,7 +117,7 @@ def index():
|
|||||||
status_code=503,
|
status_code=503,
|
||||||
)
|
)
|
||||||
items = "\n".join(
|
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 = (
|
stale_note = (
|
||||||
'<p class="stale">⚠️ Today\'s jokes are still being prepared — '
|
'<p class="stale">⚠️ Today\'s jokes are still being prepared — '
|
||||||
@@ -92,8 +129,8 @@ def index():
|
|||||||
day=batch["day"],
|
day=batch["day"],
|
||||||
stale_note=stale_note,
|
stale_note=stale_note,
|
||||||
jokes=items,
|
jokes=items,
|
||||||
article_title=batch["article_title"],
|
article_title=html.escape(batch["article_title"]),
|
||||||
article_url=batch["article_url"],
|
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>
|
PAGE_TEMPLATE = """<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Wiki Jokes — {day}</title>
|
<title>Wiki Jokes — {day}</title>
|
||||||
<style>
|
<link rel="stylesheet" href="/styles.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: "😄 "; }}
|
|
||||||
footer {{ margin-top: 2rem; font-size: .9rem; color: #888; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -47,7 +49,7 @@ PAGE_TEMPLATE = """<!DOCTYPE html>
|
|||||||
</ul>
|
</ul>
|
||||||
<footer>
|
<footer>
|
||||||
Inspired by today's Wikipedia featured article:
|
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>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user