portfolio

Security

What the deployment model gives you for free

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.

Active measures

Known limitation: response headers

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:

  1. Put a CDN that supports header rules in front (Cloudflare, Fastly).
  2. Move hosting to something that serves headers — the 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.
  3. Meta-tag CSP, which covers less and is easy to get wrong.

The inline theme script

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.

Control panel

The panel (admin/, admin-ui/) is a separate service and a separate risk profile from the static site. What is in place:

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.

Backups

Content now lives in Postgres, so the database is state worth losing sleep over. Two things mitigate it today:

Neither runs on a schedule. A scheduled dump belongs with the deployment work.

Contact form

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.