Files
threadcount-community/scripts/e2e-contact.sh
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

71 lines
5.4 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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" 'Whats this about'
check "contact page has the message box" "$PAGE" 'What are you trying to do, and whats 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 ]