#!/usr/bin/env bash # Public contact form: validation, honeypot, rate limiting, storage, and the same-origin gate. set -u B=${BASE:-http://127.0.0.1:3111} # Refuses early, with the fix, when the server under test is in production mode with # Turnstile refusing every auth route — otherwise the first signup fails and every check # after it reports a security-check error instead of what it was testing. . "$(dirname "$0")/e2e-preflight.sh"; e2e_preflight "$B" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); echo " ✓ $1"; } fail() { FAIL=$((FAIL+1)); echo " ✗ $1 :: $2"; } check(){ local name=$1 out=$2 pat=$3; if echo "$out" | grep -q "$pat"; then ok "$name"; else fail "$name" "$(echo "$out" | head -c 200)"; fi; } # Same-origin JSON gating means the Origin header has to look like a real browser post. post() { curl -s -X POST "$B/api/contact" -H 'content-type: application/json' -H "origin: $B" -H "x-forwarded-for: $2" -d "$1"; } TS=$(date +%s) # A fresh /24 per run. The limiter allows five messages an hour per address and remembers them for # that hour, so fixed addresses would start answering 429 on the fifth run of this suite inside an # hour — reported as a validation failure, which is a lie about what broke. NET=10.$((RANDOM%250)).$((RANDOM%250)) echo "== validation" check "name required" "$(post '{"email":"a@b.com","message":"Hello there, this is long enough."}' "$NET.1")" 'Add your name' check "email required" "$(post '{"name":"A","message":"Hello there, this is long enough."}' "$NET.2")" 'Add an email' check "bad email caught" "$(post '{"name":"A","email":"nope","message":"Hello there, this is long enough."}' "$NET.3")" 'Add an email' check "short message caught" "$(post '{"name":"A","email":"a@b.com","message":"hi"}' "$NET.4")" 'Say a little more' echo "== honeypot" # The payload carries nothing but the honeypot field, and would be refused on the missing name if it # ever reached validation — that is the point. A well-formed message with `company` added on top # answers {"ok":true} whether the honeypot runs, has been deleted, or has been inverted into storing # the spam, so it can only ever prove that the route is up. check "honeypot short-circuits before validation" "$(post '{"company":"spam co"}' "$NET.5")" '"ok":true' echo "== a real message" check "accepted" "$(post "{\"name\":\"Real Person\",\"email\":\"real$TS@example.com\",\"role\":\"Coordinator\",\"facility\":\"Test Hospital\",\"topic\":\"A question\",\"message\":\"Does this handle nursing entitlements the way we do them?\"}" "$NET.6")" '"ok":true' echo "== cross-origin is refused" # CSRF is a browser-only attack: a browser always sends Sec-Fetch-Site, and Origin on a cross-origin # POST, and a cross-site page cannot suppress either. Those are the shapes worth refusing. check "cross-site fetch refused" "$(curl -s -X POST "$B/api/contact" -H 'content-type: application/json' -H 'sec-fetch-site: cross-site' -H "origin: $B" -d '{"name":"X","email":"a@b.com","message":"aaaaaaaaaaaa"}')" 'Cross-site request refused' check "form-encoded post refused" "$(curl -s -X POST "$B/api/contact" -H 'content-type: application/x-www-form-urlencoded' -H "origin: $B" -d 'name=X')" 'Expected JSON' # The words the origin comparison itself produces, rather than the bare string "error": every # refusal this route can make is shaped {"error":"…"}, so that matched a rate limit, a parse failure # or a Turnstile error just as happily as the check this line is named after. check "foreign origin refused" "$(curl -s -X POST "$B/api/contact" -H 'content-type: application/json' -H 'origin: https://evil.example' -H "x-forwarded-for: $NET.7" -d '{"name":"X","email":"a@b.com","message":"aaaaaaaaaaaa"}')" 'Cross-site request refused' echo "== rate limit" IP=$NET.9 # Both edges of the limit, because five is a quantity rather than a yes/no. Watching only for a # refusal stays green on a limiter tightened to one message an hour — and every nurse in a hospital # reaches this form from behind the same NAT address, so that shape turns a whole site away silently. FIFTH="" for i in 1 2 3 4 5; do FIFTH=$(post "{\"name\":\"Flood $i\",\"email\":\"f$i-$TS@x.com\",\"message\":\"Message number $i for the flood test.\"}" "$IP"); done check "fifth from one address still accepted" "$FIFTH" '"ok":true' check "sixth from one address blocked" "$(post "{\"name\":\"Flood 6\",\"email\":\"f6-$TS@x.com\",\"message\":\"Message number six for the flood test.\"}" "$IP")" 'few messages in a short time' echo "== the form page carries the form" PAGE=$(curl -s "$B/contact") # Strings only the live form emits. The old alternation ended in a bare "Message", which matches the # word anywhere on the page, so every field could have been deleted and this still passed. check "contact page has the form" "$PAGE" 'What’s this about' check "contact page has the message box" "$PAGE" 'What are you trying to do, and what’s in the way?' # The form is a client component, so its fetch("/api/contact") is compiled into a JS chunk and never # reaches the page HTML — grepping the page for "contact" matched the URL, the nav and the mailto # address instead, and would have gone on passing with the form pointed at the wrong endpoint. # -g because chunk filenames in dev carry brackets, which curl would otherwise read as a glob. CHUNKS=$(echo "$PAGE" | grep -o 'static/chunks/[^"\\]*\.js' | sort -u) check "form posts to the api" "$(for c in $CHUNKS; do curl -sg "$B/_next/$c"; done)" '"/api/contact"' echo; echo "PASS=$PASS FAIL=$FAIL"; [ "$FAIL" -eq 0 ]