From 52b17d7559c9ee580e364bbecbb54e5dcb15e7ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 05:57:10 +0000 Subject: [PATCH 1/3] security: harden contact endpoint and promotion workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of this public repo confirmed no secrets/tokens are committed (current tree or full history). Applied defense-in-depth hardening for the few abuse vectors found: - api/contact.js: strip control chars (incl. CR/LF) from input so it can't smuggle structure into the email subject/headers; add a best-effort per-instance rate limit (5/min/IP -> 429) to blunt floods that would burn the Resend quota or spam the inbox. - .github/workflows/promote-to-main.yml: pass `inputs.pr_title` and `github.actor` through `env` instead of interpolating `${{ … }}` directly into the run script (avoids the Actions shell script-injection antipattern). - README: document the contact endpoint's anti-abuse posture and the durable rate-limit upgrade path. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0138qS6baPZutQ7s2fMbfNv3 --- .github/workflows/promote-to-main.yml | 9 ++++++-- README.md | 6 ++++++ api/contact.js | 31 ++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/.github/workflows/promote-to-main.yml b/.github/workflows/promote-to-main.yml index fde862f..0335bc7 100644 --- a/.github/workflows/promote-to-main.yml +++ b/.github/workflows/promote-to-main.yml @@ -41,6 +41,11 @@ jobs: # Fine-grained PAT (Contents + Pull requests: read/write) — the built-in # GITHUB_TOKEN is blocked from creating PRs by org policy, so we use a PAT. GH_TOKEN: ${{ secrets.PROMOTE_TOKEN }} + # Pass run inputs through the environment rather than interpolating + # ${{ … }} straight into the script, which would let crafted text run + # as shell (GitHub Actions script-injection antipattern). + PR_TITLE: ${{ inputs.pr_title }} + DISPATCHER: ${{ github.actor }} run: | set -euo pipefail if [ -z "${GH_TOKEN:-}" ]; then @@ -58,8 +63,8 @@ jobs: pr="$(gh pr list --base main --head dev --state open --json number --jq '.[0].number')" if [ -z "$pr" ]; then url="$(gh pr create --base main --head dev \ - --title "${{ inputs.pr_title }}" \ - --body "Automated promotion of \`dev\` → \`main\`, dispatched by @${{ github.actor }}.")" + --title "$PR_TITLE" \ + --body "Automated promotion of \`dev\` → \`main\`, dispatched by @${DISPATCHER}.")" pr="${url##*/}" fi diff --git a/README.md b/README.md index a06e0ea..96f3539 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ Until `RESEND_API_KEY` and `CONTACT_FROM` are set, the endpoint returns `503 { code: "not_configured" }` and the form tells the visitor to email us directly — it never silently drops a message. +The endpoint sanitises input, drops bots via a honeypot, and applies a +best-effort per-instance rate limit (5 requests/minute/IP → `429`). Because +Vercel functions are ephemeral and horizontally scaled, that limit is not a +hard global cap; if abuse becomes a concern, back it with a durable store +(Vercel KV / Upstash Redis) for a true cross-instance limit. + ## License Apache-2.0. diff --git a/api/contact.js b/api/contact.js index dc89237..adf035d 100644 --- a/api/contact.js +++ b/api/contact.js @@ -20,8 +20,30 @@ const ROUTES = { const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Best-effort, per-instance rate limit. Vercel functions are ephemeral and +// horizontally scaled, so this is not a hard global cap — it just blunts a +// trivial single-source flood that would burn the Resend quota or spam the +// inbox. A durable limiter (Vercel KV / Upstash) would be the real fix; see +// the security note in the README. +const RATE_LIMIT = { max: 5, windowMs: 60_000 }; +const hits = new Map(); // ip -> number[] (timestamps within the window) + +function rateLimited(ip) { + const now = Date.now(); + const recent = (hits.get(ip) || []).filter((t) => now - t < RATE_LIMIT.windowMs); + recent.push(now); + hits.set(ip, recent); + if (hits.size > 5000) hits.clear(); // bound memory on a long-lived instance + return recent.length > RATE_LIMIT.max; +} + function clamp(s, n) { - return String(s == null ? "" : s).slice(0, n).trim(); + // Strip control chars (incl. CR/LF) so user input can't smuggle structure + // into the email subject/headers, then bound length. + return String(s == null ? "" : s) + .replace(/[\x00-\x1F\x7F]/g, " ") + .slice(0, n) + .trim(); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => @@ -35,6 +57,13 @@ export default async function handler(req, res) { return res.status(405).json({ error: "Method not allowed" }); } + // Per-instance throttle to blunt floods (see rateLimited above). + const ip = (req.headers["x-forwarded-for"] || "").split(",")[0].trim() || "unknown"; + if (rateLimited(ip)) { + res.setHeader("Retry-After", "60"); + return res.status(429).json({ error: "Too many requests. Please try again in a minute." }); + } + // Body may arrive parsed (object) or raw (string) depending on runtime. let body = req.body; if (typeof body === "string") { From 0b388e4ef1ba87cff6342dea5dbdf363c8120052 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 06:07:42 +0000 Subject: [PATCH 2/3] security: add HSTS + Permissions-Policy + CSP (report-only), CodeQL scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headers (vercel.json, applied to all routes): - Strict-Transport-Security (2y, includeSubDomains) — enforce HTTPS. - Permissions-Policy — deny camera/mic/geolocation/browsing-topics. - Content-Security-Policy-Report-Only — locks default-src to 'self' with explicit allowances for the self-hosted bundle/fonts, Vercel Analytics, and the Hugging Face media CDN. Shipped report-only so violations surface in the browser console / report endpoint on the live site before switching to an enforcing `Content-Security-Policy`; the HF CDN redirects to hosts that can't be enumerated from CI, so report-only is the safe first step. Verified locally: production build emits no inline scripts/styles, and serving dist/ with these exact headers returns all first-party assets (JS/CSS/fonts/ SVG/SPA route) 200 — the 'self' policy won't starve the page. Also adds a CodeQL code-scanning workflow (free for public repos) so results land under Security → Code scanning. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0138qS6baPZutQ7s2fMbfNv3 --- .github/workflows/codeql.yml | 33 +++++++++++++++++++++++++++++++++ vercel.json | 8 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9cd1ed7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +# GitHub code scanning (CodeQL). Free for public repositories — results show up +# under the repo's Security → Code scanning tab. Secret scanning and push +# protection are separate toggles in Settings → Code security (also free for +# public repos) and can't be enabled from a workflow file. +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + schedule: + - cron: "27 4 * * 1" # weekly, Mondays — catches advisories on untouched code + +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # required to upload CodeQL results + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/vercel.json b/vercel.json index c55ca3c..48dcda9 100644 --- a/vercel.json +++ b/vercel.json @@ -11,7 +11,13 @@ "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, - { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), browsing-topics=()" }, + { + "key": "Content-Security-Policy-Report-Only", + "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https://huggingface.co https://*.huggingface.co https://*.hf.co; media-src 'self' https://huggingface.co https://*.huggingface.co https://*.hf.co; connect-src 'self' https://va.vercel-scripts.com https://*.vercel-insights.com https://huggingface.co https://*.huggingface.co https://*.hf.co; upgrade-insecure-requests" + } ] } ] From c172064d995ee74bdea8a35df69444e842d66333 Mon Sep 17 00:00:00 2001 From: Adrian Llopart Date: Sun, 21 Jun 2026 10:11:13 +0200 Subject: [PATCH 3/3] security: enforce CSP (verified in-browser against the real CDN) Flipped Content-Security-Policy-Report-Only -> Content-Security-Policy after validating the policy with a headless Chromium against the production build: - Drove the full page (load + lazy-loaded showcase + contact POST) under the policy in ENFORCING mode and observed ZERO securitypolicyviolation events. - Confirmed the Hugging Face media chain resolves huggingface.co (302) -> cas-bridge.xethub.hf.co (206) -> us.aws.cdn.hf.co (206); both CDN hosts are covered by the existing `*.hf.co` allowance, so video posters/clips load. - Confirmed Vercel Analytics loads same-origin (/_vercel/...) under script-src 'self', and the contact form POST passes under connect-src 'self'. Report-only enforced nothing; this makes the policy active. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0138qS6baPZutQ7s2fMbfNv3 --- vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index 48dcda9..f74d438 100644 --- a/vercel.json +++ b/vercel.json @@ -15,7 +15,7 @@ { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains" }, { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), browsing-topics=()" }, { - "key": "Content-Security-Policy-Report-Only", + "key": "Content-Security-Policy", "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self'; script-src 'self' https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https://huggingface.co https://*.huggingface.co https://*.hf.co; media-src 'self' https://huggingface.co https://*.huggingface.co https://*.hf.co; connect-src 'self' https://va.vercel-scripts.com https://*.vercel-insights.com https://huggingface.co https://*.huggingface.co https://*.hf.co; upgrade-insecure-requests" } ]