Security#
Security in this add-on is about one thing: keeping form data under your control while reducing abuse of your forms. The design follows a defense-in-depth model — no single mechanism carries the whole burden, and the mechanisms are layered so that a failure in one layer does not expose the others.
Implementation status#
The submission-validation hardening is complete for new SurveyJS
submission data. @@save-poll validates and normalizes the payload before
notify(), persistence, subscriber processing, mail delivery or configured
external POST actions.
Verified baseline:
bin/test -s zopyx.surveyjs— 195 tests, 0 failures, 0 errors, 7 skips;make test— successful, including 96 passing pytest tests;Ruff and
git diff --check— successful.
The seven skips are explicitly documented publisher-/ZCML-level cases. Stale legacy API tests were removed from discovery rather than silently renamed in the active test suite.
Philosophy#
Defense in depth. Permissions gate who may manage a survey; tokens gate who may submit to it; payload limits and server-side validation gate what may be submitted; audit logging records what happened. Layers are independent and fail closed where it matters.
Fail closed, never fail open. Wherever a protection depends on shared state (the token caches), an unavailable cache rejects the request (HTTP 503) instead of silently allowing it. A degraded deployment is temporarily unusable rather than insecure.
Client-side validation is UX, not security. Everything that happens in the browser can be bypassed. The server never trusts the client: it re-validates (optionally with the external validator binary) and enforces size limits itself.
Secrets stay out of logs and screens. API keys are password fields with a keep-mask convention; audit logging redacts tokens, keys, mail addresses and endpoints; dedicated signing keys are used for dedicated purposes and never fall back to another secret.
Minimal exposure. Endpoints are registered with the least privilege they need (
zope2.Viewvs.cmf.ModifyPortalContentvs.cmf.ManagePortal), results-reading endpoints are restricted to editors, and features can be switched off site-wide to shrink the attack surface.
Threat model#
The mechanisms below address these concrete threats:
Scripted bulk submission / form flooding — the authenticity token.
Replay of captured submissions — one-time token tracking.
Cross-site request forgery — the authenticity token is bound to the form load and cannot be obtained cross-origin; the token-management UI additionally uses Plone’s CSRF authenticator.
Unauthorized access to restricted surveys — trusted access tokens (cached or single-use).
Submission against an outdated form — tokens are bound to the form id and the form version; a new form version invalidates old tokens.
Form scraping and tampered payloads — optional server-side validation (external SurveyJS validator binary) and payload size limits.
Embedding abuse (content injection into third-party pages) — origin allowlists, origin-bound signed tokens, one-time token use, CORS only for allowlisted origins.
Oversized payloads (resource exhaustion) — the per-survey payload limit, enforced with HTTP 413 before parsing.
Insider tampering / unaccounted changes — persistent audit logging with content hashes and redacted details.
Configuration options#
All security-relevant options are described in detail in Global options (site-wide) and Survey options (per survey). This section explains what each option is for.
Global — Security fieldset (Site Setup > Forms)#
authenticity_token_enabled(default: on)Master switch for the submission token. On: every submission must carry a token issued when the form was loaded. This is the primary defense against scripted and cross-site submissions; keep it on unless an integration cannot obtain a token.
authenticity_token_secretHMAC secret signing the tokens. Required when the token is enabled. Rotate only with care — rotation invalidates all outstanding tokens, so visitors must reload the form before submitting.
authenticity_token_ttl_seconds(default: 3600)Token lifetime. A long TTL lets visitors with slow sessions submit without reloading; a short TTL shrinks the replay window. The 120-second clock-skew tolerance is built into validation.
authenticity_token_issuer/authenticity_token_audienceExpected JWT claims. Change them if you operate multiple sites or environments to prevent tokens minted elsewhere from being accepted.
authenticity_token_cache_path(default:var/token_cache.db)Where replay tracking and trusted-token metadata live. Must be writable by the Plone process and not publicly served.
Global — Logging fieldset#
log_ip_addresses/log_user_agentNot protection, but privacy controls: storing client IPs and user-agent strings helps abuse detection and auditing, yet turns them into personal data. Enable deliberately and disclose in your privacy policy.
Global — Direct DOM Embedding fieldset#
embed_direct_global_enabled(default: off)Master switch for Direct DOM embedding. Off by default — the feature is disabled until explicitly enabled.
embed_direct_signing_keyDedicated HMAC key for embed tokens. A dedicated key (not a fallback to the authenticity-token secret) limits the blast radius if one key leaks. Rotate regularly; rotation invalidates outstanding embed tokens.
embed_direct_max_origins(default: 10)Caps the allowed origins per survey — a defense against a misconfigured survey allowing an attacker-controlled origin.
Global — General fieldset#
features_enabledReducing exposure: disabling a feature removes its UI entry points site-wide.
Per-survey — Form Settings#
access_mode(default:public)Who may submit:
public— anyone with the URL. Use for open surveys.trusted— requires a trusted access token in the URL (access_token/ttparameter). Use to restrict a survey to a known group without accounts: generate a link per stakeholder; the token is bound to the survey and expires after the TTL.trusted-tokens— same, but tokens are single-use: each token is consumed by the first successful submission. Use for one-time invitations or vouchers, where each recipient may submit exactly once.
trusted_access_ttl_hours(default: 168)Lifetime of cached trusted tokens (
trustedmode). Shorten it to reduce the abuse window for leaked links; lengthen it to avoid regenerating tokens frequently.force_server_side_validation(default: on)Server-side re-validation of every submission with the external SurveyJS validator binary. On: a tampered or malformed payload is rejected even if the client never validated it.
max_payload_size_mb(default: 1)Hard size limit; oversized requests get HTTP 413 before any parsing or storage. Raise it for file-upload surveys, keep it low otherwise.
Per-survey — Embedding#
embedding_mode(none/iframe/direct)iframeis the safe default for embedding (CSPframe-ancestors).direct(Direct DOM) is experimental and unlocks the full embedding security stack below — it should stay off unless you need it.embed_direct_originsThe allowlist. Origins are normalized (scheme://host) and matched strictly; HTTPS is required outside localhost. Every token and CORS decision is evaluated against this list.
embed_direct_token_ttl(default: 300s, 60–3600)Lifetime of embed tokens. Short values minimize the window in which a stolen token is usable.
Internal security measures#
Authenticity token (anti-CSRF / anti-replay)#
A short-lived JWT signed with HMAC-SHA256 (HS256), issued by @@viewer
and required by @@save-poll in the auth_token form field:
Claims:
iss,aud,iat,nbf,exp,jti(random),form_idandform_version. Validation checks the signature with a constant-time comparison, pins the algorithm to HS256, enforces all time claims with 120 s skew, and requires issuer, audience, form id and form version to match.Version binding is the interesting part: a token is only valid for the form version it was issued for. Publishing a new form version automatically invalidates all outstanding tokens — nobody can submit against a stale schema.
Replay protection is stateful: every received token is recorded in a diskcache (atomic add, 24 h TTL). A second submission with the same token is rejected with
auth_token_replay(403). If the cache is unavailable, the request is rejected (503) — fail closed.
Trusted access tokens#
trustedmode: tokens aresecrets.token_urlsafe(16)values stored with metadata (form id, form version, issued/expiry, state) in the same diskcache. Validation checks existence, theREVOKEDstate and the form binding. Tokens travel asaccess_tokenorttrequest parameters.trusted-tokensmode: tokens come from the ITokenStore adapter (managed in@@token-store: generate, CSV import/export, stats). They are validated withhas_tokenand consumed only after a successful submission (invalidate(reason="user_submission")) — a failed submission does not burn the token. Editors and Managers bypass the checks; the token store view itself is CSRF-protected with Plone’s authenticator and permission-checked.
Direct DOM embedding stack#
Tokens are PyJWT HS256 JWTs with a dedicated signing key, verified with fixed
aud/issvalues (embed-client/privacyforms.studio) and an origin claim that must equal the request’sOriginheader.One-time use: the token’s
jtiis atomically marked used at submission time; a replayed token is rejected (token_already_used, 403). Cache failure → reject (fail closed).Origin validation: HTTPS only (HTTP tolerated solely for localhost/127.0.0.1/::1), no path, query, fragment or trailing slash; allowlist comparison on normalized
scheme://host.CORS discipline: preflight and responses carry CORS headers only for allowlisted origins (no wildcard). Unknown origins get an empty 204 — the browser blocks the actual request. Responses additionally set
X-Content-Type-Options: nosniff,X-Frame-Options: DENYandReferrer-Policy: strict-origin-when-cross-origin.
Payload and validation hardening#
Payload size is enforced against
Content-Lengthand the actual body before JSON parsing (413 request_too_large/json_too_large).With
force_server_side_validationenabled, submissions are handed to the external SurveyJS validator binary; a failed validation returns the error (with details) and the submission is not stored.The Python boundary validator additionally requires a top-level object and rejects unknown fields where applicable. The
missing_requiredcheck is retained behindenforce_required_fields=Truebut is disabled by default for compatibility.When enabled, the
missing_requiredcheck rejects empty required values; it is disabled by default. Dangerous markup and event-handler attributes, control characters and unsafe URL schemes remain enforced.Structured files use a restrictive MIME allowlist, valid Base64 data URLs, MIME consistency checks and magic-byte verification. SVG and
application/octet-streamare rejected by default.File count, file size and payload limits are enforced. Unicode filenames are normalized to NFC before storage. Normalization operates on a copy and does not mutate the caller’s payload.
Validation failures use deterministic error codes and warning-level logging without recording submission contents. Invalid submissions are stopped before event dispatch and storage.
Single-use embed/trusted tokens are consumed only after validation succeeds.
The complete validation contract is documented in
Validation; the requirement-by-requirement evidence is in
SUBMISSION_VALIDATION_REQUIREMENTS.md.
Known residual scope#
The submission validator is not a blanket fix for unrelated security findings. The following remain separate work items unless implemented elsewhere:
CSRF enforcement in the public JSON view itself, or publisher-level tests proving the surrounding Plone protection layer;
SSRF validation for configured
post_endpoint_urldestinations;output encoding for stored values rendered by result views;
rate limiting, quotas, bot controls, dependency pinning and key rotation.
Permission model#
Public rendering (
@@viewer,@@get-form-json) iszope2.Viewbut subject to the survey’s access mode and token checks.Everything that reads or mutates results and forms (
@@get-polls-json*, downloads, version endpoints, AI) requirescmf.ModifyPortalContent;@@delete-resultsre-checks Manager inside; administration (@@forms-settings,@@token-store,@@survey-monitor,@@llm-models) requirescmf.ManagePortal.
Audit logging#
Persistent audit entries (via the persistent logger) record:
Form version changes — including a SHA-256 of the JSON, page/element/ question counts and added/removed question names, so a form change can be attributed and reconstructed.
Control panel changes and metadata updates.
Embed security events (
embed.token.issued/embed.token.validated,embed.submission.accepted/embed.submission.rejectedwith reason).
All details are redacted by default: mail subjects/bodies/addresses,
POST endpoints and anything whose field name contains password,
secret, token, apikey/api_key is logged only as a redaction
marker with length/count — never with its value.
Secrets handling#
Secrets use
zope.schema.Passwordfields and a keep-mask convention in the control panel: an empty submitted value never overwrites a stored key.Dedicated keys are dedicated: the embed signing key does not fall back to the authenticity-token secret; both are stored in the Plone registry (ZODB), not in files or code.
Operational guidance#
Serve everything over HTTPS; the origin validation requires it outside localhost anyway.
Put rate limiting on submission endpoints at the reverse proxy or WAF — the authenticity token stops scripted submission from within a browser session, but a determined client can always fetch a fresh token; rate limiting is the complementary control.
Protect and monitor the token cache paths (
var/token_cache.db,var/embed_token_cache.db): they contain replay/token state and must be writable by Plone but not publicly served.Watch the logs for repeated
auth_token_replay, oversized-payload rejections andembed.submission.rejectedentries — they are the visible signs of probing or abuse.Rotate the two signing secrets (authenticity token secret, embed signing key) on a schedule or after a suspected leak, and plan for the resulting token invalidation (visitors reload the form; embeds re-issue tokens).
Further reading#
Global options — all global settings, defaults and field reference.
Survey options — per-survey settings, including access mode and embedding.
Endpoints — the endpoints these measures protect, with parameters and error codes.
Validation — client- and server-side validation details.