The site is a static export (output: "export") served by GitHub Pages. There is no server, no
database, no session, no API route and no user input that reaches a backend. The attack surface is
the set of files in out/ plus the host serving them.
That constraint does most of the work here. It also removes options — see the next section.
poweredByHeader: false — no framework version disclosure.ExternalLink and SocialIconDock, both of which pass
the URL through isSafeExternalUrl (src/lib/url.ts) and always set
rel="noreferrer noopener".src/content/schema.ts). Malformed content fails the build rather than reaching a page.noUncheckedIndexedAccess.next.config.mjs declares no remotePatterns; every asset is local.Security headers cannot be set from next.config.mjs in this project. A headers() block is
ignored under output: "export" — Next has no server to attach them to — and GitHub Pages does not
let you configure response headers.
So CSP, X-Frame-Options, X-Content-Type-Options and Referrer-Policy are not applied on
the GitHub Pages deployment today. Do not assume otherwise.
The options, in order of effort:
nginx.conf in this repository already
sets X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy, so
the Docker image is header-complete even though Pages is not.src/components/theme/theme-script.tsx injects a script with dangerouslySetInnerHTML to apply
the stored theme before first paint and avoid a flash. Every interpolated value is a compile-time
constant from src/lib/theme.ts — no user input reaches it. A CSP would need a hash or nonce for
this script; see the limitation above.
The panel (admin/, admin-ui/) is a separate service and a separate risk profile from the
static site. What is in place:
HttpOnly session cookie and the CSRF token travels in a header. No bearer token in
localStorage, which any injected script could read.LOGIN_RATE_LIMIT (default 10/min) throttles the sign-in POST only,
and nothing else — reading session state is harmless and the SPA calls it on every load. The
counter lives in a Postgres-backed cache rather than local memory, so it is shared across
workers; a per-process cache would have multiplied the effective limit by the worker count.DJANGO_SECRET_KEY is required whenever DJANGO_DEBUG is off.No two-factor authentication. django-otp was previously installed and wired into nothing —
no OTPAdminSite, no token step on the SPA login. A dependency that implies a control it does not
provide is worse than an absent one, so it was removed. Enrolling a real second factor is a
prerequisite before this panel is exposed on a public domain, not an optional extra.
Development defaults are not deployment defaults. docker-compose.yml sets
DJANGO_DEBUG=true and an insecure secret key for local work. Copying it to a server unchanged
would ship a debug-mode admin with a known key.
Content now lives in Postgres, so the database is state worth losing sleep over. Two things mitigate it today:
src/content/content.json is committed, so every published state is in git history and the
site can always be rebuilt from the repository alone.python manage.py export_content --output <file> produces the same document on demand.Neither runs on a schedule. A scheduled dump belongs with the deployment work.
The form does not submit anywhere. It builds a mailto: URL from the field values and hands off to
the visitor’s email client, so no data is transmitted to or stored by this site.
If it ever posts to a backend: validate on the server, rate limit, add CSRF protection once there
is a session, and keep every secret out of the client bundle — NEXT_PUBLIC_* variables are inlined
into the static output and are public by definition.