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
| Guard | Applies to |
|---|---|
quizably_manage_quizzes | Every 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_options | The “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_quizzesbut withoutmanage_optionscan 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 withmanage_optionsbut 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_idis 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
| Surface | Access |
|---|---|
GET /ping | Public, not rate limited. Returns plugin version. |
GET /public/quiz/{uuid} | Public, rate limited. Published quizzes only. |
GET /public/quiz/{uuid}/poll-results | Public, rate limited. Published polls only. Returns vote counts for the poll’s first question. |
POST /public/submissions/start, /{uuid}/answer, /{uuid}/complete, /{uuid}/optin | Public, rate limited, no nonce, no CAPTCHA. |
GET /public/confirm-optin | Public, not rate limited, protected by an HMAC token. |
All other quizably/v1 routes | Require quizably_manage_quizzes. |
[quizably_quiz_result] shortcode | Public. 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 page | Public, 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 isin_progress, and can call the opt-in route for it at any status. - The server does not check that a posted
question_idoranswer_idsbelong 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_gdpris whatever the client sends. - The public quiz payload removes
is_correct,points,weights, resultconditionsandauthor_id, but still contains each answer’spersonality_result_idand questionlogic.
Nonces
- Admin REST calls: the admin app receives a
wp_restnonce asQUIZABLY_ADMIN.nonceand sends it as theX-WP-Nonceheader. The plugin has nowp_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(awp_restnonce). 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
| Input | Handling |
|---|---|
Public opt-in email | sanitize_email() then is_email(). |
Lead name, phone | sanitize_text_field(). |
Lead extra_fields | Keys sanitize_key(), values sanitize_text_field(), values over 1000 characters dropped, 20 keys maximum. |
| Text answers | sanitize_textarea_field(), trimmed, cut to 2000 characters, non-scalar input discarded. |
utm_* | Five known keys only, sanitize_text_field(). |
| User-Agent | sanitize_text_field(), cut to 255 characters. |
Quiz slug | sanitize_title(). |
Question description, result content | wp_kses_post() on create and update through the REST API. |
| Settings | Per-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 similar | Stored as submitted. No sanitization in the REST layer. |
| Quiz import | Values 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()andesc_url(). The result shortcode runswp_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 withquizably_manage_quizzescan store markup in a title that runs for every visitor. Administrators normally haveunfiltered_htmlalready, 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.
| Item | Value |
|---|---|
| Per-minute limit | 60 (filter quizably_rate_limit_per_minute) |
| Per-hour limit | 300 (filter quizably_rate_limit_per_hour) |
| Minute key | quizably_rl_m_{hash}_{floor(time/60)}, expires after 65 seconds |
| Hour key | quizably_rl_h_{hash}_{floor(time/3600)}, expires after 3700 seconds |
Identifier {hash} | `substr( sha256( REMOTE_ADDR . ’ |
| Response when exceeded | HTTP 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_ADDRis used. Forwarding headers are ignored on purpose so clients cannot spoof them. If the site is behind a proxy or CDN that does not setREMOTE_ADDRto the visitor, every visitor shares one identifier and the whole site is capped at 300 public requests per hour. Use thequizably_rate_limit_ipfilter 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 fromwp_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 headerX-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
settingsJSON (integrations.webhook.secret) and is returned to anyone with the capability byGET /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
timestampfield, 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 withfputcsv(). 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.
Related
Last updated October 4, 2026.