Critical: Add ownership checks to all order routes (IDOR fix). High: Redis rate limiting on auth endpoints, HMAC-signed internal service calls replacing header-presence-only checks, nh3 HTML sanitization on ghost_sync and product import, internal auth on market API endpoints, SHA-256 hashed OAuth grant/code tokens. Medium: SECRET_KEY production guard, AP signature enforcement, is_admin param removal, cart_sid validation, SSRF protection on remote actor fetch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
12 KiB
Python
317 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import secrets
|
|
from pathlib import Path
|
|
from typing import Callable, Awaitable, Sequence
|
|
|
|
from quart import Quart, request, g, redirect, send_from_directory
|
|
|
|
from shared.config import init_config, config, pretty
|
|
from shared.models import KV # ensure shared models imported
|
|
# Register all app model classes with SQLAlchemy so cross-domain
|
|
# relationship() string references resolve correctly.
|
|
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "account.models"):
|
|
try:
|
|
__import__(_mod)
|
|
except ImportError:
|
|
pass
|
|
from shared.log_config import configure_logging
|
|
from shared.events import EventProcessor
|
|
|
|
from shared.db.session import register_db
|
|
from shared.browser.app.middleware import register as register_middleware
|
|
from shared.browser.app.redis_cacher import register as register_redis
|
|
from shared.browser.app.csrf import protect
|
|
from shared.browser.app.errors import errors
|
|
|
|
from .jinja_setup import setup_jinja
|
|
from .user_loader import load_current_user
|
|
|
|
|
|
# Async init of config (runs once at import)
|
|
asyncio.run(init_config())
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
STATIC_DIR = str(BASE_DIR / "static")
|
|
TEMPLATE_DIR = str(BASE_DIR / "browser" / "templates")
|
|
|
|
|
|
def create_base_app(
|
|
name: str,
|
|
*,
|
|
context_fn: Callable[[], Awaitable[dict]] | None = None,
|
|
before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None,
|
|
domain_services_fn: Callable[[], None] | None = None,
|
|
) -> Quart:
|
|
"""
|
|
Create a Quart app with shared infrastructure.
|
|
|
|
Parameters
|
|
----------
|
|
name:
|
|
Application name (also used as CACHE_APP_PREFIX).
|
|
context_fn:
|
|
Async function returning a dict for template context.
|
|
Each app provides its own — the cart app queries locally,
|
|
while blog/market apps fetch via internal API.
|
|
If not provided, a minimal default context is used.
|
|
before_request_fns:
|
|
Extra before-request hooks (e.g. cart_loader for the cart app).
|
|
domain_services_fn:
|
|
Callable that registers domain services on the shared registry.
|
|
Each app provides its own — registering real impls for owned
|
|
domains and stubs (or real impls) for others.
|
|
"""
|
|
if domain_services_fn is not None:
|
|
domain_services_fn()
|
|
|
|
app = Quart(
|
|
name,
|
|
static_folder=STATIC_DIR,
|
|
static_url_path="/static",
|
|
template_folder=TEMPLATE_DIR,
|
|
root_path=str(BASE_DIR),
|
|
)
|
|
|
|
configure_logging(name)
|
|
|
|
secret_key = os.getenv("SECRET_KEY")
|
|
if not secret_key:
|
|
env = os.getenv("ENVIRONMENT", "development")
|
|
if env in ("production", "staging"):
|
|
raise RuntimeError("SECRET_KEY environment variable must be set in production")
|
|
secret_key = "dev-secret-key-change-me-777"
|
|
app.secret_key = secret_key
|
|
|
|
# Per-app first-party session cookie (no shared domain — avoids Safari ITP)
|
|
app.config["SESSION_COOKIE_NAME"] = f"{name}_session"
|
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
|
app.config["SESSION_COOKIE_SECURE"] = True
|
|
|
|
# Ghost / Redis config
|
|
app.config["GHOST_API_URL"] = os.getenv("GHOST_API_URL")
|
|
app.config["GHOST_PUBLIC_URL"] = os.getenv("GHOST_PUBLIC_URL")
|
|
app.config["GHOST_CONTENT_KEY"] = os.getenv("GHOST_CONTENT_API_KEY")
|
|
app.config["REDIS_URL"] = os.getenv("REDIS_URL")
|
|
|
|
# Cache app prefix for key namespacing
|
|
app.config["CACHE_APP_PREFIX"] = name
|
|
|
|
# --- infrastructure ---
|
|
register_middleware(app)
|
|
register_db(app)
|
|
register_redis(app)
|
|
setup_jinja(app)
|
|
errors(app)
|
|
|
|
# Auto-register OAuth client blueprint for non-account apps
|
|
# (account is the OAuth authorization server)
|
|
if name != "account":
|
|
from shared.infrastructure.oauth import create_oauth_blueprint
|
|
app.register_blueprint(create_oauth_blueprint(name))
|
|
|
|
# Auto-register ActivityPub blueprint for AP-enabled apps
|
|
from shared.infrastructure.activitypub import AP_APPS
|
|
if name in AP_APPS:
|
|
from shared.infrastructure.activitypub import create_activitypub_blueprint
|
|
app.register_blueprint(create_activitypub_blueprint(name))
|
|
|
|
# Auto-register per-app social blueprint (not federation — it has its own)
|
|
if name in AP_APPS and name != "federation":
|
|
from shared.infrastructure.ap_social import create_ap_social_blueprint
|
|
app.register_blueprint(create_ap_social_blueprint(name))
|
|
|
|
# --- device id (all apps, including account) ---
|
|
_did_cookie = f"{name}_did"
|
|
|
|
@app.before_request
|
|
async def _init_device_id():
|
|
did = request.cookies.get(_did_cookie)
|
|
if did:
|
|
g.device_id = did
|
|
g._new_device_id = False
|
|
else:
|
|
g.device_id = secrets.token_urlsafe(32)
|
|
g._new_device_id = True
|
|
|
|
@app.after_request
|
|
async def _set_device_cookie(response):
|
|
if getattr(g, "_new_device_id", False):
|
|
response.set_cookie(
|
|
_did_cookie, g.device_id,
|
|
max_age=30 * 24 * 3600,
|
|
secure=True, samesite="Lax", httponly=True,
|
|
)
|
|
return response
|
|
|
|
# --- before-request hooks ---
|
|
@app.before_request
|
|
async def _route_log():
|
|
g.root = request.headers.get("x-forwarded-prefix", "/")
|
|
g.scheme = request.scheme
|
|
g.host = request.host
|
|
|
|
# Auth state check via grant verification + silent OAuth handshake
|
|
# MUST run before _load_user so stale sessions are cleared first
|
|
if name != "account":
|
|
@app.before_request
|
|
async def _check_auth_state():
|
|
from quart import session as qs
|
|
from urllib.parse import quote as _quote
|
|
if request.path.startswith(("/auth/", "/static/", "/.well-known/", "/users/", "/nodeinfo/", "/internal/")):
|
|
return
|
|
|
|
uid = qs.get("uid")
|
|
grant_token = qs.get("grant_token")
|
|
|
|
from shared.infrastructure.auth_redis import get_auth_redis
|
|
try:
|
|
auth_redis = await get_auth_redis()
|
|
except Exception:
|
|
auth_redis = None
|
|
|
|
# Case 1: logged in — verify grant still valid (direct DB, cached)
|
|
if uid and not grant_token:
|
|
# Legacy session without grant token — clear it
|
|
qs.pop("uid", None)
|
|
qs.pop("cart_sid", None)
|
|
g.user = None
|
|
uid = None
|
|
|
|
if uid and grant_token:
|
|
cache_key = f"grant:{grant_token}"
|
|
if auth_redis:
|
|
# Quick check: if did_auth was cleared (logout), skip cache
|
|
device_id = g.device_id
|
|
did_auth_present = await auth_redis.get(f"did_auth:{device_id}") if device_id else True
|
|
cached = await auth_redis.get(cache_key)
|
|
if cached == b"ok" and did_auth_present:
|
|
return
|
|
if cached == b"revoked":
|
|
qs.pop("uid", None)
|
|
qs.pop("grant_token", None)
|
|
qs.pop("cart_sid", None)
|
|
g.user = None
|
|
return
|
|
|
|
from sqlalchemy import select
|
|
from shared.db.session import get_account_session
|
|
from shared.models.oauth_grant import OAuthGrant, hash_token
|
|
try:
|
|
token_h = hash_token(grant_token)
|
|
async with get_account_session() as s:
|
|
grant = await s.scalar(
|
|
select(OAuthGrant).where(
|
|
(OAuthGrant.token_hash == token_h) | (OAuthGrant.token == grant_token)
|
|
)
|
|
)
|
|
valid = grant is not None and grant.revoked_at is None
|
|
except Exception:
|
|
valid = False # DB error — treat as invalid
|
|
|
|
if auth_redis:
|
|
await auth_redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
|
|
if not valid:
|
|
qs.pop("uid", None)
|
|
qs.pop("grant_token", None)
|
|
qs.pop("cart_sid", None)
|
|
g.user = None
|
|
return
|
|
|
|
# Case 2: not logged in — prompt=none OAuth (GET, non-HTMX only)
|
|
if not uid and request.method == "GET":
|
|
if request.headers.get("HX-Request"):
|
|
return
|
|
import time as _time
|
|
now = _time.time()
|
|
pnone_at = qs.get("_pnone_at")
|
|
device_id = g.device_id
|
|
|
|
# Check if account signalled a login after we cached "not logged in"
|
|
# (blog_did == account_did — same value set during OAuth callback)
|
|
if device_id and auth_redis and pnone_at:
|
|
auth_ts = await auth_redis.get(f"did_auth:{device_id}")
|
|
if auth_ts:
|
|
try:
|
|
if float(auth_ts) > pnone_at:
|
|
qs.pop("_pnone_at", None)
|
|
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
if pnone_at and (now - pnone_at) < 300:
|
|
return
|
|
if device_id and auth_redis:
|
|
cached = await auth_redis.get(f"prompt:{name}:{device_id}")
|
|
if cached == b"none":
|
|
return
|
|
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
|
|
|
|
@app.before_request
|
|
async def _load_user():
|
|
await load_current_user()
|
|
|
|
# Register any app-specific before-request hooks (e.g. cart loader)
|
|
if before_request_fns:
|
|
for fn in before_request_fns:
|
|
app.before_request(fn)
|
|
|
|
@app.before_request
|
|
async def _csrf_protect():
|
|
await protect()
|
|
|
|
# --- after-request hooks ---
|
|
# Clear old shared-domain session cookie (migration from .rose-ash.com)
|
|
@app.after_request
|
|
async def _clear_old_shared_cookie(response):
|
|
if request.cookies.get("blog_session"):
|
|
response.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
|
return response
|
|
|
|
@app.after_request
|
|
async def _add_hx_preserve_search_header(response):
|
|
value = request.headers.get("X-Search")
|
|
if value is not None:
|
|
response.headers["HX-Preserve-Search"] = value
|
|
return response
|
|
|
|
# --- context processor ---
|
|
if context_fn is not None:
|
|
@app.context_processor
|
|
async def _inject_base():
|
|
return await context_fn()
|
|
else:
|
|
# Minimal fallback (no cart, no menu_items)
|
|
from .context import base_context
|
|
|
|
@app.context_processor
|
|
async def _inject_base():
|
|
return await base_context()
|
|
|
|
# --- event processor ---
|
|
_event_processor = EventProcessor(app_name=name)
|
|
|
|
# --- startup ---
|
|
@app.before_serving
|
|
async def _startup():
|
|
from shared.events.handlers import register_shared_handlers
|
|
register_shared_handlers()
|
|
await init_config()
|
|
print(pretty())
|
|
await _event_processor.start()
|
|
|
|
@app.after_serving
|
|
async def _stop_event_processor():
|
|
await _event_processor.stop()
|
|
from shared.infrastructure.auth_redis import close_auth_redis
|
|
await close_auth_redis()
|
|
|
|
# --- favicon ---
|
|
@app.get("/favicon.ico")
|
|
async def favicon():
|
|
return await send_from_directory(STATIC_DIR, "favicon.ico")
|
|
|
|
return app
|