1bc2de655a
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
71 lines
5.4 KiB
Bash
Executable File
71 lines
5.4 KiB
Bash
Executable File
#!/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 ]
|