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 — 472 tests, 0 failures, 0 errors, 75 skips (66 database-container tests requiring RUN_DB_CONTAINER_TESTS=1 and Docker, 2 subprocess-race tests, 7 documented publisher-/ZCML-level cases);

  • make test — successful, including 115 passing pytest tests for the Plone-free subset (converters, schema, validator wrapper);

  • Ruff and git diff --check — successful.

The seven publisher-/ZCML-level skips are explicitly documented. 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.View vs. cmf.ModifyPortalContent vs. 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_secret

HMAC 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_audience

Expected JWT claims. Change them if you operate multiple sites or environments to prevent tokens minted elsewhere from being accepted.

kv_cache_directory (default: var/surveyjs-cache)

Where replay tracking and trusted-token metadata live: the auth namespace below this directory. Must be writable by the Plone process and not publicly served. On a multi-server deployment select the rdbms caching backend instead — see Storage Backends.

post_endpoint_validation_mode (default: public)

Controls validation of survey post_endpoint_url destinations. In public mode, the hostname does not need to be on a site-wide list, but it must still pass the outbound endpoint checks described below. In allowlist mode, the hostname must also match an exact hostname or a *.suffix entry in post_endpoint_allowlist.

post_endpoint_allowlist

One hostname or wildcard pattern per line for strict allowlist mode. At least one non-empty entry is required when that mode is selected. Entries are normalized case-insensitively and a trailing dot is ignored; a wildcard matches subdomains of its suffix.

Global — Submission rate limiting (Security fieldset)#

submission_rate_limit_enabled (default: on)

Admission control for public submissions. Every request that reaches @@save-poll consumes one slot in a per-survey, per-client bucket — attempts, not accepted submissions, so a rejected flood keeps its own budget drained. Turn it off only for surveys that must accept unmetered traffic.

submission_rate_limit_per_minute and submission_rate_limit_per_hour (defaults: 120 / 1200)

Bucket sizes. Requests over the limit are answered with 429 rate_limited and a Retry-After header (seconds until the current window rolls over) and are not stored. Raise the per-minute value for surveys behind a shared NAT or reverse-proxy address, where many respondents look like a single client.

submission_rate_limit_trust_proxy (default: off)

Key the bucket on the right-most X-Forwarded-For entry instead of REMOTE_ADDR. Enable only when every request passes through a reverse proxy that appends that header — otherwise clients can forge their own bucket key. A spoofed key only sidesteps the limiter, it never grants access to other surveys.

The limiter fails closed: when the bucket store (kv_cache_directory or the rdbms caching backend) cannot be read or written, submissions are refused with 503 rate_limit_unavailable instead of being admitted.

Global — Logging fieldset#

log_ip_addresses / log_user_agent

Not 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_key

Dedicated 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_enabled

Reducing 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/tt parameter). 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 (trusted mode). 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)

iframe is the safe default for embedding (CSP frame-ancestors). direct (Direct DOM) is experimental and unlocks the full embedding security stack below — it should stay off unless you need it.

embed_direct_origins

The 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#

Token lifecycle: issued per form load, checked on submission, consumed on success, with the rejected states (replayed, expired) and the fail-closed 503 when the replay store is unreadable

A token is minted when the form is loaded, checked while the submission arrives, and spent only after validation succeeded. The states below the rail are the ways a token stops being usable — and the cache failure that rejects a submission instead of allowing it.

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_id and form_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. The marker is released again when the request’s transaction aborts: Zope retries a request that hit a ZODB ConflictError by re-running @@save-poll with the same token, which must not be mistaken for a replay. Only a committed submission consumes its token for good.

Trusted access tokens#

  • trusted mode: tokens are secrets.token_urlsafe(16) values stored with metadata (form id, form version, issued/expiry, state) in the same diskcache. Validation checks existence, the REVOKED state and the form binding. Tokens travel as access_token or tt request parameters.

  • trusted-tokens mode: tokens come from the ITokenStore adapter (managed in @@token-store: generate, CSV import/export, stats). They are validated with has_token and 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/iss values (embed-client / privacyforms.studio) and an origin claim that must equal the request’s Origin header.

  • Survey binding: the sub claim must equal the target survey’s UID (otherwise survey_mismatch, 403) and the target survey must still be in direct embedding mode (otherwise direct_embedding_not_enabled, 403). A token issued for one survey therefore cannot authorize a submission to another survey, even when both allow-list the same origin.

  • One-time use: the token’s jti is 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: DENY and Referrer-Policy: strict-origin-when-cross-origin.

Payload and validation hardening#

  • Payload size is enforced against Content-Length and the actual body before JSON parsing (413 request_too_large / json_too_large).

  • With force_server_side_validation enabled, 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_required check is retained behind enforce_required_fields=True but is disabled by default for compatibility.

  • When enabled, the missing_required check rejects empty required values; it is disabled by default. Dangerous markup and event-handler attributes and control characters remain enforced. URL attributes are not restricted on input — a legitimate answer may contain a link — so the generated result HTML is sanitized instead (see below).

  • Generated result HTML is sanitized with a parser-based allow list before it is rendered (result detail view), exported (HTML/PDF) or mailed: elements, attributes and URL schemes are checked against an allow list; javascript:/vbscript: and non-image data: URLs (including java\tscript:) and the content of script/style/svg/ iframe are removed (converters/sanitize.py). For PDF rendering, write_pdf() applies the allow list again in data-images-only mode, so external and relative image sources are dropped before WeasyPrint can fetch them; already inlined data images remain available.

  • CSV/XLSX exports neutralise spreadsheet formula injection: values starting with =, +, -, @, tab or CR are written as text (CSV: leading apostrophe; XLSX: explicit string cells) so an answer cannot execute or build risky links when an Editor opens the export (converters/spreadsheet.py).

  • Structured files use a restrictive MIME allowlist, valid Base64 data URLs, MIME consistency checks and magic-byte verification. SVG and application/octet-stream are 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.

Outbound POST / SSRF policy#

Survey POST actions can send the accepted submission, the latest form schema and the survey URL to post_endpoint_url. The automatic submission subscriber and the results-page manual POST both call the same validator before making the request. The action is skipped or refused when the survey does not enable post, no endpoint is configured, or the endpoint fails validation.

The validator applies these checks in both public and allowlist mode:

  • The URL must use http or https and must not contain username or password credentials.

  • The hostname is resolved before the request. Every returned address is checked, and any private, loopback, link-local, multicast, reserved or unspecified address is rejected.

  • DNS failures, invalid ports, empty hostnames and hostnames with no resolved addresses fail closed. In allowlist mode, the hostname must additionally match the configured exact or *.suffix allowlist entry.

  • Requests use a 10-second timeout and do not follow redirects. A rejected automatic POST is logged and skipped; a rejected manual POST reports that the configured endpoint is not allowed and returns to the results page.

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;

  • the submission limiter’s concurrent read-modify-write race, plus per-form quotas, bot controls, dependency pinning and key rotation.

Permission model#

  • Public rendering (@@viewer, @@get-form-json) is zope2.View but 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) requires cmf.ModifyPortalContent; @@delete-results re-checks Manager inside; administration (@@forms-settings, @@token-store, @@survey-monitor, @@llm-models) requires cmf.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.rejected with 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.Password fields 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 KV cache directories (var/surveyjs-cache/auth, var/surveyjs-cache/embed, var/surveyjs-cache/monitoring or the configured kv_cache_directory): 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 and embed.submission.rejected entries — 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.