Skip to content

Capabilities, permissions and security

Which capabilities guard Quizably screens and REST routes, how nonces, sanitization, rate limiting, HMAC tokens and webhook signing work, and known gaps.

This page describes how Quizably controls access and handles untrusted input, based on reading the source. It is a factual description with the gaps stated, not a security audit. Behavior marked “from reading the code” was not executed.

Capabilities and roles

GuardApplies to
quizably_manage_quizzesEvery admin REST route: quizzes, questions, answers, results, leads, submissions, analytics, settings, integrations, templates, presets, question bank, CSV exports and the webhook test. Checked with current_user_can() in BaseController::permission_check().
manage_optionsThe “Quiz Builder” admin menu and all its submenus (Dashboard, All Quizzes, Question Bank, Leads, Integrations, Settings).

Key facts:

  • The plugin defines one custom capability, quizably_manage_quizzes. It is added to the Administrator role when the plugin is activated, and only if the role does not already have it. It is not re-added on later requests, and it is not removed on deactivation or uninstall.
  • There is no editor-level or author-level role model. The Roles & access card on the Settings screen is read-only text. It says an Editor can create and edit quizzes, but the code gives access to administrators only, and nothing on that card changes it.
  • The two guards are independent. A user with quizably_manage_quizzes but without manage_options can call the REST API but will not see the menu or load the admin app, because the app is only enqueued on Quizably’s own admin pages. A user with manage_options but without the capability (for example an administrator role created by another plugin before Quizably was activated) sees the menu, and every REST call answers HTTP 403.
  • A failed permission check always returns HTTP 403 with code quizably_forbidden, including for logged-out requests (not 401).
  • There is no per-quiz or per-user ownership check. Anyone with the capability can read and change every quiz, lead and submission. author_id is recorded but is not used for access control.

To give another role REST access (the menu stays restricted to manage_options):

$role = get_role( 'editor' );
if ( $role ) {
    $role->add_cap( 'quizably_manage_quizzes' );
}

Run this once, for example on activation of your own plugin, since capabilities are stored in the database.

Public and authenticated surface

SurfaceAccess
GET /pingPublic, not rate limited. Returns plugin version.
GET /public/quiz/{uuid}Public, rate limited. Published quizzes only.
GET /public/quiz/{uuid}/poll-resultsPublic, rate limited. Published polls only. Returns vote counts for the poll’s first question.
POST /public/submissions/start, /{uuid}/answer, /{uuid}/complete, /{uuid}/optinPublic, rate limited, no nonce, no CAPTCHA.
GET /public/confirm-optinPublic, not rate limited, protected by an HMAC token.
All other quizably/v1 routesRequire quizably_manage_quizzes.
[quizably_quiz_result] shortcodePublic. Reads ?quizably_result=<submission uuid> and prints that submission’s result title, content, score line and call-to-action. There is no state change.
?quizably_embed=ID pagePublic, published quizzes only. Sends X-Frame-Options: ALLOWALL and noindex,nofollow, so any site may frame it.

What the public side implies:

  • Submission UUIDs (version 4, from random_bytes()) act as bearer secrets. Anyone holding one can add or replace answers while the submission is in_progress, and can call the opt-in route for it at any status.
  • The server does not check that a posted question_id or answer_ids belong to the quiz. Unknown IDs are stored and ignored by the scorers.
  • Nothing limits repeat submissions per person other than the IP rate limit. Leads are unverified user input, and consent_gdpr is whatever the client sends.
  • The public quiz payload removes is_correct, points, weights, result conditions and author_id, but still contains each answer’s personality_result_id and question logic.

Nonces

  • Admin REST calls: the admin app receives a wp_rest nonce as QUIZABLY_ADMIN.nonce and sends it as the X-WP-Nonce header. The plugin has no wp_verify_nonce() calls of its own. Verification is done by WordPress core’s cookie authentication for REST requests. Requests authenticated another way (for example application passwords) need no nonce.
  • Public REST calls: no nonce is sent or required.
  • Markup: the shortcode, popup and slide-in output include data-nonce (a wp_rest nonce). The player ignores it. Do not full-page cache pages rendered for logged-in users, because the attribute carries a nonce valid for that user.

Input handling

InputHandling
Public opt-in emailsanitize_email() then is_email().
Lead name, phonesanitize_text_field().
Lead extra_fieldsKeys sanitize_key(), values sanitize_text_field(), values over 1000 characters dropped, 20 keys maximum.
Text answerssanitize_textarea_field(), trimmed, cut to 2000 characters, non-scalar input discarded.
utm_*Five known keys only, sanitize_text_field().
User-Agentsanitize_text_field(), cut to 255 characters.
Quiz slugsanitize_title().
Question description, result contentwp_kses_post() on create and update through the REST API.
SettingsPer-key handling: booleans normalized, emails checked, text through sanitize_text_field(), consent text through sanitize_textarea_field(), alert body through an email-safe wp_kses allowlist. Unknown keys are rejected.
Quiz, question and result title, answer label, media_url and similarStored as submitted. No sanitization in the REST layer.
Quiz importValues are inserted as given.

SQL: repository queries use $wpdb->prepare() with bound values and esc_like() for search. ORDER BY columns are chosen from allowlists (verified for quizzes, leads, submissions and the question bank), and LIMIT and OFFSET are cast to integers.

Output escaping

  • PHP-rendered markup escapes with esc_attr(), esc_html() and esc_url(). The result shortcode runs wp_kses_post() on result content.
  • The hydrated quiz JSON is encoded with JSON_HEX_TAG | JSON_HEX_AMP, so quiz text cannot terminate the script element.
  • Owner emails substitute tokens with HTML-escaped values.
  • Gap: the player renders quiz, question and result titles and descriptions with Vue v-html, and the REST layer does not sanitize titles. The effect is that an account with quizably_manage_quizzes can store markup in a title that runs for every visitor. Administrators normally have unfiltered_html already, but on multisite, or if you grant the capability to a lower role, treat it as a trusted-with-HTML capability. The webhook payload also carries the result title unescaped.
  • The visitor-typed text answers are not rendered back to visitors. They appear in the admin lead detail panel.

Rate limiting

Applies to every public route except GET /public/confirm-optin and GET /ping. It is implemented in Quizably\RateLimit\TokenBucket as two fixed-window counters stored in transients.

ItemValue
Per-minute limit60 (filter quizably_rate_limit_per_minute)
Per-hour limit300 (filter quizably_rate_limit_per_hour)
Minute keyquizably_rl_m_{hash}_{floor(time/60)}, expires after 65 seconds
Hour keyquizably_rl_h_{hash}_{floor(time/3600)}, expires after 3700 seconds
Identifier {hash}`substr( sha256( REMOTE_ADDR . ’
Response when exceededHTTP 429, code quizably_rate_limited

Details that matter in production:

  • The counters are read, compared and then incremented without locking, so a burst of simultaneous requests can slightly exceed the limits.
  • The window is a calendar bucket (the current minute and hour), not a sliding window.
  • Only REMOTE_ADDR is used. Forwarding headers are ignored on purpose so clients cannot spoof them. If the site is behind a proxy or CDN that does not set REMOTE_ADDR to the visitor, every visitor shares one identifier and the whole site is capped at 300 public requests per hour. Use the quizably_rate_limit_ip filter to return the real client address, and only after you have verified that the proxy is trusted.
  • A 10 question quiz needs about 13 calls, so one IP can complete roughly 20 quizzes per hour. Offices, schools and mobile carriers often share an IP.

HMAC and secrets

Double opt-in token

  • Secret: option quizably_doi_secret, 32 random characters from wp_generate_password( 32, false ), created on first use.
  • Token: base64url( lead_id ) . '.' . hash_hmac( 'sha256', lead_id, secret ), where base64url swaps +/= for -_~.
  • Verification: hash_equals() on the signature. A token is rejected when no secret exists yet, so it cannot be forged with an empty key. The token has no expiry and can be used more than once (a repeat click reports the address as already confirmed). It is deterministic per lead, so the same lead always gets the same token.
  • The emailed link is home_url( '/?quizably_doi=TOKEN' ). A front-end handler verifies it, marks the lead confirmed and shows a plain WordPress message page. GET /public/confirm-optin?token=... does the same and returns JSON. See Double opt-in.
  • The secret is removed when the plugin’s data is erased on uninstall (see Data and privacy).

Webhook signing

  • When the quiz’s webhook configuration contains a secret, the dispatcher adds the header X-Quizably-Signature: sha256=<hex>, an HMAC-SHA256 of the exact request body using that secret. Without a secret there is no signature header.
  • The secret lives in plain text in the quiz’s settings JSON (integrations.webhook.secret) and is returned to anyone with the capability by GET /quizzes/{id}. The free Integrations tab only shows a URL field, so set the secret through the REST API if you need signing. The multi-webhook rows that have a Secret field belong to Pro.
  • There is no signature timestamp header. The body has a timestamp field, which is covered by the signature, so check it to limit replays.
$body      = file_get_contents( 'php://input' );
$expected  = 'sha256=' . hash_hmac( 'sha256', $body, YOUR_SECRET );
$received  = $_SERVER['HTTP_X_QUIZABLY_SIGNATURE'] ?? '';
if ( ! hash_equals( $expected, $received ) ) {
    http_response_code( 401 );
    exit;
}
// Node, Express with raw body
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-Quizably-Signature') || ''));

timingSafeEqual throws if the two buffers differ in length, so compare lengths first in real code.

Webhook request safety

Real deliveries run in the background through WordPress cron. They require wp_http_validate_url() and reject localhost, 0.0.0.0 and IPv4 addresses in loopback, link-local and RFC 1918 ranges, resolved with gethostbynamel() at send time. IPv6 is not checked, a hostname that cannot be resolved is allowed through, and a name could resolve differently when the request is actually made. The “Send test” endpoint (POST /quizzes/{id}/test-webhook) skips the private-address check. TLS verification is on. See Webhooks.

CSV export caveats

  • Lead CSVs (leads/export, quizzes/{id}/leads/export) are written with fputcsv(). Any cell that comes from a visitor or author and starts with =, +, -, @, a tab or a carriage return gets a leading apostrophe, so a spreadsheet does not run it as a formula. Columns the plugin generates itself (id, consent, dates, score, quiz id, opt-in status) are left as they are.
  • The analytics CSV is written the same way but has no such prefixing. It holds question titles and counts, which authors write.
  • Lead exports stream all matching rows. Besides the contact fields they include the quiz, result, score, double opt-in status, UTM tags, custom fields and one column per question.

Personal data and deletion

The plugin stores no cookies and stores no raw IP addresses. It registers no WordPress personal data exporter or eraser. Deleting a lead removes only the lead row. The linked submission with its answers, hashed IP, User-Agent and UTM data stays. Deleting a quiz with force=1 removes its submissions as well. See Data and privacy.

Last updated October 4, 2026.