Security: signed staff tokens, fail-closed checksum, upload & submit hardening #2

Merged
joelbrock merged 6 commits from security/staff-auth-hardening into main 2026-10-30 11:24:28 -07:00
6 Commits
Author SHA1 Message Date
Joel Brock 335cdd0c23 Refresh Next.js-generated agent rules block in AGENTS.md 2026-10-30 11:20:30 -07:00
Joel Brock 526422abed Give /api/preview-link its own Bearer secret; constant-time token checks
preview-link mints working form links for any contact, but was gated by
HEALTH_TOKEN (the diagnostics secret) passed in the query string, where
it lands in access logs. README already documented PREVIEW_ADMIN_TOKEN
as a Bearer header; make the code match.

- PREVIEW_ADMIN_TOKEN, when set, is required as Authorization: Bearer
  and is never accepted from the URL.
- Until it is set, HEALTH_TOKEN still works (Bearer or legacy ?token=)
  so the support runbook in EMAIL_DELIVERY.md keeps working.
- preview-link and /api/health compare tokens in constant time.
- redact() also scrubs PREVIEW_ADMIN_TOKEN and STAFF_REPORT_KEY.
2026-10-30 11:20:30 -07:00
Joel Brock df471e0e88 Verify client-supplied file ids on submit
/api/submit wrote whatever {id} the client sent into file custom fields.
A link holder could point their org at any civicrm_file id, learn its
filename via /api/report, and make the staff file proxy treat it as
theirs. Each file id must now either be anchored to the submitting
contact (how /api/upload stores uploads) or equal the org's current value
for that field; otherwise the submit is rejected with a 400.
2026-10-30 11:19:07 -07:00
Joel Brock 0a19b57a86 Harden the extension upload endpoint (CSRF, record ACL, mime allowlist)
civicrm/webform-mw/upload accepted any session-authenticated POST, called
civicrm_api3('Attachment','create') (which skips permission checks from
PHP) against any contact/activity id, and stored any client mime type.

- Require X-Requested-With: XMLHttpRequest (already sent by the app), so
  cross-site form posts can't ride a logged-in staff session.
- Require EDIT permission on the target contact/activity.
- Enforce the same mime allowlist as the app's /api/upload route.
2026-10-30 11:18:10 -07:00
Joel Brock c9b610590e Fail closed in verifyChecksum; drop the Contact.get fallback
If Contact.validateChecksum threw for any reason, verifyChecksum retried
with Contact.get plus an unknown `checksum` param. Our API key is
privileged, so if Civi ever ignored that param the probe would resolve
the contact and accept any checksum. Treat errors as invalid instead,
and reject non-positive/non-integer contact ids up front.
2026-10-30 11:17:52 -07:00
Joel Brock 818c979479 Replace raw staff key in URLs with signed, org-bound tokens
The Engagement Report tab wrote STAFF_REPORT_KEY into the iframe src for
every user with "access CiviCRM", without checking contact permissions.
Anyone could copy it from the DOM and read any org's report and files,
bypassing Civi ACLs; the key never expired and landed in access logs.

- Extension (0.4.0): Tab.php checks Contact_Permission::allow(VIEW) and
  mints sig = base64url(HMAC-SHA256(key, "webform-mw.staff-report.v1:
  <org>:<exp>")) with an 8h exp. The secret no longer leaves the server.
- App: lib/staff-token.mjs verifies the token (constant-time, org must
  match, rejects expired or >12h tokens). /staff/report, /api/staff/report
  and /api/staff/file share staffAuthQuery(); client links carry exp/sig.
- Legacy ?key= is still accepted (constant-time compare, logs a warning)
  so the app can deploy before the extension. Remove it once every Civi
  install runs extension 0.4.0.
- PHP/Node signature compatibility is pinned by a PHP-generated vector.
2026-10-30 11:17:28 -07:00