ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
#!/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 ]