The live-state grid renders player avatars as <img src="https://avatars.steamstatic.com/...">, but the CSP img-src directive was `'self' data:` — so the browser silently blocked every avatar load, leaving placeholder circles in place. The DB cache and Steam API path were both healthy; only the browser-side load was blocked. Use the wildcard *.steamstatic.com host-source rather than pinning a single hostname: Steam rotates avatars across steamcdn-a.akamaihd.net, avatars.akamai/cloudflare/fastly.steamstatic.com over time, and a single-hostname allowlist would re-break on the next shuffle. Test now pins img-src explicitly — the previous assertions only checked default-src/frame-ancestors/form-action, so a regression of this exact line would have silently passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
import hmac
|
|
import os
|
|
import secrets
|
|
|
|
import click
|
|
from flask import Flask, Response, g, jsonify, redirect, request, session
|
|
|
|
from l4d2web.auth import current_user, load_current_user
|
|
from l4d2web.cli import register_cli
|
|
from l4d2web.config import load_config
|
|
from l4d2web.db import init_db
|
|
from l4d2web.routes.blueprint_routes import bp as blueprint_bp
|
|
from l4d2web.routes.auth_routes import bp as auth_bp
|
|
from l4d2web.routes.auth_routes import reset_login_rate_limits
|
|
from l4d2web.routes.console_routes import bp as console_bp
|
|
from l4d2web.routes.files_routes import bp as files_bp
|
|
from l4d2web.routes.job_routes import bp as job_bp
|
|
from l4d2web.routes.log_routes import bp as log_bp
|
|
from l4d2web.routes.overlay_routes import bp as overlay_bp
|
|
from l4d2web.routes.page_routes import bp as page_bp
|
|
from l4d2web.routes.profile_routes import bp as profile_bp
|
|
from l4d2web.routes.profile_routes import reset_profile_password_rate_limits
|
|
from l4d2web.routes.server_routes import bp as server_bp
|
|
from l4d2web.routes.workshop_routes import bp as workshop_bp
|
|
from l4d2web.services.job_worker import (
|
|
recover_stale_jobs,
|
|
start_job_workers,
|
|
start_state_poller,
|
|
)
|
|
from l4d2web.services.live_state_poller import start_live_state_poller
|
|
|
|
|
|
def _in_flask_cli_context() -> bool:
|
|
return click.get_current_context(silent=True) is not None
|
|
|
|
|
|
def create_app(test_config: dict[str, object] | None = None) -> Flask:
|
|
app = Flask(__name__)
|
|
app.config.from_mapping(load_config())
|
|
app.config.update(
|
|
SESSION_COOKIE_HTTPONLY=True,
|
|
SESSION_COOKIE_SAMESITE="Lax",
|
|
CSRF_EXEMPT_PATHS={"/login", "/health"},
|
|
)
|
|
if test_config is not None:
|
|
app.config.update(test_config)
|
|
|
|
secret_key = app.config.get("SECRET_KEY")
|
|
if not app.config.get("TESTING") and (not secret_key or secret_key == "dev"):
|
|
raise RuntimeError("SECRET_KEY must be set to a non-default value outside of testing")
|
|
|
|
secure_env = os.getenv("SESSION_COOKIE_SECURE")
|
|
if secure_env is not None:
|
|
app.config["SESSION_COOKIE_SECURE"] = secure_env.lower() not in {"0", "false", "no"}
|
|
else:
|
|
app.config["SESSION_COOKIE_SECURE"] = not app.config.get("TESTING", False)
|
|
|
|
with app.app_context():
|
|
init_db()
|
|
|
|
@app.before_request
|
|
def csrf_protect() -> Response | None:
|
|
if "csrf_token" not in session:
|
|
session["csrf_token"] = secrets.token_hex(16)
|
|
|
|
if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
|
|
return None
|
|
|
|
if request.endpoint is None:
|
|
return None
|
|
|
|
if request.path in app.config["CSRF_EXEMPT_PATHS"]:
|
|
return None
|
|
|
|
token = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token")
|
|
expected = session.get("csrf_token")
|
|
if not token or not expected or not hmac.compare_digest(token, expected):
|
|
return Response("csrf token missing or invalid", status=400)
|
|
return None
|
|
|
|
@app.before_request
|
|
def assign_csp_nonce() -> None:
|
|
g.csp_nonce = secrets.token_urlsafe(16)
|
|
|
|
@app.after_request
|
|
def set_security_headers(response: Response) -> Response:
|
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
nonce = getattr(g, "csp_nonce", "")
|
|
# script-src nonce blocks inline XSS; style 'unsafe-inline' kept for
|
|
# htmx's auto-injected indicator styles. img/data: for SVG icons.
|
|
# *.steamstatic.com covers Steam avatar hosts (avatars.steamstatic.com
|
|
# plus the cloudflare/akamai/fastly mirrors they rotate through).
|
|
response.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; "
|
|
f"script-src 'self' 'nonce-{nonce}'; "
|
|
"style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data: https://*.steamstatic.com; "
|
|
"connect-src 'self'; "
|
|
"frame-ancestors 'none'; "
|
|
"base-uri 'self'; "
|
|
"form-action 'self'",
|
|
)
|
|
if not app.config.get("TESTING") and request.is_secure:
|
|
response.headers.setdefault(
|
|
"Strict-Transport-Security",
|
|
"max-age=31536000; includeSubDomains",
|
|
)
|
|
return response
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(_err):
|
|
return Response("not found", status=404, mimetype="text/plain")
|
|
|
|
@app.errorhandler(500)
|
|
def internal_error(_err):
|
|
return Response("internal server error", status=500, mimetype="text/plain")
|
|
|
|
app.before_request(load_current_user)
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(overlay_bp)
|
|
app.register_blueprint(files_bp)
|
|
app.register_blueprint(workshop_bp)
|
|
app.register_blueprint(blueprint_bp)
|
|
app.register_blueprint(server_bp)
|
|
app.register_blueprint(job_bp)
|
|
app.register_blueprint(log_bp)
|
|
app.register_blueprint(console_bp)
|
|
app.register_blueprint(page_bp)
|
|
app.register_blueprint(profile_bp)
|
|
register_cli(app)
|
|
if app.config.get("TESTING"):
|
|
reset_login_rate_limits()
|
|
reset_profile_password_rate_limits()
|
|
should_start_workers = (
|
|
app.config.get("JOB_WORKER_ENABLED")
|
|
and not app.config.get("TESTING")
|
|
and not _in_flask_cli_context()
|
|
)
|
|
if should_start_workers:
|
|
recover_stale_jobs()
|
|
start_job_workers(app)
|
|
start_state_poller(app)
|
|
if not app.config.get("TESTING"):
|
|
start_live_state_poller(app)
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return jsonify({"status": "ok"})
|
|
|
|
@app.get("/")
|
|
def root():
|
|
if current_user() is None:
|
|
return redirect("/login")
|
|
return redirect("/dashboard")
|
|
|
|
return app
|