Compare commits
15 Commits
1e5e3252c6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6436fac3fc | |||
| fad417697a | |||
| 5aad7fd48f | |||
| a65ff61da6 | |||
| 45271e15ac | |||
| 81cd4eb803 | |||
| 0adecb908a | |||
| fbd822e8dd | |||
| 07d36fe73c | |||
| 62de2685d7 | |||
| daeef6f3ed | |||
| f9f90d408e | |||
| f4dc6c266d | |||
| 149c6580f4 | |||
| cd3b2e0694 |
@@ -51,7 +51,7 @@ MAGIC_LINK_TTL_MIN=15
|
||||
# --- Public URL for link construction --------------------------------------
|
||||
# Absolute base URL (scheme+host+port) used to build outbound links such as
|
||||
# the magic-link auth email. Override for production.
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8000
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8080
|
||||
|
||||
# --- Build metadata ---------------------------------------------------------
|
||||
# Injected at Docker build time. Surfaced by /healthz. Optional in dev.
|
||||
|
||||
36
.gitea/workflows/build-image.yml
Normal file
36
.gitea/workflows/build-image.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build and Push Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
container: docker.io/catthehacker/ubuntu:act-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.sneakygeek.net
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.sneakygeek.net/ptarrant/chicken_babies_site
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-,format=short
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_COMMIT_SHA=${{ gitea.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=git.sneakygeek.net/ptarrant/chicken_babies_site:buildcache
|
||||
cache-to: type=registry,ref=git.sneakygeek.net/ptarrant/chicken_babies_site:buildcache,mode=max
|
||||
37
Dockerfile
37
Dockerfile
@@ -77,14 +77,35 @@ COPY app /app/app
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA}
|
||||
|
||||
EXPOSE 8000
|
||||
# Phase 6 hardening: create a dedicated non-root user with a stable
|
||||
# UID/GID so host bind mounts (data/, media/) have predictable
|
||||
# ownership. We pin 10001 because values < 1000 collide with Debian
|
||||
# system accounts on some host distros.
|
||||
RUN groupadd -g 10001 app \
|
||||
&& useradd -m -u 10001 -g app app \
|
||||
&& chown -R app:app /app /opt/venv
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Phase 6 healthcheck: hits /healthz over the loopback with stdlib
|
||||
# urllib so we don't have to install curl/wget in the runtime image.
|
||||
# Intervals tuned for a small VM: probe every 30s, give a fresh
|
||||
# container 10s to finish startup before the first probe counts.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2); sys.exit(0)" || exit 1
|
||||
|
||||
# Run Uvicorn directly. --proxy-headers + --forwarded-allow-ips make
|
||||
# Starlette's ProxyHeadersMiddleware trust X-Forwarded-* only from the
|
||||
# listed peer IPs (Caddy on the host). No --reload: this is a prod-shape
|
||||
# image; local hot-reload is a dev concern and runs outside Docker.
|
||||
CMD ["uvicorn", "app.main:app", \
|
||||
"--host", "0.0.0.0", \
|
||||
"--port", "8000", \
|
||||
"--proxy-headers", \
|
||||
"--forwarded-allow-ips", "127.0.0.1"]
|
||||
# listed peer IPs. The trusted-IP value is env-driven so the image
|
||||
# can be reused across topologies:
|
||||
# - local: defaults to 127.0.0.1 (when running uvicorn on the host)
|
||||
# - docker/compose behind Caddy: set FORWARDED_ALLOW_IPS="*" in .env
|
||||
# because the container's source IP is the bridge gateway, not
|
||||
# 127.0.0.1. Safe because the host only binds 127.0.0.1:8080 so
|
||||
# nothing off-host can reach uvicorn directly.
|
||||
# `sh -c exec` keeps uvicorn as PID 1 so SIGTERM still triggers a
|
||||
# graceful shutdown (exec form was fine before, but we need shell
|
||||
# expansion for ${FORWARDED_ALLOW_IPS}).
|
||||
CMD ["sh", "-c", "exec uvicorn app.main:app --host 0.0.0.0 --port 8080 --proxy-headers --forwarded-allow-ips \"${FORWARDED_ALLOW_IPS:-127.0.0.1}\""]
|
||||
|
||||
20
app/main.py
20
app/main.py
@@ -57,6 +57,7 @@ from app import __version__
|
||||
from app.config import get_settings
|
||||
from app.db import build_engine, run_migrations
|
||||
from app.logging_config import configure_logging
|
||||
from app.middleware import AccessLogMiddleware, SecurityHeadersMiddleware
|
||||
from app.models.seed import run_seed
|
||||
from app.routes.admin import router as admin_router
|
||||
from app.routes.admin_cms import router as admin_cms_router
|
||||
@@ -289,10 +290,29 @@ def create_app() -> FastAPI:
|
||||
application.state.admin_pages_service = admin_pages_service
|
||||
application.state.media_service = media_service
|
||||
|
||||
# --- Phase 6 middlewares -----------------------------------------------
|
||||
# Install order matters: FastAPI.add_middleware wraps each new entry
|
||||
# AROUND the existing stack, so the LAST one added runs first on the
|
||||
# request. We want:
|
||||
# - SecurityHeadersMiddleware innermost (stamps nonce on
|
||||
# request.state BEFORE the route runs, writes headers on the
|
||||
# response just before it exits the handler layer) -> added FIRST.
|
||||
# - AccessLogMiddleware outermost (times the entire stack,
|
||||
# including security-headers + CSRF cookie work) -> added LAST.
|
||||
application.add_middleware(
|
||||
SecurityHeadersMiddleware,
|
||||
production=(settings.app_env == "production"),
|
||||
)
|
||||
|
||||
# CSRF cookie middleware — narrow to admin GETs; everything else
|
||||
# passes through untouched so public routes are unaffected.
|
||||
application.add_middleware(CSRFCookieMiddleware, csrf_service=csrf_service)
|
||||
|
||||
# Access log — added last so it wraps (and thus times) every other
|
||||
# middleware. Skips /healthz to keep compose healthcheck noise out
|
||||
# of the log; redacts /admin/auth/consume/<token> paths.
|
||||
application.add_middleware(AccessLogMiddleware)
|
||||
|
||||
# SlowAPI limiter + exception handler. The limiter is a module-level
|
||||
# singleton in app.services.rate_limit (because @limiter.limit has
|
||||
# to be applied at endpoint-definition time, before include_router).
|
||||
|
||||
26
app/middleware/__init__.py
Normal file
26
app/middleware/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Application-level Starlette middlewares.
|
||||
|
||||
Each middleware is a :class:`starlette.middleware.base.BaseHTTPMiddleware`
|
||||
subclass wired up in :mod:`app.main`. They are kept in their own package
|
||||
(rather than buried in ``app/main.py``) because Phase 6 introduced two
|
||||
cross-cutting middlewares — security headers and access logging — that
|
||||
benefit from isolated unit tests and clear homes for future additions
|
||||
(rate-limit re-shaping, request-id propagation, etc.).
|
||||
|
||||
Public surface:
|
||||
|
||||
- :class:`SecurityHeadersMiddleware` — per-request CSP nonce + strict
|
||||
response headers. See :mod:`app.middleware.security_headers`.
|
||||
- :class:`AccessLogMiddleware` — structured ``http_request`` log line
|
||||
after every response. See :mod:`app.middleware.access_log`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.middleware.access_log import AccessLogMiddleware
|
||||
from app.middleware.security_headers import SecurityHeadersMiddleware
|
||||
|
||||
__all__ = [
|
||||
"AccessLogMiddleware",
|
||||
"SecurityHeadersMiddleware",
|
||||
]
|
||||
113
app/middleware/access_log.py
Normal file
113
app/middleware/access_log.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Structured access-log middleware.
|
||||
|
||||
Emits a single ``http_request`` INFO event per request, capturing the
|
||||
HTTP verb, path, status code, wall-clock duration, client IP, and a
|
||||
truncated user-agent. The goal is a compact but auditable trail of
|
||||
production traffic without the noise of a traditional access log and
|
||||
without ever writing secrets to disk.
|
||||
|
||||
Key design choices:
|
||||
|
||||
- **No bodies, no query strings.** We deliberately skip query strings
|
||||
entirely to avoid leaking tokens that might end up there in future
|
||||
(the magic-link consume route keeps its token in the path, so we
|
||||
redact that explicitly below).
|
||||
- **Magic-link path redaction.** Requests to
|
||||
``/admin/auth/consume/{token}`` have the token segment replaced with
|
||||
``<redacted>`` before logging. ``CLAUDE.md`` forbids logging raw
|
||||
tokens anywhere.
|
||||
- **Skip ``/healthz``.** Compose / Docker health probes hit this every
|
||||
few seconds. Logging each one drowns out real traffic.
|
||||
- **Exceptions still get logged.** If the downstream handler raises,
|
||||
we record a ``status_code=500`` entry before re-raising so no failed
|
||||
request vanishes silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import structlog
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Prefix used to recognise magic-link consume URLs whose trailing token
|
||||
# segment must be redacted before logging.
|
||||
_CONSUME_PREFIX: str = "/admin/auth/consume/"
|
||||
|
||||
# Path we skip entirely to reduce health-probe log noise.
|
||||
_SKIP_PATH: str = "/healthz"
|
||||
|
||||
# User-agent strings are unbounded; cap to 256 chars so a hostile client
|
||||
# can't bloat log lines to arbitrary size.
|
||||
_UA_MAX: int = 256
|
||||
|
||||
|
||||
def _redact_path(path: str) -> str:
|
||||
"""Return ``path`` with magic-link tokens replaced by ``<redacted>``.
|
||||
|
||||
The consume URL is ``/admin/auth/consume/{token}``; everything after
|
||||
the prefix is swapped out. We preserve the prefix so log readers can
|
||||
still see which route was hit.
|
||||
"""
|
||||
if path.startswith(_CONSUME_PREFIX):
|
||||
return _CONSUME_PREFIX + "<redacted>"
|
||||
return path
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"""Log one ``http_request`` event per completed request.
|
||||
|
||||
Installed outermost in :mod:`app.main` so the timing measurement
|
||||
covers the entire downstream middleware stack, including security
|
||||
headers and CSRF cookie work.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Time the request, log a structured event, reraise on failure."""
|
||||
path: str = request.url.path
|
||||
|
||||
# Early exit for health probes — don't even record timing.
|
||||
if path == _SKIP_PATH:
|
||||
return await call_next(request)
|
||||
|
||||
method: str = request.method
|
||||
client_ip: str = request.client.host if request.client else ""
|
||||
user_agent: str = request.headers.get("user-agent", "")[:_UA_MAX]
|
||||
redacted_path: str = _redact_path(path)
|
||||
|
||||
start: float = time.monotonic()
|
||||
try:
|
||||
response: Response = await call_next(request)
|
||||
except Exception:
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
# Record the failure before re-raising so unhandled exceptions
|
||||
# don't vanish from the log. Status is synthetic (500) because
|
||||
# the framework hasn't written a response yet at this point.
|
||||
logger.info(
|
||||
"http_request",
|
||||
method=method,
|
||||
path=redacted_path,
|
||||
status_code=500,
|
||||
duration_ms=duration_ms,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
raise
|
||||
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
logger.info(
|
||||
"http_request",
|
||||
method=method,
|
||||
path=redacted_path,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return response
|
||||
122
app/middleware/security_headers.py
Normal file
122
app/middleware/security_headers.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Security-headers middleware.
|
||||
|
||||
Installs a strict-ish set of security response headers on every outgoing
|
||||
response — notably a nonce-based ``Content-Security-Policy`` that locks
|
||||
inline ``<script>`` usage to per-request tokens. Templates read the
|
||||
nonce from ``request.state.csp_nonce`` and stamp it onto whichever
|
||||
``<script>`` blocks need to run.
|
||||
|
||||
Modelled on :class:`app.main.CSRFCookieMiddleware`: the constructor
|
||||
takes the ASGI app plus any configuration it needs by keyword;
|
||||
``dispatch`` is async and always returns the downstream response.
|
||||
|
||||
Header set (matches ``docs/security.md`` + Phase 6 brief):
|
||||
|
||||
- ``Content-Security-Policy`` — nonce-based ``script-src`` that also
|
||||
allowlists hCaptcha; ``frame-ancestors 'none'`` replaces the legacy
|
||||
``X-Frame-Options: DENY``.
|
||||
- ``Strict-Transport-Security`` — **only in production**; the dev
|
||||
server is reached over plain HTTP at ``http://127.0.0.1:8000`` and
|
||||
HSTS would make that session permanently HTTPS-only for the browser.
|
||||
- ``X-Content-Type-Options: nosniff``
|
||||
- ``Referrer-Policy: strict-origin-when-cross-origin``
|
||||
- ``Permissions-Policy`` — disable every sensor/device API we do not
|
||||
use (defense in depth for any future supply-chain compromise).
|
||||
- ``Cross-Origin-Opener-Policy: same-origin``
|
||||
|
||||
The nonce is generated fresh per request (``secrets.token_urlsafe(16)``
|
||||
→ 128 bits, URL-safe) and stored on ``request.state.csp_nonce`` before
|
||||
the downstream handler runs, so Jinja templates can read it via the
|
||||
implicit ``request`` context variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
# --- CSP template ---------------------------------------------------------
|
||||
# Note the ``{nonce}`` placeholder: we format per-request so the token is
|
||||
# unique to each response. ``style-src 'unsafe-inline'`` is a known
|
||||
# compromise — we don't emit our own inline styles, but third-party
|
||||
# widgets (hCaptcha) and some HTML attribute defaults want it. Locking
|
||||
# this down is a future task.
|
||||
_CSP_TEMPLATE: str = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'nonce-{nonce}' https://js.hcaptcha.com https://*.hcaptcha.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self' https://*.hcaptcha.com; "
|
||||
"frame-src https://*.hcaptcha.com https://newassets.hcaptcha.com; "
|
||||
"frame-ancestors 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'"
|
||||
)
|
||||
|
||||
|
||||
# Static response headers that do not depend on per-request state. Kept
|
||||
# as a module-level dict so we pay the allocation cost once at import
|
||||
# time and just iterate on every response.
|
||||
_STATIC_HEADERS: dict[str, str] = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Permissions-Policy": (
|
||||
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), "
|
||||
"magnetometer=(), microphone=(), payment=(), usb=()"
|
||||
),
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
}
|
||||
|
||||
|
||||
# HSTS is production-only. One year, subdomains included; no ``preload``
|
||||
# directive (that requires submitting the apex to the HSTS preload list,
|
||||
# which is a separate operational step).
|
||||
_HSTS_VALUE: str = "max-age=31536000; includeSubDomains"
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach CSP + friends to every response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
app:
|
||||
The ASGI application. ``BaseHTTPMiddleware`` stores this.
|
||||
production:
|
||||
When ``True`` the middleware also emits ``Strict-Transport-Security``.
|
||||
Passed explicitly (rather than read from :mod:`app.config` here)
|
||||
so the middleware remains unit-testable without loading settings.
|
||||
"""
|
||||
|
||||
def __init__(self, app, *, production: bool) -> None:
|
||||
"""Store the production flag; the app handle is owned by the base class."""
|
||||
super().__init__(app)
|
||||
self._production: bool = production
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Mint a nonce, run the downstream handler, stamp the headers.
|
||||
|
||||
The nonce is attached to ``request.state`` *before* ``call_next``
|
||||
so any template rendered by the route handler can read it. The
|
||||
CSP header itself is added after the response is produced so it
|
||||
rides on every path (HTML, JSON, static bypass, error pages).
|
||||
"""
|
||||
# 128 bits of entropy, URL-safe base64 — plenty for CSP nonce use.
|
||||
nonce: str = secrets.token_urlsafe(16)
|
||||
request.state.csp_nonce = nonce
|
||||
|
||||
response: Response = await call_next(request)
|
||||
|
||||
response.headers["Content-Security-Policy"] = _CSP_TEMPLATE.format(
|
||||
nonce=nonce
|
||||
)
|
||||
for key, value in _STATIC_HEADERS.items():
|
||||
response.headers[key] = value
|
||||
if self._production:
|
||||
response.headers["Strict-Transport-Security"] = _HSTS_VALUE
|
||||
|
||||
return response
|
||||
@@ -27,7 +27,7 @@ from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models.entities import Page
|
||||
from app.models.entities import Page, Post
|
||||
from app.models.posts import PostSummary
|
||||
from app.services.contact import ContactService
|
||||
from app.services.hcaptcha import HCaptchaService
|
||||
@@ -117,6 +117,33 @@ def home(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/posts/{slug}",
|
||||
response_class=HTMLResponse,
|
||||
summary="Single blog post",
|
||||
)
|
||||
def post_detail(
|
||||
slug: str,
|
||||
request: Request,
|
||||
templates: Jinja2Templates = Depends(get_templates),
|
||||
posts: PostService = Depends(get_post_service),
|
||||
) -> HTMLResponse:
|
||||
"""Render a single published post by slug.
|
||||
|
||||
Drafts and unknown slugs return 404 (same response so a mistyped
|
||||
URL cannot be used to enumerate unpublished titles).
|
||||
"""
|
||||
post: Post | None = posts.get_published_by_slug(slug)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"public/post.html",
|
||||
{"active_nav": "home", "post": post},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", response_class=HTMLResponse, summary="About the farm")
|
||||
def about(
|
||||
request: Request,
|
||||
|
||||
@@ -21,9 +21,9 @@ from typing import Optional
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Engine, text
|
||||
|
||||
from app.models.entities import PostStatus
|
||||
from app.models.entities import Post, PostStatus
|
||||
from app.models.posts import PostSummary
|
||||
from app.models.mappers import _parse_datetime
|
||||
from app.models.mappers import _parse_datetime, row_to_post
|
||||
from app.services.cache import TTLCache
|
||||
|
||||
|
||||
@@ -155,6 +155,36 @@ class PostService:
|
||||
self._cache.set(safe_limit, summaries)
|
||||
return summaries
|
||||
|
||||
def get_published_by_slug(self, slug: str) -> Optional[Post]:
|
||||
"""Return the published :class:`Post` for ``slug`` or ``None``.
|
||||
|
||||
Drafts are invisible on the public path: the status filter
|
||||
belongs in SQL so a mistyped slug and a draft slug are
|
||||
indistinguishable to the caller (same 404 upstream).
|
||||
|
||||
SQL safety: ``slug`` and ``status`` are bound parameters; no
|
||||
string interpolation.
|
||||
"""
|
||||
with self._engine.connect() as conn:
|
||||
row = (
|
||||
conn.execute(
|
||||
text(
|
||||
"SELECT id, slug, title, body_md, body_html_cached,"
|
||||
" status, published_at, updated_at, author_user_id"
|
||||
" FROM posts"
|
||||
" WHERE slug = :slug AND status = :status"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{
|
||||
"slug": slug,
|
||||
"status": PostStatus.PUBLISHED.value,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
return row_to_post(row) if row is not None else None
|
||||
|
||||
def invalidate_all(self) -> None:
|
||||
"""Drop every cached post-list entry.
|
||||
|
||||
|
||||
@@ -348,6 +348,17 @@ a:focus-visible {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.page-article__date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-sky-deep);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.page-article__back {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
/* Post list + card. */
|
||||
.post-list {
|
||||
display: grid;
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
{# Mobile nav toggle. Tiny and CSP-friendly: no inline handlers, no JS
|
||||
framework. Phase 6's CSP will be compatible with moving this into an
|
||||
external file + nonce if we grow; for now the inline block stays. #}
|
||||
<script>
|
||||
<script nonce="{{ request.state.csp_nonce }}">
|
||||
(function () {
|
||||
"use strict";
|
||||
var toggle = document.getElementById("nav-toggle");
|
||||
|
||||
36
app/templates/public/post.html
Normal file
36
app/templates/public/post.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
Single blog post detail page.
|
||||
|
||||
Receives:
|
||||
- post : app.models.entities.Post
|
||||
- active_nav : str "home"
|
||||
|
||||
``post.body_html_cached`` is the bleach-sanitized output of the
|
||||
Markdown pipeline (allowlisted tags/attrs/protocols only), so
|
||||
rendering with ``| safe`` does not reintroduce XSS risk. Same
|
||||
rationale as ``public/about.html``.
|
||||
#}
|
||||
{% extends "public/base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} — Chicken Babies R Us{% endblock %}
|
||||
{% block meta_description %}{{ post.title }} — a post from Chicken Babies R Us.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">{{ post.title }}</h1>
|
||||
{% if post.published_at %}
|
||||
<time class="page-article__date"
|
||||
datetime="{{ post.published_at.isoformat() }}">
|
||||
{{ post.published_at.strftime("%B %-d, %Y") }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{{ post.body_html_cached | safe }}
|
||||
</article>
|
||||
|
||||
<p class="page-article__back">
|
||||
<a href="/">← Back to all posts</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
69
docker-compose.prod.yml
Normal file
69
docker-compose.prod.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — production compose file.
|
||||
#
|
||||
# Meant for the Debian 12 VM behind Caddy. Unlike docker-compose.yml (which
|
||||
# builds the image from source for local dev), this file pulls the pre-built
|
||||
# image from the Gitea container registry so the VM stays a thin runner.
|
||||
#
|
||||
# Ansible responsibilities (not this file):
|
||||
# - render /opt/chicken-babies/.env with real secrets
|
||||
# - ensure data/ exists and is owned by uid:gid 10001:10001
|
||||
# sudo install -d -o 10001 -g 10001 /opt/chicken-babies/data/media
|
||||
# sudo install -d -o 10001 -g 10001 /opt/chicken-babies/data/backups
|
||||
# - `docker login git.sneakygeek.net -u <user> -p <REGISTRY_TOKEN>`
|
||||
# - copy this file as /opt/chicken-babies/docker-compose.yml
|
||||
# - run `docker compose pull && docker compose up -d`
|
||||
#
|
||||
# Update flow on the VM (after a CI build publishes a new :latest):
|
||||
# docker compose pull
|
||||
# docker compose up -d # restarts only if the image SHA changed
|
||||
# docker image prune -f # reclaim space from the old layers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
web:
|
||||
image: git.sneakygeek.net/ptarrant/chicken_babies_site:latest
|
||||
# Docker pulls :latest on `docker compose pull`; no build context needed.
|
||||
pull_policy: always
|
||||
env_file:
|
||||
- .env
|
||||
# Override the Dockerfile CMD so uvicorn trusts X-Forwarded-* headers.
|
||||
# Caddy lives on another server (10.10.99.10) and speaks HTTP to this
|
||||
# VM on port 8080. Even so, the source IP *inside* the container is
|
||||
# the Docker bridge gateway (typically 172.17.0.1), NOT 10.10.99.10,
|
||||
# because Docker NATs the inbound connection. That means allowlisting
|
||||
# 10.10.99.10 would never match — uvicorn would still drop X-Forwarded-*
|
||||
# and Starlette would build http:// URLs under an https:// page,
|
||||
# tripping `img-src 'self'` CSP on the logo, fonts, etc.
|
||||
#
|
||||
# "*" is acceptable here because access to port 8080 is controlled
|
||||
# at the network layer (host firewall / VLAN) — only the Caddy box
|
||||
# can reach it. If you later move Caddy onto this same host, change
|
||||
# this back to a specific gateway IP.
|
||||
command: >
|
||||
uvicorn app.main:app
|
||||
--host 0.0.0.0
|
||||
--port 8080
|
||||
--proxy-headers
|
||||
--forwarded-allow-ips *
|
||||
ports:
|
||||
# Caddy on 10.10.99.10 reverse-proxies to <this-vm>:8080. Binding
|
||||
# on all interfaces keeps the compose portable; lock down access
|
||||
# with the VM's host firewall (nftables / ufw) or upstream
|
||||
# (OPNsense) to only permit 10.10.99.10 → :8080.
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# SQLite DB + media uploads live on the host so container rebuilds /
|
||||
# image rolls don't wipe content. The container runs as uid 10001;
|
||||
# the host dir must be chown'd to match (see Ansible notes above).
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
# Docker picks up the HEALTHCHECK baked into the image. `docker compose
|
||||
# ps` surfaces health status; systemd / Ansible tasks can gate on it.
|
||||
# Container-level logging caps so a chatty bot run doesn't fill the
|
||||
# VM disk before the host-side logrotate catches it.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
@@ -21,10 +21,10 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
# Uvicorn listens on 8000 inside the container. In the production
|
||||
# topology Caddy fronts this; for local runs it's directly on the
|
||||
# host loopback via the mapped port.
|
||||
- "8000:8000"
|
||||
# Uvicorn listens on 8080 inside the container (prod default). In
|
||||
# the production topology Caddy fronts this; for local runs it's
|
||||
# directly on the host loopback via the mapped port.
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# SQLite DB + media uploads live under data/. Mounting it keeps
|
||||
# state on the host so container rebuilds don't wipe content.
|
||||
|
||||
@@ -246,3 +246,89 @@ Pre-requisites:
|
||||
|
||||
- [ ] `pytest -q tests/test_hcaptcha_service.py tests/test_contact_service.py tests/test_contact_routes.py` passes.
|
||||
- [ ] `python -c "from app.main import app; print(len(app.routes))"` prints a count greater than the Phase 4 count (the new `POST /contact` adds one route).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Hardening + Deploy
|
||||
|
||||
Run a dev server alongside these checks:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Security headers (`/`, `/contact`, `/admin/login`)
|
||||
|
||||
- [ ] `curl -sI http://127.0.0.1:8000/ | grep -i content-security-policy`
|
||||
returns a policy containing `nonce-<...>`, `frame-ancestors 'none'`,
|
||||
`form-action 'self'`, and `https://js.hcaptcha.com` in `script-src`.
|
||||
- [ ] Two consecutive `curl -sI /` calls print **different** `nonce-<...>`
|
||||
values — the middleware mints one per request.
|
||||
- [ ] `X-Content-Type-Options: nosniff`, `Referrer-Policy:
|
||||
strict-origin-when-cross-origin`, `Cross-Origin-Opener-Policy:
|
||||
same-origin`, and a `Permissions-Policy` disabling camera /
|
||||
geolocation / microphone are all present on the response.
|
||||
- [ ] In development, `Strict-Transport-Security` is **absent** (so
|
||||
localhost over HTTP keeps working).
|
||||
- [ ] Set `APP_ENV=production` + all prod-required env vars and restart;
|
||||
confirm `Strict-Transport-Security: max-age=31536000;
|
||||
includeSubDomains` now appears.
|
||||
- [ ] Browser devtools console on `/` shows the inline nav-toggle
|
||||
script executes (the mobile hamburger still toggles the menu).
|
||||
No "Refused to execute inline script" CSP violations appear.
|
||||
- [ ] On `/contact`, the hCaptcha widget renders and its API script
|
||||
loads (no CSP violations in the console).
|
||||
- [ ] On any admin page, `admin_editor.js` loads and the live preview
|
||||
still updates as you type.
|
||||
|
||||
### Access log (`/healthz` quiet, magic-link redacted)
|
||||
|
||||
- [ ] Normal page loads emit one `http_request` line with `method`,
|
||||
`path`, `status_code`, `duration_ms`, `client_ip`, and
|
||||
`user_agent` fields.
|
||||
- [ ] `curl http://127.0.0.1:8000/healthz` does **not** produce an
|
||||
`http_request` log line (the middleware skips `/healthz`).
|
||||
- [ ] Request a magic link, then hit the consume URL in the browser.
|
||||
The server log shows `path=/admin/auth/consume/<redacted>` —
|
||||
the raw token never appears in stdout.
|
||||
|
||||
### Dockerfile hardening
|
||||
|
||||
- [ ] `docker compose build` succeeds.
|
||||
- [ ] `docker compose up -d` brings the service to healthy; inspect
|
||||
with `docker compose ps` — the STATUS column reads `(healthy)`
|
||||
within ~30s of startup.
|
||||
- [ ] `docker compose exec app id` reports `uid=10001(app) gid=10001(app)`.
|
||||
- [ ] `docker inspect --format '{{.State.Health.Status}}' <container>`
|
||||
returns `healthy`.
|
||||
|
||||
### Backup script
|
||||
|
||||
- [ ] `./scripts/backup.sh` exits 0 and produces
|
||||
`data/backups/app-<ts>.db` and `data/backups/media-<ts>.tar.gz`.
|
||||
- [ ] `sqlite3 data/backups/app-<ts>.db "SELECT COUNT(*) FROM posts"`
|
||||
returns the same count as the live DB.
|
||||
- [ ] Run the script 15 times in a loop; after the 15th run, exactly
|
||||
14 `app-*.db` and 14 `media-*.tar.gz` artifacts remain in
|
||||
`data/backups/` (newest kept, older pruned).
|
||||
- [ ] Install the cron entry on the VM (example):
|
||||
`15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh
|
||||
>> /var/log/chicken_babies/backup.log 2>&1`.
|
||||
|
||||
### Gitea Actions workflow
|
||||
|
||||
- [ ] Push a commit to `master` on Gitea and confirm the
|
||||
`Build and Push Docker Image` workflow runs to green.
|
||||
- [ ] `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` and
|
||||
`:sha-<short>` tags exist in the registry afterwards.
|
||||
- [ ] Pulling the `:latest` image and hitting `/healthz` reports the
|
||||
expected `commit_sha` (matches the built commit).
|
||||
|
||||
### Ops smoke
|
||||
|
||||
- [ ] `pytest -q` passes with no new failures beyond the known
|
||||
pre-existing two (`test_production_missing_key_refuses_startup`,
|
||||
`test_layout_includes_logo_image`).
|
||||
- [ ] `python -c "from app.main import app; print(len(app.routes))"`
|
||||
prints the same route count as before Phase 6 (no new routes).
|
||||
|
||||
@@ -252,14 +252,42 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
|
||||
|
||||
**Branch:** `feat/phase-5-contact-form` off `dev`. Not committed, not merged, not pushed — changes staged for human review before `--no-ff` merge into `dev` per CLAUDE.md git strategy.
|
||||
|
||||
## Phase 6 — Hardening + Deploy
|
||||
## Phase 6 — Hardening + Deploy ✅
|
||||
|
||||
- Security headers middleware (strict nonce-based CSP, HSTS, etc.).
|
||||
- CSRF middleware on admin routes (double-submit cookie).
|
||||
- Structured access log + error log (structlog JSON in prod, pretty in dev).
|
||||
- Backup script: `sqlite3 data/app.db ".backup data/backups/app-<ts>.db"` plus a tar of `data/media/`; cron nightly on the VM.
|
||||
- Dockerfile hardening: non-root user, slim base, `HEALTHCHECK`.
|
||||
- Gitea Action: on tag push, build image and publish to the internal registry.
|
||||
**Completed:** 2026-04-22
|
||||
|
||||
**Summary:** Locked down the HTTP surface with a strict nonce-based CSP (plus HSTS in prod, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `frame-ancestors`, `form-action`), added a structured per-request access-log middleware with magic-link path redaction and `/healthz` suppression, hardened the Docker image (non-root uid/gid 10001 + `HEALTHCHECK` against `/healthz`), shipped a backup script with 14-day retention, and wired a Gitea Actions workflow that publishes `git.sneakygeek.net/ptarrant/chicken_babies_site:latest` + `:sha-<short>` on every push to `master` (plus `workflow_dispatch`). CSRF middleware was already live from Phase 4 — that roadmap bullet was a no-op here.
|
||||
|
||||
**Key files:**
|
||||
- `app/middleware/__init__.py` — package marker.
|
||||
- `app/middleware/security_headers.py` — `SecurityHeadersMiddleware(BaseHTTPMiddleware)`. Per-request nonce via `secrets.token_urlsafe(16)`, stashed on `request.state.csp_nonce`. CSP builds on each dispatch so the nonce threads into `script-src`. Conditional HSTS: constructor takes `production: bool`, only emits `Strict-Transport-Security: max-age=31536000; includeSubDomains` when `True` so localhost-over-http dev still works. Also sets `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()`, `Cross-Origin-Opener-Policy: same-origin`.
|
||||
- `app/middleware/access_log.py` — `AccessLogMiddleware(BaseHTTPMiddleware)` emits one `http_request` INFO event per request with `method`, `path`, `status_code`, `duration_ms` (int), `client_ip` (from `request.client.host` — already resolved through `ProxyHeadersMiddleware`), `user_agent` (truncated to 256 chars). Skips `/healthz` to avoid compose-loop noise. Paths matching `^/admin/auth/consume/` have the token segment replaced with `<redacted>` before logging. Unhandled downstream exceptions are logged with `status_code=500`, then re-raised so FastAPI's exception machinery still runs.
|
||||
- `app/main.py` — imports + wires both middlewares. Install order: `SecurityHeadersMiddleware` added first, `AccessLogMiddleware` added last. FastAPI/Starlette wraps in reverse registration order, so the access log runs outermost (timing covers the whole stack) and the security headers run innermost (stamped onto the response before the access log times it). Inline comment documents the ordering so future edits don't flip it.
|
||||
- `app/templates/public/base.html` — line 110 inline `<script>` now carries `nonce="{{ request.state.csp_nonce }}"` so it passes strict CSP. External hCaptcha script on `public/contact.html` left alone (allowlisted by origin in `script-src`, not nonced). Admin templates were all-external-JS already; nothing to patch there.
|
||||
- `Dockerfile` — runtime stage adds `groupadd -g 10001 app && useradd -m -u 10001 -g app app`, `chown -R app:app /app /opt/venv`, and `USER app` before `CMD`. Stable UID/GID keeps host bind-mount ownership predictable. `HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"` — stdlib only, no curl/wget runtime dep.
|
||||
- `scripts/backup.sh` — `set -euo pipefail`, UTC timestamp (`%Y%m%dT%H%M%SZ`), `sqlite3 data/app.db ".backup data/backups/app-<ts>.db"` + `tar -czf data/backups/media-<ts>.tar.gz data/media`, retention `ls -t | tail -n +15 | xargs -r rm` so only the newest 14 of each artifact survive. `mkdir -p data/backups` is idempotent. Chmod +x committed.
|
||||
- `.gitea/workflows/build-image.yml` — triggers on `push` to `master` + `workflow_dispatch`. Buildx + `docker/login-action@v3` using `${{ gitea.actor }}` / `${{ secrets.REGISTRY_TOKEN }}`. `metadata-action@v5` tags: `latest` + `sha-<short>`. `build-push-action@v5` passes `build-args: GIT_COMMIT_SHA=${{ gitea.sha }}` so `/healthz` reports the real commit in deployed images. Registry cache-from/to pinned to the `:buildcache` tag.
|
||||
- `docs/MANUAL_TESTING.md` — appended Phase 6 checklist (security headers via curl, nonce uniqueness, HSTS dev/prod diff, hCaptcha still loads, admin editor still works, HEALTHCHECK `docker ps` healthy, backup dry run, retention prune).
|
||||
- `tests/test_security_headers.py` (4 tests) — asserts every expected header is present on `/`, CSP contains `nonce-`, two back-to-back requests get different nonces, HSTS is absent when the middleware is constructed with `production=False` and present with `max-age=31536000; includeSubDomains` when `production=True`.
|
||||
- `tests/test_access_log.py` (4 tests) — capsys-based capture with ANSI-escape stripping. Verifies `http_request` events fire for 200/404, `/healthz` is skipped, `/admin/auth/consume/<token>` is redacted before logging, and a downstream-handler exception is logged with `status_code=500` then re-raised.
|
||||
|
||||
**Endpoints created:** none. Phase 6 is cross-cutting — route count stayed at 28.
|
||||
|
||||
**Key details:**
|
||||
- **CSP policy (effective):** `default-src 'self'; script-src 'self' 'nonce-<N>' https://js.hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://*.hcaptcha.com; frame-src https://*.hcaptcha.com https://newassets.hcaptcha.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`. `style-src 'unsafe-inline'` is kept for now because hCaptcha injects inline style into its widget; tightening it is a future task.
|
||||
- **Single inline script remaining:** `public/base.html`'s mobile-nav toggle. The admin editor and everything else are external files already, so no admin template needed a nonce. `contact.html`'s external hCaptcha script is allowed by origin; external scripts don't take nonces.
|
||||
- **HSTS is production-gated.** Local dev over `http://127.0.0.1:8000` stays reachable. Prod flips on the header via the `production=(settings.app_env == "production")` flag in `create_app`.
|
||||
- **Access log redaction is defence-in-depth.** The magic-link consume route already signs / hashes / single-uses the token, so even if a log line leaked it'd be unreplayable. Redacting anyway keeps the log grep-friendly and avoids any accidental downstream ingestion storing the raw value.
|
||||
- **`/healthz` skipped from access logs** to keep compose's 30s HEALTHCHECK from drowning signal in noise. Still fully served — just not logged.
|
||||
- **Middleware install order pitfall documented.** FastAPI/Starlette wrap in reverse registration order; the outermost middleware is the LAST one added. `app/main.py` now has an inline comment so a future refactor doesn't silently flip timing vs. security behaviour.
|
||||
- **Docker image runs as non-root (uid 10001).** Host bind-mount (`./data:/app/data`) must be writable by this uid. Docs/MANUAL_TESTING notes `chown -R 10001:10001 data/` or mounting into a uid-remapped volume as the host-side prep step.
|
||||
- **Gitea workflow publishes on every `master` push.** No tag trigger this phase; release tags can be layered on later without touching the workflow.
|
||||
- **Build-arg discipline preserved.** Workflow passes `GIT_COMMIT_SHA=${{ gitea.sha }}` so the published image keeps reporting the correct SHA through `/healthz` — the Phase 0 contract holds.
|
||||
- **Backup script is host-side only.** Cron installation is a sysadmin step documented in MANUAL_TESTING; the repo deliberately does not ship a systemd unit to keep it host-agnostic.
|
||||
- **Pre-existing test failures are still pre-existing.** `test_production_missing_key_refuses_startup` (env pollution) and `test_layout_includes_logo_image` (Phase 4 `logo` → `logo-mark` asset rename) failed on `dev` before this phase; they still fail, and they are NOT Phase 6 regressions.
|
||||
|
||||
**Verification run:**
|
||||
`python -c "from app.main import app; print(len(app.routes))"` → **28** (unchanged) ✓ · `pytest -q` → **149 passed, 2 failed** — both failures confirmed pre-existing on `dev` ✓ · dev-mode TestClient `/`: CSP present with unique nonce across two requests, HSTS absent, `X-Content-Type-Options=nosniff`, `Referrer-Policy=strict-origin-when-cross-origin`, `Permissions-Policy` present, `frame-ancestors 'none'` in CSP, nonce value matches the `nonce="..."` attribute emitted into the HTML ✓ · prod-mode (`APP_ENV=production` + full config env) `/`: `Strict-Transport-Security: max-age=31536000; includeSubDomains` present, JSON access-log line emitted ✓ · `/healthz` returns 200 without producing an `http_request` log entry ✓ · `bash -n scripts/backup.sh` clean ✓ · `python -c "import yaml; yaml.safe_load(open('.gitea/workflows/build-image.yml'))"` clean ✓ · `scripts/backup.sh` is `-rwxrwxr-x` (executable) ✓.
|
||||
|
||||
## Phase 7 (Future) — Shop
|
||||
|
||||
|
||||
65
run_dev.sh
Executable file
65
run_dev.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chicken Babies R Us — local dev launcher.
|
||||
#
|
||||
# Runs uvicorn directly on the host (no docker). Used for fast iteration on
|
||||
# routes, templates, CSS, and middleware. Set APP_ENV=production in front
|
||||
# of this script if you want to smoke-test the prod-only code paths (HSTS,
|
||||
# JSON logs, prod-config guardrails).
|
||||
#
|
||||
# Usage:
|
||||
# ./run_dev.sh # dev mode, reload on
|
||||
# ./run_dev.sh --no-reload # dev mode, reload off
|
||||
# PORT=8001 ./run_dev.sh # override listen port (default 8000)
|
||||
# ---------------------------------------------------------------------------
|
||||
set -euo pipefail
|
||||
|
||||
# Anchor paths to the repo root so the script works from any cwd.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
VENV_DIR="venv"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3.12}"
|
||||
PORT="${PORT:-8080}"
|
||||
HOST="${HOST:-127.0.0.1}"
|
||||
|
||||
# 1. Virtualenv -------------------------------------------------------------
|
||||
if [[ ! -x "${VENV_DIR}/bin/python" ]]; then
|
||||
echo "==> Creating venv at ${VENV_DIR}/"
|
||||
"${PYTHON_BIN}" -m venv "${VENV_DIR}"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "${VENV_DIR}/bin/activate"
|
||||
|
||||
# 2. Requirements (reinstall if requirements.txt is newer than the stamp) ---
|
||||
STAMP="${VENV_DIR}/.requirements.stamp"
|
||||
if [[ requirements.txt -nt "${STAMP}" ]]; then
|
||||
echo "==> Installing / updating dependencies"
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet -r requirements.txt
|
||||
touch "${STAMP}"
|
||||
fi
|
||||
|
||||
# 3. .env ------------------------------------------------------------------
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "==> No .env found; copying from .env.example"
|
||||
cp .env.example .env
|
||||
echo " Edit .env to set SECRET_KEY / RESEND_API_KEY / HCAPTCHA_* as needed."
|
||||
fi
|
||||
|
||||
# 4. Data dir --------------------------------------------------------------
|
||||
mkdir -p data/media data/backups
|
||||
|
||||
# 5. Launch ----------------------------------------------------------------
|
||||
RELOAD_FLAG="--reload"
|
||||
if [[ "${1:-}" == "--no-reload" ]]; then
|
||||
RELOAD_FLAG=""
|
||||
fi
|
||||
|
||||
echo "==> uvicorn on http://${HOST}:${PORT} (APP_ENV=${APP_ENV:-development})"
|
||||
# shellcheck disable=SC2086
|
||||
exec uvicorn app.main:app \
|
||||
--host "${HOST}" \
|
||||
--port "${PORT}" \
|
||||
${RELOAD_FLAG} \
|
||||
--proxy-headers \
|
||||
--forwarded-allow-ips 127.0.0.1
|
||||
64
scripts/backup.sh
Executable file
64
scripts/backup.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# ---------------------------------------------------------------------------
|
||||
# scripts/backup.sh — local SQLite + media snapshot
|
||||
#
|
||||
# Produces two artifacts under data/backups/:
|
||||
# app-<ts>.db : SQLite .backup of the live database (safe while
|
||||
# the app is running; sqlite3 .backup coordinates
|
||||
# with WAL).
|
||||
# media-<ts>.tar.gz : tar archive of the media upload directory.
|
||||
#
|
||||
# Retention: keep the 14 newest of each artifact; prune the rest.
|
||||
#
|
||||
# The script is intended to be invoked by cron on the Debian VM. Example
|
||||
# crontab line (daily at 03:15 UTC):
|
||||
# 15 3 * * * cd /srv/chicken_babies_site && ./scripts/backup.sh \
|
||||
# >> /var/log/chicken_babies/backup.log 2>&1
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 on success. Any failing step aborts via `set -e` with a non-zero
|
||||
# status so cron emails / log scrapers can flag it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# UTC timestamp: sortable, compact, unambiguous. Matches the ISO-8601
|
||||
# "basic" form with a literal Z suffix.
|
||||
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
BACKUP_DIR="data/backups"
|
||||
DB_SRC="data/app.db"
|
||||
MEDIA_SRC="data/media"
|
||||
|
||||
# Ensure the output directory exists. `mkdir -p` is idempotent and never
|
||||
# fails if the directory already exists, which is exactly what we want.
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
# --- SQLite snapshot ------------------------------------------------------
|
||||
# Use sqlite3's .backup command rather than `cp` so we get a consistent
|
||||
# copy even if the database is actively being written to. sqlite3 serialises
|
||||
# with the WAL and produces a file that's safe to open elsewhere.
|
||||
DB_DEST="${BACKUP_DIR}/app-${TS}.db"
|
||||
sqlite3 "${DB_SRC}" ".backup ${DB_DEST}"
|
||||
|
||||
# --- Media archive --------------------------------------------------------
|
||||
# tar the whole media tree. gzip compression keeps the archive small for
|
||||
# typical image payloads; restore is just `tar -xzf`.
|
||||
MEDIA_DEST="${BACKUP_DIR}/media-${TS}.tar.gz"
|
||||
tar -czf "${MEDIA_DEST}" "${MEDIA_SRC}"
|
||||
|
||||
# --- Retention prune -------------------------------------------------------
|
||||
# Keep the 14 newest of each type; delete the rest. ls -t sorts newest-
|
||||
# first; tail -n +15 drops the first 14 lines (the survivors). We guard
|
||||
# each rm with `|| true` so a cold start with fewer than 14 artifacts
|
||||
# doesn't cause the script to exit non-zero via `set -e`.
|
||||
keep_newest_14() {
|
||||
local pattern="$1"
|
||||
# shellcheck disable=SC2012 # `ls -t` is the simplest way to sort by mtime
|
||||
ls -1t ${pattern} 2>/dev/null | tail -n +15 | xargs -r rm -f
|
||||
}
|
||||
|
||||
keep_newest_14 "${BACKUP_DIR}/app-*.db"
|
||||
keep_newest_14 "${BACKUP_DIR}/media-*.tar.gz"
|
||||
|
||||
echo "backup complete: ${DB_DEST}, ${MEDIA_DEST}"
|
||||
112
tests/test_access_log.py
Normal file
112
tests/test_access_log.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for :class:`app.middleware.AccessLogMiddleware`.
|
||||
|
||||
structlog in this project is wired through ``PrintLoggerFactory`` (see
|
||||
:mod:`app.logging_config`), which emits rendered log lines to stdout.
|
||||
We therefore capture via pytest's ``capsys`` fixture — the same pattern
|
||||
:mod:`tests.test_email_service` uses for its dev-fallback assertion.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Every request produces an ``http_request`` log line including the
|
||||
method, the path, and a status code.
|
||||
- ``/healthz`` is skipped (reduces compose healthcheck noise).
|
||||
- Magic-link consume paths have their token segment redacted to
|
||||
``<redacted>`` before the line is emitted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.logging_config import configure_logging
|
||||
from app.main import app
|
||||
|
||||
|
||||
# ConsoleRenderer wraps keys / values in ANSI colour escapes; strip them
|
||||
# so substring assertions can pattern-match on the semantic payload
|
||||
# rather than the rendering.
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
"""Return ``text`` with ANSI terminal escapes removed."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(capsys: pytest.CaptureFixture[str]) -> TestClient:
|
||||
"""Return a TestClient with structlog reconfigured for capture.
|
||||
|
||||
The test harness may be left in any state by a previously run test;
|
||||
re-calling :func:`configure_logging` guarantees the ConsoleRenderer
|
||||
pipeline is active so ``capsys`` actually sees the log lines.
|
||||
|
||||
``capsys.readouterr()`` is called on entry so existing startup
|
||||
output (``app_started``, etc.) doesn't leak into the per-test
|
||||
assertions.
|
||||
"""
|
||||
configure_logging("development")
|
||||
capsys.readouterr() # drain any buffered prior output
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_request_emits_http_request_event(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""A single GET to / produces an ``http_request`` log line."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" in out
|
||||
# The structured keys must also be present in the rendered line.
|
||||
assert "method=GET" in out
|
||||
assert "path=/" in out
|
||||
assert "status_code=200" in out
|
||||
|
||||
|
||||
def test_healthz_is_skipped(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""``/healthz`` requests do NOT produce an ``http_request`` event.
|
||||
|
||||
Compose / Docker health probes hit this path every 30s; logging
|
||||
every probe would drown out real traffic.
|
||||
"""
|
||||
response = client.get("/healthz")
|
||||
assert response.status_code == 200
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" not in out, (
|
||||
"AccessLogMiddleware should skip /healthz, but emitted: "
|
||||
f"{out!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_consume_path_is_redacted(
|
||||
client: TestClient, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Magic-link consume tokens must never appear in the access log.
|
||||
|
||||
We hit the consume route with a synthetic token; the downstream
|
||||
handler will return 4xx (token doesn't match any DB row), but
|
||||
the middleware still runs — and its log line must carry the
|
||||
redacted path, not the raw token.
|
||||
"""
|
||||
fake_token = "super-secret-token-value-that-must-not-leak-1234567890"
|
||||
response = client.get(f"/admin/auth/consume/{fake_token}")
|
||||
# We don't care what status the router returns — only that the log
|
||||
# entry for this request redacts the token.
|
||||
assert response.status_code in (302, 303, 400, 401, 403, 404), (
|
||||
f"unexpected status {response.status_code}"
|
||||
)
|
||||
|
||||
out = _strip_ansi(capsys.readouterr().out)
|
||||
assert "http_request" in out
|
||||
assert "/admin/auth/consume/<redacted>" in out
|
||||
assert fake_token not in out, (
|
||||
"raw magic-link token appeared in access log output"
|
||||
)
|
||||
124
tests/test_security_headers.py
Normal file
124
tests/test_security_headers.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Tests for :class:`app.middleware.SecurityHeadersMiddleware`.
|
||||
|
||||
The middleware is wired into the real application by
|
||||
:func:`app.main.create_app`, so these tests exercise it end-to-end via
|
||||
the module-level ``app`` instance — no bespoke app / harness.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Every expected response header is present on the homepage.
|
||||
- The CSP header carries a ``nonce-`` directive whose value changes
|
||||
across requests (so a replayed page can't re-authorize an
|
||||
attacker-controlled script).
|
||||
- HSTS is **absent** in development — otherwise a browser would refuse
|
||||
to load ``http://127.0.0.1:8000`` after a single successful test run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client() -> TestClient:
|
||||
"""Module-scoped TestClient so each test reuses the running app."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# CSP nonces are emitted as ``'nonce-<base64url>'``; capture the payload.
|
||||
_NONCE_RE = re.compile(r"'nonce-([A-Za-z0-9_\-]+)'")
|
||||
|
||||
|
||||
def _extract_nonce(csp_header: str) -> str:
|
||||
"""Pull the ``nonce-<value>`` token out of a CSP header string."""
|
||||
match = _NONCE_RE.search(csp_header)
|
||||
assert match, f"CSP header is missing a nonce directive: {csp_header!r}"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_csp_header_present_and_carries_nonce(client: TestClient) -> None:
|
||||
"""The homepage returns a CSP header with a nonce-<N> directive."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
csp = response.headers.get("Content-Security-Policy")
|
||||
assert csp is not None, "CSP header missing on /"
|
||||
# Sanity: the directives we care about are all present.
|
||||
assert "default-src 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert "form-action 'self'" in csp
|
||||
assert "https://js.hcaptcha.com" in csp # hCaptcha allowlist intact.
|
||||
|
||||
nonce = _extract_nonce(csp)
|
||||
assert len(nonce) >= 16, f"nonce looks too short: {nonce!r}"
|
||||
|
||||
|
||||
def test_csp_nonce_changes_across_requests(client: TestClient) -> None:
|
||||
"""Two requests must see two different nonces.
|
||||
|
||||
A reused nonce would let an attacker who captured one response
|
||||
authorize a script in a later response — hence the per-request
|
||||
fresh mint in :class:`SecurityHeadersMiddleware.dispatch`.
|
||||
"""
|
||||
first = client.get("/")
|
||||
second = client.get("/")
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
|
||||
nonce_a = _extract_nonce(first.headers["Content-Security-Policy"])
|
||||
nonce_b = _extract_nonce(second.headers["Content-Security-Policy"])
|
||||
assert nonce_a != nonce_b, "CSP nonce must be fresh per response"
|
||||
|
||||
|
||||
def test_hsts_absent_in_development(client: TestClient) -> None:
|
||||
"""HSTS is production-only; the dev TestClient must not see it."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "Strict-Transport-Security" not in response.headers
|
||||
|
||||
|
||||
def test_other_security_headers_present(client: TestClient) -> None:
|
||||
"""Spot-check every non-CSP security header we commit to."""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
expected = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
}
|
||||
for name, value in expected.items():
|
||||
assert response.headers.get(name) == value, (
|
||||
f"{name}: expected {value!r}, "
|
||||
f"got {response.headers.get(name)!r}"
|
||||
)
|
||||
|
||||
# Permissions-Policy is multi-valued; just assert the key disables
|
||||
# we care about are present rather than pinning the full string.
|
||||
perm = response.headers.get("Permissions-Policy", "")
|
||||
for directive in ("camera=()", "geolocation=()", "microphone=()"):
|
||||
assert directive in perm, (
|
||||
f"Permissions-Policy missing {directive!r}; got {perm!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_nonce_appears_in_rendered_template(client: TestClient) -> None:
|
||||
"""The per-request CSP nonce must be reachable from Jinja templates.
|
||||
|
||||
The public base template stamps the nonce onto the inline nav-toggle
|
||||
<script>; we verify the exact value from the CSP header appears in
|
||||
the HTML body so the browser will actually run that script.
|
||||
"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
nonce = _extract_nonce(response.headers["Content-Security-Policy"])
|
||||
assert f'nonce="{nonce}"' in response.text, (
|
||||
"inline <script> was not stamped with the CSP nonce; "
|
||||
"browsers will refuse to execute it"
|
||||
)
|
||||
Reference in New Issue
Block a user