smoke-auth.sh 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. #!/usr/bin/env bash
  2. # End-to-end check of the auth gate against a local `wrangler dev`.
  3. #
  4. # Verifies the acceptance criteria for the gate: unauthenticated requests reach
  5. # nothing (pages, API, or static assets), a valid cookie reaches everything, and
  6. # a tampered cookie is rejected. Run it after touching src/auth.ts or the route
  7. # table in src/index.ts.
  8. #
  9. # ./scripts/smoke-auth.sh
  10. set -uo pipefail
  11. cd "$(dirname "$0")/.."
  12. # Deliberately NOT $PORT: that is commonly already set to some other local dev
  13. # server, and the whole suite would then silently test the wrong app.
  14. DASH_PORT="${DASH_PORT:-8788}"
  15. BASE="http://127.0.0.1:${DASH_PORT}"
  16. PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)"
  17. JAR="$(mktemp -t cg-dash-jar)"
  18. LOG="$(mktemp -t cg-dash-log)"
  19. DEV_VARS_BACKUP="$(mktemp -t cg-dash-vars)"
  20. PASS=0
  21. FAIL=0
  22. cleanup() {
  23. [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null
  24. # The rotation phase rewrites .dev.vars; always put the original back.
  25. [[ -s "$DEV_VARS_BACKUP" ]] && cp "$DEV_VARS_BACKUP" .dev.vars
  26. rm -f "$JAR" "$LOG" "$DEV_VARS_BACKUP"
  27. }
  28. trap cleanup EXIT
  29. # `curl -o /dev/null -w '%{http_code}'` plus the headers we care about.
  30. status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
  31. body() { curl -s "$@"; }
  32. check() { # check <description> <expected> <actual>
  33. if [[ "$2" == "$3" ]]; then
  34. printf ' ok %s\n' "$1"
  35. PASS=$((PASS + 1))
  36. else
  37. printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3"
  38. FAIL=$((FAIL + 1))
  39. fi
  40. }
  41. contains() { # contains <description> <needle> <haystack>
  42. if [[ "$3" == *"$2"* ]]; then
  43. printf ' ok %s\n' "$1"
  44. PASS=$((PASS + 1))
  45. else
  46. printf ' FAIL %s (missing %q in %.200q…)\n' "$1" "$2" "$3"
  47. FAIL=$((FAIL + 1))
  48. fi
  49. }
  50. lacks() { # lacks <description> <needle> <haystack>
  51. if [[ "$3" != *"$2"* ]]; then
  52. printf ' ok %s\n' "$1"
  53. PASS=$((PASS + 1))
  54. else
  55. printf ' FAIL %s (found %q)\n' "$1" "$2"
  56. FAIL=$((FAIL + 1))
  57. fi
  58. }
  59. echo "Seeding local D1 from the ingest worker's migration…"
  60. npx wrangler d1 execute codegraph-telemetry --local \
  61. --file=../telemetry-worker/migrations/0001_init.sql >/dev/null 2>&1
  62. echo "Starting wrangler dev on :${DASH_PORT}…"
  63. npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
  64. DEV_PID=$!
  65. READY=""
  66. for _ in $(seq 1 90); do
  67. if [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi
  68. sleep 1
  69. done
  70. if [[ -z "$READY" ]]; then
  71. echo "wrangler dev never came up on :${DASH_PORT} — log follows"
  72. cat "$LOG"
  73. exit 1
  74. fi
  75. echo
  76. echo "Unauthenticated — nothing but the login page and robots.txt"
  77. check "GET / → 302 to login" 302 "$(status "$BASE/")"
  78. check "GET /index.html → 302 to login" 302 "$(status "$BASE/index.html")"
  79. check "GET /styles.css → 302 to login" 302 "$(status "$BASE/styles.css")"
  80. check "GET /app.js → 302 to login" 302 "$(status "$BASE/app.js")"
  81. check "GET /vendor/chart → 302 to login" 302 "$(status "$BASE/vendor/chart.umd.js")"
  82. check "GET /api/health → 401" 401 "$(status "$BASE/api/health")"
  83. check "GET /api/session → 401" 401 "$(status "$BASE/api/session")"
  84. check "GET /api/anything → 401" 401 "$(status "$BASE/api/whatever")"
  85. check "GET /login → 200" 200 "$(status "$BASE/login")"
  86. check "GET /robots.txt → 200" 200 "$(status "$BASE/robots.txt")"
  87. contains "no data leaks in the 401 body" '"unauthorized"' "$(body "$BASE/api/health")"
  88. echo
  89. echo "Login page"
  90. LOGIN_HTML="$(body "$BASE/login")"
  91. contains "sentence-case heading" "codegraph telemetry" "$LOGIN_HTML"
  92. contains "sentence-case label" ">Password<" "$LOGIN_HTML"
  93. contains "sentence-case button" ">Sign in<" "$LOGIN_HTML"
  94. lacks "no uppercased labels" "uppercase" "$LOGIN_HTML"
  95. lacks "no tracked-out labels" "letter-spacing" "$LOGIN_HTML"
  96. contains "label is normal size" "font-size: 16px" "$LOGIN_HTML"
  97. check "open redirect refused" "/" \
  98. "$(body "$BASE/login?next=%2F%2Fevil.example" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')"
  99. check "same-origin next kept" "/api/health" \
  100. "$(body "$BASE/login?next=%2Fapi%2Fhealth" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')"
  101. echo
  102. echo "Sign-in"
  103. check "wrong password → 401" 401 \
  104. "$(status -X POST "$BASE/login" -d "password=definitely-not-it" -d "next=/")"
  105. check "wrong password sets no cookie" "" \
  106. "$(curl -s -D - -o /dev/null -X POST "$BASE/login" -d "password=nope" | grep -ci 'set-cookie' | sed 's/^0$//')"
  107. check "empty password → 400" 400 "$(status -X POST "$BASE/login" -d "password=")"
  108. check "cross-origin post → 400" 400 \
  109. "$(status -X POST "$BASE/login" -H 'Origin: https://evil.example' -d "password=${PASSWORD}")"
  110. # One sign-in, then every cookie assertion reads the captured headers. Doing a
  111. # fresh POST per assertion would burn the login rate limit and 429 halfway down.
  112. # The sign-in carries `Origin: null` — what Chromium actually sends on a
  113. # same-origin form submit from a page with our `Referrer-Policy: no-referrer`
  114. # header. Rejecting it locked every Chromium browser out of the login form
  115. # while curl-shaped tests (no Origin at all) kept passing.
  116. SIGNIN="$(curl -s -D - -o /dev/null -c "$JAR" -X POST "$BASE/login" -H 'Origin: null' -d "password=${PASSWORD}" -d "next=/")"
  117. SIGNIN_STATUS="$(printf '%s' "$SIGNIN" | head -1 | awk '{print $2}')"
  118. check "correct password → 302" "302" "$SIGNIN_STATUS"
  119. check "Origin: null (Chromium form post) not rejected" "yes" "$([ "$SIGNIN_STATUS" != "400" ] && echo yes || echo no)"
  120. contains "cookie is HttpOnly" "HttpOnly" "$SIGNIN"
  121. contains "cookie is Secure" "Secure" "$SIGNIN"
  122. contains "cookie is SameSite=Lax" "SameSite=Lax" "$SIGNIN"
  123. contains "cookie is ~1 year" "Max-Age=31536000" "$SIGNIN"
  124. contains "cookie is site-wide" "Path=/" "$SIGNIN"
  125. COOKIE="$(grep cg_admin_session "$JAR" | awk '{print $NF}')"
  126. PAYLOAD="${COOKIE%%.*}"
  127. SIG="${COOKIE#*.}"
  128. # A persistent cookie carries a real expiry in the jar; a session cookie (gone
  129. # on browser restart) carries 0. This is the "survives a restart" criterion.
  130. JAR_EXPIRY="$(grep cg_admin_session "$JAR" | awk '{print $5}')"
  131. if [[ "$JAR_EXPIRY" -gt "$(( $(date +%s) + 300 * 86400 ))" ]]; then
  132. check "cookie persists across browser restarts" "persistent" "persistent"
  133. else
  134. check "cookie persists across browser restarts" "persistent" "session-only (expiry ${JAR_EXPIRY})"
  135. fi
  136. echo
  137. echo "Authenticated — the whole app"
  138. check "GET / → 200" 200 "$(status -b "$JAR" "$BASE/")"
  139. check "GET /styles.css → 200" 200 "$(status -b "$JAR" "$BASE/styles.css")"
  140. check "GET /app.js → 200" 200 "$(status -b "$JAR" "$BASE/app.js")"
  141. check "GET /vendor/chart→ 200" 200 "$(status -b "$JAR" "$BASE/vendor/chart.umd.js")"
  142. check "GET /api/session → 200" 200 "$(status -b "$JAR" "$BASE/api/session")"
  143. check "GET /api/health → 200" 200 "$(status -b "$JAR" "$BASE/api/health")"
  144. contains "health reads D1" '"ok":true' "$(body -b "$JAR" "$BASE/api/health")"
  145. check "GET /login while signed in → 302" 302 "$(status -b "$JAR" "$BASE/login")"
  146. check "unknown API route → 404" 404 "$(status -b "$JAR" "$BASE/api/nope")"
  147. check "POST to an API route → 405" 405 "$(status -b "$JAR" -X POST "$BASE/api/health")"
  148. echo
  149. echo "Tampering"
  150. # Mutate the FIRST signature character, not the last: base64url's final
  151. # character of a 32-byte tag carries only 4 significant bits, so flipping it is
  152. # sometimes a no-op on the decoded bytes and the test would pass vacuously.
  153. FLIPPED="${PAYLOAD}.$([[ "${SIG:0:1}" == 'A' ]] && echo B || echo A)${SIG:1}"
  154. check "flipped signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/api/health")"
  155. check "truncated signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}.${SIG:0:40}" "$BASE/api/health")"
  156. check "swapped payload → 401" 401 \
  157. "$(status -H "Cookie: cg_admin_session=$(printf '%s' '{"v":1,"iat":0,"exp":9999999999,"pw":"x"}' | base64 | tr -d '=' | tr '+/' '-_').${SIG}" "$BASE/api/health")"
  158. check "no signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}" "$BASE/api/health")"
  159. check "garbage cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=not-a-token' "$BASE/api/health")"
  160. check "empty cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=' "$BASE/api/health")"
  161. check "tampered cookie on a page → 302 to login" 302 \
  162. "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/")"
  163. echo
  164. echo "Sign-out"
  165. check "POST /logout → 302" 302 "$(status -X POST "$BASE/logout" -H 'Origin: null')"
  166. contains "logout clears the cookie" "Max-Age=0" \
  167. "$(curl -s -D - -o /dev/null -X POST "$BASE/logout")"
  168. check "GET /logout → 405" 405 "$(status "$BASE/logout")"
  169. check "cross-origin logout → 400" 400 \
  170. "$(status -X POST "$BASE/logout" -H 'Origin: https://evil.example')"
  171. echo
  172. echo "Rate limiting (6 attempts in a minute; the 6th should be capped)"
  173. LAST=""
  174. for _ in 1 2 3 4 5 6 7; do
  175. LAST="$(status -X POST "$BASE/login" -d 'password=guess')"
  176. done
  177. check "brute force capped → 429" 429 "$LAST"
  178. echo
  179. echo "Password rotation (restarting with a different ADMIN_PASSWORD)"
  180. cp .dev.vars "$DEV_VARS_BACKUP"
  181. sed 's/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD="rotated-password"/' "$DEV_VARS_BACKUP" >.dev.vars
  182. kill "$DEV_PID" 2>/dev/null
  183. wait "$DEV_PID" 2>/dev/null
  184. npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
  185. DEV_PID=$!
  186. for _ in $(seq 1 90); do
  187. [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]] && break
  188. sleep 1
  189. done
  190. check "cookie from the old password → 401" 401 \
  191. "$(status -H "Cookie: cg_admin_session=${COOKIE}" "$BASE/api/health")"
  192. check "old password no longer signs in → 401" 401 \
  193. "$(status -X POST "$BASE/login" -d "password=${PASSWORD}")"
  194. check "new password signs in → 302" 302 \
  195. "$(status -X POST "$BASE/login" -d "password=rotated-password")"
  196. echo
  197. printf '%s\n' "-----"
  198. printf '%d passed, %d failed\n' "$PASS" "$FAIL"
  199. [[ "$FAIL" -eq 0 ]]