Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/promote-to-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 30 additions & 1 deletion api/contact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -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") {
Expand Down
8 changes: 7 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
Expand Down
Loading