Back to Blog

Hardening a portfolio site: contact form, PGP-encrypted email, and a real pentest

What it takes to ship an Actix-web site that survives nmap, nikto, sqlmap, and your own paranoia

12 min read
rust security owasp actix-web self-hosting

Published 2026-05-02 · 12 min read

Why this site exists

There is a particular kind of silliness in a security engineer publishing a CV on a site held together by a CMS plugin from 2018. So I wrote my own. Rust, Actix-web, PostgreSQL, HTMX. No frontend framework. No third-party tracking. No dependency on a hosted form provider, no Disqus, no analytics relay. Every layer is something I can read, audit, and replace if I have to.

The site itself is closed-source for now. This post is the documentation: what's in place, what got tested, what failed the test, what I accepted as an unsolvable platform constraint, and what I learnt from running a full pentest against my own deploy.

What I considered first

Before settling on Rust + Actix-web from scratch I looked at three other shapes. None of them survived the security walkthrough.

  • Hosted CMS. Wordpress, Ghost, the usual suspects. Even with hardening guides and good defaults, every plugin is a third party with write access to my content layer. The published-CVE history of CMS plugins is exactly the failure mode this site is meant to avoid.
  • Static site generator. Hugo, Jekyll, Zola. Excellent for content, but a contact form means either a third-party form provider (data flowing through someone else's infrastructure) or a dynamic backend. If I'm writing the backend anyway I'd rather it be the whole site.
  • Modern JavaScript framework on a managed host. Next.js on Vercel, Astro on Cloudflare Pages. Pleasant developer experience. But the supply chain is enormous - hundreds of npm dependencies, three vendor relationships, and the platform's analytics edge sees every request. I wanted fewer parties between visitor and database, not more.

The threat model

Two things to defend.

  • The site itself. Auth on the admin path. No injection. No XSS. No header smuggling. No way for an attacker to read or modify content.
  • Contact form submissions. The contact form is the single channel where strangers can push data into my system. It's also a privacy boundary: people send me their name, email, and a message. If I store that in plaintext I have created a target. If a contact form notification leaks through a compromised mail relay, I have made the same mistake.

The site is small enough that I don't need separate microservices for any of this. It's one Actix-web binary, one PostgreSQL instance, an nginx reverse proxy in front, and a Hetzner VPS in Helsinki running Debian. All the security has to fit inside that.

Auth and the inexpensive-but-effective bits

Predictability is the goal. Auth is one of the places where novelty is a red flag.

  • JWT for session tokens. Access token 15 minutes (configurable), refresh token 7 days. Both signed with HS256 and a secret loaded from env. The middleware reads the token from the Authorization: Bearer ... header first, with an auth_token cookie as a fallback so the admin pages can be browsed without manual header injection. The Bearer flow makes CSRF irrelevant for the protected endpoints (no ambient credential the browser will attach automatically) and the cookie path is gated to the admin routes only.
  • Argon2id for passwords, with Algorithm::Argon2id, OWASP-recommended iteration count (3) as the default, configurable through ARGON2_* env vars. The registration validator follows NIST SP 800-63B-4: a 15-character minimum, a 128-character maximum, no mandatory composition rules, and a small reject-list of obvious passwords. Registration also calls the HaveIBeenPwned k-anonymity API on submission and refuses any password whose SHA-1 prefix returns the matching suffix; HIBP errors are logged and treated as fail-open so the API being down can't lock new sign-ups out.
  • Refresh token rotation. Each refresh issues a new refresh token and invalidates the old. The blacklist for revoked tokens is fail-open by design - if Redis is down the site stays up rather than locking everyone out, but the request is logged. That fail-open is documented in middleware/auth.rs so I don't forget what I chose.
  • RBAC. Admin routes are gated behind JwtAuth + RequireRole::admin(). The role check happens in middleware, not in the handler.
  • 12 security headers. CSP, Reporting-Endpoints, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-XSS-Protection, COOP, CORP, X-Request-ID, X-Permitted-Cross-Domain-Policies (plus a conditional Cache-Control: no-store on /api/* so token responses never end up in a cache). The full set is one middleware. The securityheaders.com scan grades it A+, which is the easy part - the hard part is making sure the rest of the site actually works inside the CSP.

The CSP in particular has bitten me a few times. style-src 'self' blocks both inline style="" attributes and <style> blocks. Tailwind survives because it's self-hosted at /static/css/tailwind.css (built once, cache-busted by content hash) so the policy passes by virtue of 'self'. Alpine.js lives because it doesn't need eval for the patterns I use. But it means any future "let me just inline this one style" instinct has to be resisted, hard. Every styling decision goes into theme.css or onto a Tailwind class.

The contact form, with PGP

Of all the things I built into this site, the contact form is the one that does the most for visitor privacy with the least visible weight.

The contact form does three things on submit:

  1. Validates input. Length caps, MIME-of-text checks, basic anti-bot.
  2. Inserts into PostgreSQL. The handler holds an Option<PgPool> and is fail-open by design: a missing pool or a failing insert is logged but the visitor still gets the same success response, so a database hiccup does not turn the contact form into a side-channel that says "we're down".
  3. Spawns a background task that PGP-encrypts the message body to my ProtonMail public key and sends it as an email.

The PGP step is the interesting one. The encrypted body is the real notification - the email itself is tagged with the visitor's metadata as fallback, but the message contents are locked behind my key. ProtonMail keeps the PGP-armoured body encrypted at rest; decryption happens client-side in the ProtonMail app when I open the message, using my private key, which Proton never sees in plaintext. That means the message contents never sit unencrypted on disk between the VPS and the inbox.

// In src/services/email.rs - the contact-form notification path.
let plaintext  = format_contact_body(sender_name, sender_email, subject, message_body);
let ciphertext = encrypt_message(public_key, plaintext.as_bytes())?;

let email = Message::builder()
    .from(smtp.from_address.parse()?)
    .reply_to(sender_email.parse()?)
    .to(smtp.notification_email.parse()?)
    .subject(format!("[Contact Form] {subject}"))
    .header(ContentType::TEXT_PLAIN)
    .body(ciphertext)?;

Two important details in that snippet that took me a while to get right.

  • The From: header is the configured sender, not the visitor's email. If you put the visitor's email in From: you fail the receiving server's SPF check - the visitor's domain has no record authorising your IP to send on their behalf, and ProtonMail (and basically every modern receiver) rejects or spam-folders the message. The visitor's email goes in Reply-To: instead, so my reply still goes to the right place.
  • Two timeouts, belt and braces. Each send is wrapped in a tokio::spawn with a 10-second timeout, and the SMTP transport itself has a 10-second timeout. If the mail server is slow, the contact handler's response is not delayed. The visitor sees a fast OK; the email either lands or it doesn't, and the message is already in the database either way.

The transport choice was deliberate. I do not use a third-party relay - no SendGrid, no Mailgun, no Gmail SMTP. This is direct MX delivery on port 25 to ProtonMail's MX record, sometimes via mullvad-exclude in development to avoid VPN routing. The reasoning is purely privacy: a third-party relay is a third party with access to a fraction of my contact-form traffic. The cost is that I have to deal with reputation, SPF, and rDNS myself.

In production on Hetzner: SPF TXT record on ericjingryd.com authorising the VPS IP, rDNS pointer set in the Hetzner Cloud Console, port 25 unblocked after the first invoice paid (Hetzner blocks port 25 for the first 30 days on new accounts). Messages arrive in ProtonMail's spam folder until I add the sender to my contacts, which is acceptable for my own inbox. If this site started receiving meaningful contact-form volume, I would revisit and possibly stand up a small relay with proper DKIM. For now, direct MX to one inbox works.

Visitor IP retention with three buckets

This is one of those features where the right answer is not the obvious one.

I want to know who is hitting the site, in aggregate. I do not want to keep IPs forever. GDPR aside, IPs are personal data and the fewer I keep the safer my visitors are. So the system has three retention buckets:

  • Ordinary visitors. IP and timestamp kept for a short rolling window, then aged out by an automatic cleanup job that runs on a schedule.
  • Trusted IPs. IPs marked as trusted (mine, my known peers, monitoring) get kept longer, because I want to be able to distinguish "this is me from my phone" from "this is a stranger".
  • Abuse-review IPs. IPs flagged for review (hit a rate limit, tripped an auth gate, sent malformed input) get held longer for analysis, then aged out.

Three buckets, three retention windows, one cleanup job. The trusted-proxy-aware IP extraction is the housekeeping detail: the VPS sits behind an nginx reverse proxy, so the real client IP is in X-Forwarded-For, but only when the request actually comes from the trusted proxy. The middleware honours a TRUSTED_PROXY_IP env var and refuses to trust forwarded headers from any other source. Otherwise an attacker could spoof their IP by sending a fake X-Forwarded-For, which is a classic way to bypass IP-based rate limits.

The contact form ties into the same extraction, so each contact message in PostgreSQL has the visitor's true IP attached. That IP is part of the per-message admin view, and it ages out under the abuse-review retention bucket if the message ever gets flagged.

The pentest

I ran a full pentest against the deployed site over eight phases. Roughly twelve hours of active scanning, covering reconnaissance, TLS analysis, web-server scanning, authentication probing, injection testing, configuration review, infrastructure scanning, and exploitation attempts. The toolchain:

  • nmap - discovery, version detection, full NSE script set including vuln and auth categories
  • testssl.sh - TLS configuration, cipher suites, certificate chain
  • nikto - web-server misconfigurations, with the full plugin set against / and the API surface
  • sqlmap - injection probing at level 5, risk 3, against the registration form, the login form, and every URL parameter the crawler discovered
  • whatweb - technology fingerprinting (also a fingerprinting baseline for what the site reveals about itself)
  • dig - DNS reconnaissance for SPF, CAA, and the missing-www edge case
  • ab - load and request handling, used to confirm rate limiting actually trips
  • Metasploit - auxiliary modules (http_version, http_header, ssl_version) against the discovered services, for completeness

Results: 0 Critical, 0 High, 0 Medium. 3 Low findings, 3 Informational findings.

Each Low finding got investigated. Two were accepted as platform limitations. One was fixed.

Low 1: CAA records

The site initially had no DNS CAA record, so any CA could in principle issue a certificate for the domain. I added explicit CAA records pointing at Let's Encrypt. They got accepted at the registrar, but Cloudflare's edge appears to inject its own CAA flags on top. The net result is that my CAA records are present but Cloudflare also appears in the CAA chain. I have accepted this as a platform limitation: I do not control Cloudflare's CAA injection, and the explicit records I added are the right answer for a site that doesn't sit behind Cloudflare.

Low 2: OCSP stapling

The pentest flagged OCSP stapling as missing. This was correct at the time and is now correct industry-wide: Let's Encrypt has retired OCSP entirely as the industry migrates to CRLite and similar alternatives. Accepted, with a note in the audit doc explaining why.

Low 3: Stored HTML in contact form

This one was real. The contact form's subject and message fields accepted angle brackets, and the admin view rendered the messages in HTML. An attacker could submit a contact message containing <script>...</script> and the script would execute in my admin browser when I viewed the message.

The fix is sanitise_contact_text(): before storing the message in the database, replace < and > with their fullwidth Unicode equivalents (U+FF1C and U+FF1E). The fullwidth versions look almost identical when rendered as text - they're characters intended for double-byte writing systems - but the HTML parser doesn't recognise them as tag delimiters. So the raw text round-trips visually but cannot escape into the DOM.

pub fn sanitise_contact_text(input: &str) -> String {
    input.replace('<', "\u{FF1C}").replace('>', "\u{FF1E}")
}

Four tests cover this: a <script>...</script> payload that loses both brackets, an <img onerror> payload that loses both brackets, a plain-text round-trip with no replacement, and an explicit assertion that the substituted bytes are U+FF1C and U+FF1E. The fix is in production and the admin view is no longer vulnerable. This was the only real bug the pentest found.

What this whole exercise actually proves

It does not prove the site is unbreakable. No site is unbreakable. What it proves is that I took the time to apply the standard playbook end to end and that an off-the-shelf attack toolchain finds nothing critical. That's a lower bar than "secure", but it's a higher bar than "untested", and the gap between those two is where most published web apps live.

What I did differently from the average tutorial:

  • CSP at deploy time, not post hoc. Every styling and scripting decision had to fit inside the policy from day one. This is annoying. It is also the reason there is no inline <script> to leak into.
  • Fail-open on optional infrastructure. Token blacklist (Redis), email send (SMTP), database insert vs response (PostgreSQL): each one chooses the response that least disrupts the user when its dependency is unavailable, with a logged warning. The wrong choice in any of these is to fail-closed, because then a Redis hiccup is a site outage.
  • Real-IP extraction that won't be tricked. The TRUSTED_PROXY_IP env var is the single source of truth for "is this X-Forwarded-For value real". Anything else is rejected. That same extractor is used for visitor tracking, contact-message attribution, and rate limiting, so they all see the same truth.
  • Three retention buckets, not one. The naive choice is "keep everything for 30 days". The thoughtful choice is "keep different categories for the time that's actually justified by their purpose, and document which is which".

What I'd do differently

If I were starting again I would put the pentest infrastructure in place as a CI step from day one. The way I did it was: build the site, deploy, then run an external pentest against it. That works and produced clean results, but it would be much better to have a cargo test --features pentest target that fires nmap, nikto, and a small SQL injection probe at a localhost test server every PR. Catching regressions early is much cheaper than catching them at deploy.

I would also start the site behind Tor or a similar privacy-respecting frontend earlier in development. Not as a primary endpoint - the site is public and SEO-indexed - but as a secondary one, so that visitors who want maximum privacy have a path that doesn't involve a Cloudflare-style edge with its own analytics.

And I would write the privacy notice before writing the visitor tracking, not after. The order I did it in worked but it meant the notice was being written to fit the implementation rather than the implementation being written to fit the notice. Privacy by design is much easier than privacy by retrofit.

What's next

The next item on the list is end-to-end encryption for contact messages at rest, not just in transit. Currently they sit in PostgreSQL as plaintext. The PGP encryption I described earlier protects the email path, but the database row itself is still readable to anyone with database access. The right design is probably to store only the encrypted blob in PostgreSQL and decrypt it in memory on the admin view, with my private key loaded from a separate path that's not in version control. That's a Phase H item.

Code is at github.com/tidynest. The site itself is closed-source for now; this post is its documentation. The numbers, in one line: 12 security headers, A+ on securityheaders.com and SSL Labs, 0 Critical / 0 High / 0 Medium pentest findings, three retention buckets, one PGP key, one VPS in Helsinki. If you're running a similar setup and you find a hole I missed, send me a contact form. I'll see it eventually.