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") { diff --git a/vercel.json b/vercel.json index c55ca3c..f74d438 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", + "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" + } ] } ]