Skip to content

dotCooCoo/hermitstash-web

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

24 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HermitStash Web — PQC Gateway

A Cloudflare Worker that serves the landing page at hermitstash.com and gates access to the app at app.hermitstash.com on post-quantum TLS support.

The visitor's browser must negotiate a hybrid post-quantum key exchange (X25519MLKEM768) with the app origin; if it can't, the gateway blocks the redirect and shows a browser-upgrade screen instead. This is a defense against "harvest now, decrypt later" attacks — everyone routed through HermitStash is already on PQC the moment they arrive.

How it works

  1. Browser hits https://hermitstash.com/
  2. Worker runs the request filter chain (method → path → rate limit → bot UA → ASN → browser fingerprint); anything that fails is blocked
  3. Worker serves a single static HTML page with a strict CSP, a per-request nonce, and an embedded handshake UI
  4. Client-side JS issues a CORS fetch() to https://app.hermitstash.com/health
  5. If the fetch succeeds, the browser negotiated PQC TLS with the app — redirect to the app
  6. If the fetch fails (for any reason — no PQC, TLS error, aborted, etc.) — show the PQC requirements screen with a link to pq.cloudflareresearch.com so the visitor can verify their browser

The PQC check is end-to-end between the visitor's browser and the app origin. The gateway never proxies the health check — if it did, the whole point would collapse.

Browser requirements

Browser Minimum version
Chrome 131
Edge 131
Firefox 135
Safari 18.4

Request filter chain

Order is load-bearing. Do not reorder.

  1. Method — only GET and HEAD; anything else → 405 Method Not Allowed
  2. Path — only /; any other path → 301 redirect to root
  3. Rate limit — 30 requests / 60 seconds per cf-connecting-ip, in-memory per isolate (best-effort; not a global counter). Probabilistic cleanup (~1% chance per request) keeps the map bounded.
  4. Bot UA blocklist — 37 substring patterns matched case-insensitively against the User-Agent header. Empty UAs are treated as bots. Covers: curl, wget, python, httpie, scrapy, go-http, java/, libwww, php/, ruby, perl, nikto, sqlmap, nmap, masscan, zgrab, semrush, ahref, mj12bot, bytespider, gptbot, ccbot, claudebot, anthropic, dataforseo, headlesschrome, phantomjs, selenium, puppeteer, playwright, applebot, yandexbot, baiduspider, aiohttp, and friends.
  5. Blocked ASNs — TOR exits, known open proxies, and internet scanners. Currently blocks AS209 (TOR), AS396507, AS212238, AS63949, AS211590 (FBW Networks — stripe/env scanner), AS213737 (AYOSOFT — vuln scanner), AS213412 (ONYPHE — internet scanner), AS9009 (UAB code200 — proxy/scanner).
  6. Browser fingerprint — requires Accept-Language, an Accept header containing text/html, and at least one of Sec-Fetch-Dest or Upgrade-Insecure-Requests. Headless tools that forget these get dropped.

Anything that fails after step 2 gets a terse 403 Access Denied with no body leaking why.

Security

Every successful HTML response sets the following headers:

  • Content-Security-Policydefault-src 'none', nonce-based script-src (no unsafe-inline, no unsafe-eval), style-src 'unsafe-inline' https://fonts.googleapis.com, font-src https://fonts.gstatic.com, connect-src https://app.hermitstash.com, img-src 'self' https://assets.hermitstash.com, frame-ancestors 'none', base-uri 'none', form-action 'none'
  • Strict-Transport-Securitymax-age=31536000; includeSubDomains; preload
  • X-Frame-OptionsDENY
  • X-Content-Type-Optionsnosniff
  • Referrer-Policystrict-origin-when-cross-origin
  • Permissions-Policy — camera, microphone, geolocation, payment, USB, bluetooth, serial all disabled
  • Cross-Origin-Opener-Policysame-origin
  • Cross-Origin-Embedder-Policycredentialless (not require-corp; cross-origin R2 assets and Google Fonts don't set CORP headers, so require-corp would block them)
  • Cross-Origin-Resource-Policysame-origin

The script nonce is generated per-request from crypto.getRandomValues(Uint8Array(16)) and templated only into the single <script nonce="..."> tag and the CSP header. Inline event handlers (onclick=, onload=, etc.) are not used and would be blocked if added.

See SECURITY.md for the vulnerability reporting policy.

Project conventions

  • Zero dependencies. The worker is a single file using only the Cloudflare Workers runtime. No npm packages are bundled into the deploy; wrangler is the only devDep.
  • Single entry point. Everything — rate limiter, bot filter, ASN blocklist, security headers, HTML template, client JS — lives in src/worker.js for auditability.
  • No build step. ES modules, plain functions, inline HTML as a JS string array. No TypeScript, no bundler, no asset pipeline.
  • Stateless per-request. No KV, no Durable Objects, no R2 in the critical path. Runs cold on any edge isolate with no warm-up penalty.

File structure

src/worker.js                  The entire worker — filters, headers, HTML, client JS
wrangler.toml                  Cloudflare deploy config
package.json                   npm scripts for wrangler dev/deploy
.github/workflows/deploy.yml   Auto-deploy on push to main
.github/ISSUE_TEMPLATE/        Bug / feature / question templates
SECURITY.md                    Vulnerability reporting policy
LICENSE                        AGPL-3.0-or-later

Static assets (the favicon/logo SVG) are hosted separately at assets.hermitstash.com — the worker only references them via <img src> and CSP img-src.

Development

npm install
npm run dev       # wrangler dev — local at http://localhost:8787
node --check src/worker.js   # syntax check

There is no test suite. Verification is done by hitting localhost:8787 in a real browser (Chrome/Firefox with recent versions), inspecting headers with curl -I from an allowlisted UA, and confirming the PQC gate behaves correctly in both an old browser (block screen) and a new browser (success + redirect).

If adding test coverage ever becomes worthwhile, prefer Miniflare over mocking — the filter logic depends on request.cf and real headers.

Deployment

Platform: Cloudflare Workers

This project deploys as a Cloudflare Worker — not Docker, not a traditional server. The worker runs on Cloudflare's edge network (300+ data centers), with zero cold-start penalty and no infrastructure to manage.

Runtime Cloudflare Workers (V8 isolates)
Build step None — wrangler deploy uploads src/worker.js directly
CI/CD GitHub Actions via cloudflare/wrangler-action@v3
Assets R2 bucket pqc (custom domain: assets.hermitstash.com)
Asset versioning SHA3-512 content hash of pqc.svg, computed at deploy time
TLS Managed by Cloudflare's edge (supports X25519MLKEM768 PQC hybrid)

Auto-deploy via GitHub Actions

Every push to main triggers the full pipeline. See .github/workflows/deploy.yml.

Checkout → Node 24 → npm ci → node --check → Sync public/ to R2 → SHA3-512 hash → wrangler deploy
  • Push to mainwrangler deploy (live) + R2 asset sync + post-deploy smoke test
  • Pull request to mainwrangler deploy --dry-run (validates without deploying, no R2 sync)
  • Manual dispatch → available via the Actions tab

Required repository secrets

Secret Purpose
CLOUDFLARE_API_TOKEN Scoped API token with Workers Scripts:Edit + Workers R2 Storage:Edit (use the "Edit Cloudflare Workers" template)
CLOUDFLARE_ACCOUNT_ID Target Cloudflare account ID

Manual deploy

npm install
npx wrangler deploy

Note: manual deploy skips the R2 asset sync and SHA3-512 computation. The asset version falls back to "dev". Prefer pushing to main.

Configuration

Runtime behavior is hardcoded in src/worker.js. There are no user-facing runtime flags:

  • Rate limit constants live at the top of the file (RATE_LIMIT_WINDOW, RATE_LIMIT_MAX)
  • Bot patterns and blocked ASNs are plain arrays — add to them with a source comment
  • The app origin (https://app.hermitstash.com) is hardcoded in both the CSP connect-src directive and the client-side PQC_ORIGIN constant. The PQC_ORIGIN env var in wrangler.toml exists for documentation only; changing it does not change runtime behavior. If you fork this and point it at a different origin, update the worker source in both places together.

Related repositories

HermitStash is split across three repos, each with its own release cadence:

Contributing

This project is not currently accepting external code contributions — the security surface is small and deliberately kept under single-author review. Bug reports and feature requests via the issue tracker are welcome; see the templates in .github/ISSUE_TEMPLATE/.

If you find a security issue, please follow the coordinated disclosure process in SECURITY.md instead of opening a public issue.

License

AGPL-3.0-or-later — Copyright © 2026 dotCooCoo

About

PQC gateway for hermitstash.com — Cloudflare Worker

Resources

License

Security policy

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors